From 8c323718010b490d01ef7c8db778f4d4272d8682 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 5 Oct 2026 23:18:11 -0400 Subject: [PATCH 01/43] =?UTF-8?q?docs(plan):=20requirements=20slice=201=20?= =?UTF-8?q?=E2=80=94=20a=20uniform=20requirement-test=20filter=20in=20all?= =?UTF-8?q?=20five=20ports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's answer to the plan's open question 8: the filter that selects which requirements get a generated test offers the same options in every port. Table I gains the filter and uncovered-warning rows, Table J gains seven filter cases run through each port's predicate seam, and a separate worked-example case replaces the double duty the plan gave concern-fanout. Refs the plan and ADR-0057. --- ...ents-slice-1-checks-and-test-generators.md | 96 ++++++++++++++----- 1 file changed, 70 insertions(+), 26 deletions(-) diff --git a/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md b/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md index 7747de25b..e679cd005 100644 --- a/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md +++ b/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md @@ -157,7 +157,7 @@ One record per generated test. This is what the identity corpus pins, and it is | `skip` | `null` when the status is `live` or `partial`; otherwise the status (`planned` or `retired`). Derived from the status lists, never a literal set. | | `digest` | Table G. | -Which requirements get a test: the default filter is **functional and `level >= 4`**. Requirements the filter excludes produce one warning, capped at five names, with the text of `requirement-tests.ts:179-183`. +Which requirements get a test: the default filter is **functional and `level >= 4`**. Every port lets a project replace it with its own predicate over the requirement view (Table I). Requirements the filter excludes produce one warning, capped at five names, with the text of `requirement-tests.ts:179-183`; every port can switch that warning off. Two records with the same `witnessKey` are a **collision**. Every generator outside TypeScript refuses to generate, with code `ERR_REQUIREMENT_WITNESS_KEY_COLLISION` and a message naming both `id` values. TypeScript's generator does not use the key in its output and does not refuse. @@ -294,8 +294,10 @@ def test_req_acme_shop_Orders_Refunded(): | Renderer hook. Receives the Table F record plus `statement`, `counterexample`, `targets` (`ref`, `concern`), `disposition`, `trackedBy`. Its result replaces the default text of that one test. | existing `renderers` and `resolveRenderer`; `RequirementTestArgs` gains `package`, `unit`, `id`, `witnessKey`, `skip`, `digest` | `requirement_tests(renderer=…)`; config `requirementTests.renderer` as `module:symbol`. Returns `RenderedTest(imports, source)` or `None` for the default | generator arg `renderer`: the class name of a `RequirementTestRenderer` | as Java, an `IRequirementTestRenderer` | | Strict switch: row 10 of Table C becomes severity `error`, code unchanged | `meta verify --require-implementers`, or `META_REQUIRE_IMPLEMENTERS=1` | `metaobjects verify --require-implementers`, same variable | `-Dmeta.verify.requireImplementers=true`, same variable | `dotnet meta verify --require-implementers`, same variable | | Witness location | not applicable | `requirement_tests(witness_module=…)`; config `requirementTests.witnessModule` | generator args `testPackage` and `witnessClass` | the same two, C# names | +| Filter: a predicate over the requirement view. Absent, the default of Table F applies. | `requirementTests({ filter })` | `requirement_tests(filter=…)`; config `requirementTests.filter` as `module:symbol` | generator arg `filter`: the class name of a `RequirementTestFilter` (`boolean include(RequirementView view)`), loaded the way the renderer is | as Java, an `IRequirementTestFilter` (`bool Include(RequirementView view)`) | +| Uncovered warning: name the requirements the filter excluded (Table F). Default on. | `requirementTests({ warnUncovered })` | `requirement_tests(warn_uncovered=…)`; config `requirementTests.warnUncovered` | generator arg `warnUncovered` (`true` or `false`) | as Java | -A filter predicate stays a code-level option: TypeScript `filter` and Python `requirement_tests(filter=…)`. Java, Kotlin and C# ship the default filter only in this slice. +**The filter is uniform (owner's answer 8).** Every port offers the same two options, and every port's predicate receives the same projection, the **requirement view**: `subType`, `level` (absent on an unlevelled architectural requirement), `status`, `path`, `package` (the effective package) and `implementedByTypes` (the distinct `.` of the resolved targets, first-seen order). The view never hands out the node. A port that cannot express one of the two options is wrong; the identity corpus's `filter-*` cases (Table J) run each port's predicate seam. The flag is not called `--strict`: `cli/src/lib/args.ts:357` records why a `--strict` beside `--lax` misreads. @@ -351,7 +353,19 @@ Both follow the field-lint corpus shape: each case is a directory with `input/` | `coverage-library-overlay-does-not-activate` | An overlay on a library requirement does not | | `summary-undecided-rollup` | A partial parent over a partial child counts once, at the child; a grandchild excludes both ancestors | -**`fixtures/requirement-test-identity-conformance/`**. `options.json` key: `grain`. `expected.json`: +**`fixtures/requirement-test-identity-conformance/`**. `options.json` keys: `grain`, and `filter`, the **name** of one predicate from the closed list below. A predicate cannot be written in a language-neutral file, so the corpus names it and each port's runner implements the list in its own language and passes the predicate through the port's public filter seam. That is what makes the seam, and every field of the view it receives, a five-port contract. With no `filter` key the port's default applies. + +| `filter` | The predicate over the requirement view | +|---|---| +| `all` | always true | +| `architectural` | `subType` is `architectural` | +| `live` | `status` is `live` | +| `level-5` | `level` is `5` | +| `package-acme-shop` | `package` is `acme::shop` | +| `path-under-Shop` | `path` is `Shop` or starts with `Shop.` | +| `claims-entity` | `implementedByTypes` contains `object.entity` | + +`expected.json`: ```json { "tests": [ { "id": "acme::shop::Orders.Recorded [object.entity]", "package": "acme::shop", @@ -367,6 +381,7 @@ Both follow the field-lint corpus shape: each case is a directory with `input/` | Case | Pins | |---|---| | `default-filter` | L1 to L3 and architectural nodes get no test; functional L4 and L5 do | +| `worked-example` | The model of Tables F, G and H exactly: `Orders.Recorded` (functional, level 4, live, claiming `Order`) and `Orders.Refunded` (functional, level 4, planned, no links) in `acme::shop`. Both digests are the pinned ones. Every port's generator test renders this case | | `concern-fanout` | One requirement claiming two entities and a template yields two tests | | `no-targets` | A live L4 with no `implementedBy` yields one test, unit `*` | | `planned-skip`, `retired-skip`, `partial-not-skipped` | `skip` per status; a planned requirement naming absent nodes has unit `*` | @@ -378,6 +393,13 @@ Both follow the field-lint corpus shape: each case is a directory with `input/` | `digest-inherited` | A requirement inheriting its statement through `extends` hashes the effective text | | `digest-multibyte-and-crlf` | A non-ASCII statement and a `\r\n` in the counterexample | | `witness-key-collision` | `Orders.Recorded` beside `Orders_Recorded`: one entry in `collisions` | +| `filter-all` | `filter: all` over an L1 to L5 tree with one architectural policy: every requirement gets a test, the L1 to L3 nodes with unit `*` | +| `filter-by-subtype` | `filter: architectural`: an unlevelled policy and a levelled one get tests, the functional nodes none. Pins `subType`, and that an absent `level` reaches the predicate as absent | +| `filter-by-status` | `filter: live`: a partial and a planned requirement are dropped. Pins `status` | +| `filter-by-level` | `filter: level-5`: only the L5 nodes. Pins `level` | +| `filter-by-package` | `filter: package-acme-shop` over two packages, one requirement taking its package from the file default. Pins `package` as the effective package | +| `filter-by-path` | `filter: path-under-Shop`: `Shop` and its descendants, not a sibling `Shopfront`. Pins `path` | +| `filter-by-claimed-concern` | `filter: claims-entity`: a requirement claiming an entity is kept, one claiming only a template or only an unresolvable name is dropped. Pins `implementedByTypes` as the resolved concerns | ### Table K — where the code lives in each port @@ -395,7 +417,7 @@ Each port also adds a library-package accessor beside its embedded manifests: Py ## File structure -**Shared, new:** `fixtures/requirement-check-conformance/` (a `README.md` and the 42 cases of Table J), `fixtures/requirement-test-identity-conformance/` (a `README.md` and 16 cases), `spec/decisions/ADR-0057-requirement-checks-and-tests-in-every-port.md`, `scripts/write-requirement-corpus-expected.ts`. +**Shared, new:** `fixtures/requirement-check-conformance/` (a `README.md` and the 42 cases of Table J), `fixtures/requirement-test-identity-conformance/` (a `README.md` and 24 cases), `spec/decisions/ADR-0057-requirement-checks-and-tests-in-every-port.md`, `scripts/write-requirement-corpus-expected.ts`. **Shared, modified:** `fixtures/generator-registry-conformance/registry.json` (the `requirement-tests` entry's `ports`, one port per generator task). @@ -456,7 +478,7 @@ export interface RequirementScan { } ``` -- [ ] **Step 1: Write the ADR** in Nygard format. Context: the decision recorded only in `docs/CONFORMANCE.md` "Split coverage" ("Checks — TypeScript only, by decision … one implementation of a build-time gate rather than five") and why it was made. Decision: the owner's ruling that every language runs the requirement checks and scaffolds tests from requirements; the gate is implemented per port and held to the TypeScript reference by `requirement-check-conformance`; the generator is implemented per port and held by `requirement-test-identity-conformance`; the witness model replaces the three-way merge outside TypeScript; `verify` still never reads test results. Consequences: a change to a gate code, a message, the identity or the digest is now a five-port change with a corpus edit; the analogy with ADR-0015 no longer applies to requirements and ADR-0015 itself is unchanged; the authoring lint stays TypeScript-only until ruled on (open question 1). +- [ ] **Step 1: Write the ADR** in Nygard format. Context: the decision recorded only in `docs/CONFORMANCE.md` "Split coverage" ("Checks — TypeScript only, by decision … one implementation of a build-time gate rather than five") and why it was made. Decision: the owner's ruling that every language runs the requirement checks and scaffolds tests from requirements; the gate is implemented per port and held to the TypeScript reference by `requirement-check-conformance`; the generator is implemented per port and held by `requirement-test-identity-conformance`; the witness model replaces the three-way merge outside TypeScript; the filter that selects which requirements get a test offers the same two options in every port (a predicate over one shared requirement view, and the uncovered-warning switch); the generator, its renderer and the witness model are a **recommended approach, not a contract**: the generator is ejectable in every port through that port's existing eject mechanism, and an application may change or replace its copy freely, while the checks in `verify` stay stock because they are the shared contract; `verify` still never reads test results. Consequences: a change to a gate code, a message, the identity or the digest is now a five-port change with a corpus edit; the analogy with ADR-0015 no longer applies to requirements and ADR-0015 itself is unchanged; the seven authoring-lint advisories stay TypeScript-only in this slice, by the owner's ruling, and porting them is a follow-up slice. - [ ] **Step 2: Failing tests.** In `requirement-check.test.ts`: `requireImplementers raises nothing-implements to an error and keeps the code` (a live functional L4 with no links: default scan gives severity `warn`; `scanRequirements(root, { requireImplementers: true })` gives severity `error`, code `WARN_REQUIREMENT_NOTHING_IMPLEMENTS`, same path and message) and `requireImplementers changes no other diagnostic`. In `args-verify.test.ts`: `--require-implementers` parses to `requireImplementers: true`, default `false`. In `verify-requirements-e2e.test.ts`: the flag, and `META_REQUIRE_IMPLEMENTERS=1`, each turn a run with only that warning from exit 0 to exit 1. Run: `cd server/typescript/packages/cli && bun test test/unit/requirement-check.test.ts test/unit/args-verify.test.ts test/verify-requirements-e2e.test.ts`. Expected: the new tests FAIL. @@ -550,7 +572,7 @@ test("witness keys follow Table F", () => { }); ``` -Also: `the view carries the effective package`; `member grain yields one identity per distinct resolving reference`; `a requirement with no resolved target yields unit *` in both grains; `skip is derived from the status lists` (planned and retired skip, live and partial do not); `identities come back sorted by id`; `two addresses that mangle alike are reported as a collision`; `the digest ignores title, notes, disposition and trackedBy`; `the digest normalises CRLF`. In `requirement-tests-generator.test.ts`: `grain: "member"` emits one file per reference and the renderer receives `digest` and `witnessKey`. In `requirement-test-render.test.ts`: `the default output is byte-identical with the new args present` (compare with the existing expected text, unchanged). +Also: `the view carries the effective package`; `member grain yields one identity per distinct resolving reference`; `a requirement with no resolved target yields unit *` in both grains; `skip is derived from the status lists` (planned and retired skip, live and partial do not); `identities come back sorted by id`; `a filter replaces the default and its view carries the effective package` (a predicate keeping only `package === "acme::shop"` over two packages); `two addresses that mangle alike are reported as a collision`; `the digest ignores title, notes, disposition and trackedBy`; `the digest normalises CRLF`. In `requirement-tests-generator.test.ts`: `grain: "member"` emits one file per reference and the renderer receives `digest` and `witnessKey`. In `requirement-test-render.test.ts`: `the default output is byte-identical with the new args present` (compare with the existing expected text, unchanged). Run: `cd server/typescript/packages/codegen-ts && bun test test/requirement-walk.test.ts test/requirement-tests-generator.test.ts test/requirement-test-render.test.ts`. Expected: the new tests FAIL. - [ ] **Step 2: Implement the digest and key.** @@ -602,7 +624,7 @@ export function witnessKeyOf(qualifiedAddress: string, unit: string): string { ### Task 4: The requirement-test identity corpus and its TypeScript runner **Files:** -- Create: `fixtures/requirement-test-identity-conformance/README.md` and the 16 case directories of Table J +- Create: `fixtures/requirement-test-identity-conformance/README.md` and the 24 case directories of Table J - Create: `server/typescript/packages/codegen-ts/test/requirement-test-identity-conformance.test.ts` - Modify: `scripts/write-requirement-corpus-expected.ts` (the `identity` corpus) @@ -610,11 +632,11 @@ export function witnessKeyOf(qualifiedAddress: string, unit: string): string { - Consumes: `requirementTestIdentities`, `witnessKeyCollisions` (Task 3). - Produces: the corpus Tasks 6, 8, 10 and 11 run. -- [ ] **Step 1: Write the runner** on the pattern of Task 2's: load strict, assert no load error, compute `requirementTestIdentities(root, { grain })` and `witnessKeyCollisions(...)`, compare with `expected.tests` (both sorted by `id`) and `expected.collisions`. Include the README-equals-disk test. -- [ ] **Step 2: Write the inputs** for each row of Table J. `concern-fanout` holds the worked example of Tables F and G exactly, so its expected digest is the pinned one. +- [ ] **Step 1: Write the runner** on the pattern of Task 2's: load strict, assert no load error, compute `requirementTestIdentities(root, { grain, filter })` (the `filter` option is a name, mapped to a predicate by a table in the runner that holds exactly the seven rows of Table J; an unknown name fails the test) and `witnessKeyCollisions(...)`, compare with `expected.tests` (both sorted by `id`) and `expected.collisions`. Include the README-equals-disk test. +- [ ] **Step 2: Write the inputs** for each row of Table J. `worked-example` holds the model of Tables F, G and H exactly, so its two expected digests are the pinned ones (`2714aa39…` and `4ddcd781…`). If the second does not come out as pinned, the input differs from the model the plan's author hashed: find the difference (level, statement or counterexample text) before touching Table H. Each `filter-*` case keeps a requirement the default filter would drop, or drops one it would keep, so a runner that ignores the option fails the case. - [ ] **Step 3: Write and review the expectations.** `bun scripts/write-requirement-corpus-expected.ts identity`, then check each file by hand: the unit set against Table F, each `witnessKey` by applying the mangle rule on paper, each `skip` against the status, and for `digest-claim-fields` that the digests differ and agree where the case says. Recompute one digest independently (`printf` the text of Table G into `sha256sum`) for `digest-multibyte-and-crlf`. - [ ] **Step 4: Write the README** (purpose, format, runner steps, case table, "Who asserts it"). -- [ ] **Step 5: Run.** `cd server/typescript/packages/codegen-ts && bun test test/requirement-test-identity-conformance.test.ts`. Expected: PASS, 17 tests. +- [ ] **Step 5: Run.** `cd server/typescript/packages/codegen-ts && bun test test/requirement-test-identity-conformance.test.ts`. Expected: PASS, 25 tests. - [ ] **Step 6: Commit.** `git commit -m "test(conformance): requirement-test identity corpus, run by the TypeScript reference"` --- @@ -697,6 +719,16 @@ class RequirementTestIdentity: skip: str | None # None | "planned" | "retired" digest: str +@dataclass(frozen=True) +class RequirementView: # what a filter receives; never the node + sub_type: str + level: int | None + status: str | None + path: str + package: str + implemented_by_types: tuple[str, ...] + +def default_requirement_test_filter(view: RequirementView) -> bool: ... def requirement_digest(node: MetaRequirement) -> str: ... def witness_key_of(qualified_address: str, unit: str) -> str: ... def requirement_test_identities(root, *, grain="concern", filter=None) -> list[RequirementTestIdentity]: ... @@ -729,13 +761,15 @@ requirementTests: witnessModule: tests.requirement_witnesses grain: member renderer: codegen.requirement_renderer:render + filter: codegen.requirement_filter:include + warnUncovered: false ``` - [ ] **Step 1: Read first.** `requirement-walk.ts`, `generators/requirement-tests.ts` and `templates/requirement-test.ts` (the escaping comments are the specification of Review Focus 1), then an existing Python generator factory and its registry entry, `eject.build_owned` (how a `module:symbol` is imported relative to the config), and `test_schema_and_loader_accept_EXACTLY_the_same_keys`. **UNVERIFIED:** how `metaobjects gen` reports or removes a generated file that is no longer emitted (`_diff_report`, `_is_ours_for` in `cli.py`), and whether a generator entry needs `source=` to appear in `eject`. -- [ ] **Step 2: Failing identity runner,** parametrized over `fixtures/requirement-test-identity-conformance/`, comparing records as dicts with the corpus's field names (`witnessKey`, not `witness_key`). Run it. Expected: FAIL. -- [ ] **Step 3: Implement `requirement_walk.py`** from Tables F and G. Digest lengths use `len(value.encode("utf-8"))`. Sort by `id` with plain string comparison. Run the identity runner. Expected: PASS for all 16 cases. +- [ ] **Step 2: Failing identity runner,** parametrized over `fixtures/requirement-test-identity-conformance/`, comparing records as dicts with the corpus's field names (`witnessKey`, not `witness_key`). The runner maps the `filter` name of `options.json` to a predicate over `RequirementView` with a table of exactly the seven rows of Table J, and passes it as `filter=`. Run it. Expected: FAIL. +- [ ] **Step 3: Implement `requirement_walk.py`** from Tables F and G. Digest lengths use `len(value.encode("utf-8"))`. Sort by `id` with plain string comparison. Run the identity runner. Expected: PASS for all 24 cases. - [ ] **Step 4: Failing generator tests** in `test_requirement_tests_generator.py`: - - `the worked example renders the reference file`: compare with the Python block of Table H, byte for byte. + - `the worked example renders the reference file`: generate from the identity corpus's `worked-example` input and compare with the Python block of Table H, byte for byte. - `one file per metamodel package`, `an unpackaged ledger writes test_root_requirements.py`. - `the output imports only importlib and pytest`: parse with `ast` and collect every `Import` and `ImportFrom`. - `the output parses under the Python 3.9 grammar`: `ast.parse(source, feature_version=(3, 9))`. @@ -746,10 +780,10 @@ requirementTests: - `grain="member" emits one test per reference`. - `a renderer hook replaces one test and receives the digest`; `a hook returning None keeps the default`; `the hook's imports are merged, deduplicated and sorted`. - `a model with no requirement emits nothing and warns nothing`. - - `requirements the filter excludes produce one capped warning`. + - `requirements the filter excludes produce one capped warning`; `warn_uncovered=False` silences it; `a filter keeps an L3 requirement the default drops, and it renders with unit *`; `requirementTests.filter in the config is imported as module:symbol and applied`. - `stale file`: generate for two packages, remove one package's requirements, generate again, and assert what `gen` and `verify --codegen` report for the file that is no longer emitted. Write the assertion to match the port's existing behaviour for any generator and name that behaviour in the test title. - [ ] **Step 5: Implement the generator.** The factory returns a generator that reads `ctx.loaded_root`, returns `[]` when it is `None` or holds no requirement, refuses on a collision, groups identities by package and renders each file per Table H. The default renderer is a function with the hook's signature. Register it: name `requirement-tests`, tier `native`, layer `capability`, description equal to the manifest's `concept`. -- [ ] **Step 6: Config.** Add `requirementTests` to `TOP_LEVEL_KEYS` and to the JSON Schema with its three string keys (`grain` an enum of `concern` and `member`), reject unknown keys, resolve `renderer` the way `providers` are resolved, and carry the block on `GeneratorBuildContext` so the registry factory builds `requirement_tests(...)` from it. With no block, the defaults apply. +- [ ] **Step 6: Config.** Add `requirementTests` to `TOP_LEVEL_KEYS` and to the JSON Schema with its five keys (`witnessModule`, `renderer` and `filter` strings, `grain` an enum of `concern` and `member`, `warnUncovered` a boolean), reject unknown keys, resolve `renderer` and `filter` the way `providers` are resolved, and carry the block on `GeneratorBuildContext` so the registry factory builds `requirement_tests(...)` from it. With no block, the defaults apply. - [ ] **Step 7: Run** `uv run pytest tests/conformance/test_requirement_test_identity_conformance.py tests/conformance/test_generator_registry_conformance.py tests/codegen/test_requirement_tests_generator.py tests/codegen/test_codegen_compile_conformance.py -q` and the config-key parity test. Expected: PASS. The compile gate's model has no requirement, so its output set is unchanged. - [ ] **Step 8: Add the Python row** to the identity corpus README. - [ ] **Step 9: Commit.** `git commit -m "feat(python): requirement-tests generator emitting pytest with project-owned witnesses"` @@ -804,7 +838,7 @@ Run: `mvn -q -f server/java/pom.xml -pl metadata test -Dtest=RequirementCheckCon ### Task 8: Java `requirement-tests` generator **Files:** -- Create: `server/java/metadata/src/main/java/com/metaobjects/requirement/RequirementTestIdentities.java` +- Create: `server/java/metadata/src/main/java/com/metaobjects/requirement/RequirementTestIdentities.java`, `RequirementTestFilter.java` - Create: `server/java/codegen-base/src/main/java/com/metaobjects/generator/requirement/JUnitRequirementTestsGenerator.java`, `RequirementTestRenderer.java`, `RequirementTestArgs.java`, `RenderedTest.java` - Modify: `codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java`, `codegen-base/pom.xml` (JUnit Jupiter API, test scope, for compiling generated output in the test), `fixtures/generator-registry-conformance/registry.json` (add `"java"`) - Test: `metadata/src/test/java/com/metaobjects/conformance/RequirementTestIdentityConformanceTest.java`, `codegen-base/src/test/java/com/metaobjects/generator/requirement/JUnitRequirementTestsGeneratorTest.java` @@ -820,21 +854,31 @@ public final class RequirementTestIdentities { String status, String skip, String digest) {} public static String digest(MetaRequirement node); public static String witnessKeyOf(String qualifiedAddress, String unit); - public static List identities(MetaRoot root, Grain grain); + /** What a filter receives; never the node. level and status may be null. */ + public record View(String subType, Integer level, String status, String path, String pkg, + List implementedByTypes) {} + public static boolean defaultFilter(View view); + /** A null filter means the default. */ + public static List identities(MetaRoot root, Grain grain, RequirementTestFilter filter); public static List witnessKeyCollisions(List tests); } +/** In `metadata`, beside the identities, because the identity function applies it. */ +public interface RequirementTestFilter { + boolean include(RequirementTestIdentities.View view); +} + public interface RequirementTestRenderer { /** Return null to keep the default rendering of this test. */ RenderedTest render(RequirementTestArgs args); } ``` -Generator args: `testPackage` (required), `witnessClass` (required, a fully-qualified class name), `grain` (`concern` or `member`), `renderer` (optional class name). +Generator args: `testPackage` (required), `witnessClass` (required, a fully-qualified class name), `grain` (`concern` or `member`), `renderer` (optional class name), `filter` (optional class name of a `RequirementTestFilter`), `warnUncovered` (`true` by default). - [ ] **Step 1: Read first.** `GeneratorBase.java` (`getArg`, the output-directory args), one existing `codegen-base` generator that writes Java source and its test, `CodegenCompileConformanceTest.java` in `codegen-spring` (how generated Java is compiled in a test), and how `MetaDataGeneratorMojo` instantiates a generator class by name. **UNVERIFIED:** whether `codegen-base` or the registry module should own the generator given `GeneratorRegistry` lives in `codegen-spring`; how a renderer class named in an arg can be loaded with the same class loader as the generator; how the JUnit Jupiter version is managed in `server/java/pom.xml` (it is used today only by the Kotlin and integration modules). -- [ ] **Step 2: Failing identity runner,** then implement `RequirementTestIdentities` from Tables F and G (`value.getBytes(StandardCharsets.UTF_8).length`, `MessageDigest` SHA-256, sort with `String.compareTo`). Run: `mvn -q -f server/java/pom.xml -pl metadata test -Dtest=RequirementTestIdentityConformanceTest`. Expected: FAIL, then PASS for all 16 cases. -- [ ] **Step 3: Failing generator tests:** the worked example emits `Requirements_acme_shop_Witnesses.java` and `Requirements_acme_shop_Test.java` in `testPackage`; the interface has one default member per non-skipped test and none for a skipped one; a skipped test carries `@Disabled` with the reason of Table H; the output imports only `org.junit.jupiter.api.*`; **the output compiles** with `javax.tools` together with a hand-written witness class, and invoking the test method on the compiled class throws `AssertionError` whose message holds `unimplemented requirement:` and the counterexample when the witness class does not override it, and returns normally when it does; prose with `"`, `\`, a newline and `*/` still compiles; a collision throws `GeneratorException` naming `ERR_REQUIREMENT_WITNESS_KEY_COLLISION` and both ids; `grain=member`; a renderer class replaces one test and receives the digest; a missing `witnessClass` arg is a clear `GeneratorException`; a model with no requirement writes nothing; `stale file` as in Task 6. +- [ ] **Step 2: Failing identity runner,** then implement `RequirementTestIdentities` from Tables F and G (`value.getBytes(StandardCharsets.UTF_8).length`, `MessageDigest` SHA-256, sort with `String.compareTo`). The runner maps the `filter` name of `options.json` to a `RequirementTestFilter` with a table of exactly the seven rows of Table J. Run: `mvn -q -f server/java/pom.xml -pl metadata test -Dtest=RequirementTestIdentityConformanceTest`. Expected: FAIL, then PASS for all 24 cases. +- [ ] **Step 3: Failing generator tests:** the worked example (the identity corpus's `worked-example` input) emits `Requirements_acme_shop_Witnesses.java` and `Requirements_acme_shop_Test.java` in `testPackage`; the interface has one default member per non-skipped test and none for a skipped one; a skipped test carries `@Disabled` with the reason of Table H; the output imports only `org.junit.jupiter.api.*`; **the output compiles** with `javax.tools` together with a hand-written witness class, and invoking the test method on the compiled class throws `AssertionError` whose message holds `unimplemented requirement:` and the counterexample when the witness class does not override it, and returns normally when it does; prose with `"`, `\`, a newline and `*/` still compiles; a collision throws `GeneratorException` naming `ERR_REQUIREMENT_WITNESS_KEY_COLLISION` and both ids; `grain=member`; a renderer class replaces one test and receives the digest; a `filter` class keeps an L3 requirement the default drops and it renders with unit `*`; the excluded requirements produce one capped warning and `warnUncovered=false` silences it; a `filter` class that cannot be loaded is a clear `GeneratorException`; a missing `witnessClass` arg is a clear `GeneratorException`; a model with no requirement writes nothing; `stale file` as in Task 6. - [ ] **Step 4: Implement** per Table H. The test class holds `private final Requirements__Witnesses witnesses = new ();`. Register `requirement-tests` with `Tier.NATIVE` and `Layer.CAPABILITY` (the enum's capability constant; read its name). - [ ] **Step 5: Run** `mvn -q -f server/java/pom.xml -pl metadata,codegen-base,codegen-spring -am test -Dtest='RequirementTestIdentityConformanceTest,JUnitRequirementTestsGeneratorTest,GeneratorRegistryConformanceTest,CodegenCompileConformanceTest'`. Expected: PASS. - [ ] **Step 6: Add the Java row** to the identity corpus README. @@ -873,11 +917,11 @@ Generator args: `testPackage` (required), `witnessClass` (required, a fully-qual **Interfaces:** - Consumes: Task 9's walk and resolver. -- Produces: `RequirementTestIdentities.Digest`, `WitnessKeyOf`, `Identities(root, grain)`, `WitnessKeyCollisions`; `record RequirementTestIdentity(string Package, string Path, string Unit, string Id, string WitnessKey, string? Status, string? Skip, string Digest)`; `interface IRequirementTestRenderer { RenderedTest? Render(RequirementTestArgs args); }`. +- Produces: `RequirementTestIdentities.Digest`, `WitnessKeyOf`, `DefaultFilter`, `Identities(root, grain, filter: null)`, `WitnessKeyCollisions`; `record RequirementView(string SubType, int? Level, string? Status, string Path, string Package, IReadOnlyList ImplementedByTypes)`; `interface IRequirementTestFilter { bool Include(RequirementView view); }`; `record RequirementTestIdentity(string Package, string Path, string Unit, string Id, string WitnessKey, string? Status, string? Skip, string Digest)`; `interface IRequirementTestRenderer { RenderedTest? Render(RequirementTestArgs args); }`. -- [ ] **Step 1: Read first.** `Generator.cs` (`IGenerator`, `GenContext`), an existing generator and its registry entry, `CodegenCompileConformanceTests.cs` (the Roslyn compile helper), `MetaObjects.Cli/GenCommand.cs` and `OwnedCopy.cs`. **UNVERIFIED:** how a per-generator option (`testNamespace`, `witnessClass`, `grain`, a renderer) reaches a C# generator from the CLI or a config file; decide from the code, keep the four names of Table I, and state the surface in the commit. **UNVERIFIED:** that `Xunit.Sdk.XunitException(string)` is usable from generated code under xUnit 2.9.2; if not, throw `System.InvalidOperationException` and say so in Table H when updating the docs. -- [ ] **Step 2: Failing identity runner,** then implement `RequirementTestIdentities` (`Encoding.UTF8.GetByteCount`, `SHA256.HashData`, lower-case hex, `StringComparer.Ordinal`). Expected: PASS for all 16 cases. -- [ ] **Step 3: Failing generator tests,** the C# equivalents of Task 8 Step 3: the two files per package; interface members only for non-skipped tests; `[Fact(Skip = "…")]`; only `Xunit` imported; **the output compiles** with Roslyn beside a hand-written witness class, and invoking the test method throws with the unimplemented message when the member is not implemented and returns when it is; the escaping case (`"`, `\`, a newline, `*/`); the collision refusal; member grain; the renderer hook; no requirements writes nothing; `stale file`. +- [ ] **Step 1: Read first.** `Generator.cs` (`IGenerator`, `GenContext`), an existing generator and its registry entry, `CodegenCompileConformanceTests.cs` (the Roslyn compile helper), `MetaObjects.Cli/GenCommand.cs` and `OwnedCopy.cs`. **UNVERIFIED:** how a per-generator option (`testNamespace`, `witnessClass`, `grain`, a renderer, a filter, `warnUncovered`) reaches a C# generator from the CLI or a config file; decide from the code, keep the six names of Table I, and state the surface in the commit. **UNVERIFIED:** that `Xunit.Sdk.XunitException(string)` is usable from generated code under xUnit 2.9.2; if not, throw `System.InvalidOperationException` and say so in Table H when updating the docs. +- [ ] **Step 2: Failing identity runner,** the runner mapping the `filter` name of `options.json` to an `IRequirementTestFilter` with a table of exactly the seven rows of Table J; then implement `RequirementTestIdentities` (`Encoding.UTF8.GetByteCount`, `SHA256.HashData`, lower-case hex, `StringComparer.Ordinal`). Expected: PASS for all 24 cases. +- [ ] **Step 3: Failing generator tests,** the C# equivalents of Task 8 Step 3: the two files per package; interface members only for non-skipped tests; `[Fact(Skip = "…")]`; only `Xunit` imported; **the output compiles** with Roslyn beside a hand-written witness class, and invoking the test method throws with the unimplemented message when the member is not implemented and returns when it is; the escaping case (`"`, `\`, a newline, `*/`); the collision refusal; member grain; the renderer hook; a filter keeps an L3 requirement the default drops; the capped uncovered warning and its switch; no requirements writes nothing; `stale file`. - [ ] **Step 4: Implement** per Table H and register `requirement-tests` (`Tier` native, `Layer` capability). - [ ] **Step 5: Run** `dotnet test server/csharp --filter "RequirementTestIdentityConformance|RequirementTestsGenerator|GeneratorRegistryConformance|CodegenCompileConformance"`. Expected: PASS. - [ ] **Step 6: Add the C# row** to the identity corpus README. @@ -893,10 +937,10 @@ Generator args: `testPackage` (required), `witnessClass` (required, a fully-qual - Test: `codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinRequirementTestsGeneratorTest.kt` **Interfaces:** -- Consumes: `RequirementTestIdentities`, `RequirementTestRenderer`, `RequirementTestArgs`, `RenderedTest` (Task 8). Same four generator args as Java. +- Consumes: `RequirementTestIdentities`, `RequirementTestRenderer`, `RequirementTestArgs`, `RenderedTest` (Task 8). Same six generator args as Java, the `filter` arg naming a `RequirementTestFilter` class. - [ ] **Step 1: Read first.** `KotlinNamesGenerator.kt` (a small generator and how it writes), `KotlinGenUtil.kt` (string escaping helpers, including `$`), `CodegenCompileConformanceTest.kt` and `KotlinSpringControllerGeneratorTest.kt` (kotlin-compile-testing). **UNVERIFIED:** that `codegen-kotlin` can see `codegen-base`'s `generator.requirement` types. -- [ ] **Step 2: Failing tests:** the worked example emits `Requirements_acme_shop_Witnesses.kt` (an interface whose members have default bodies) and `Requirements_acme_shop_Test.kt`; the emitted test function names equal the `witnessKey` values of the identity corpus's `concern-fanout` case (this is how Kotlin asserts the identity contract; the identity function itself is the JVM one Task 8 gates); skipped tests carry `@Disabled`; **the output compiles** with a hand-written witness class and behaves as in Task 8 when invoked; a counterexample containing `$name`, `"`, `\` and a newline compiles and survives into the message; the collision refusal; member grain; the renderer hook; no requirements writes nothing. +- [ ] **Step 2: Failing tests:** the worked example emits `Requirements_acme_shop_Witnesses.kt` (an interface whose members have default bodies) and `Requirements_acme_shop_Test.kt`; the emitted test function names equal the `witnessKey` values of the identity corpus's `worked-example` and `concern-fanout` cases (this is how Kotlin asserts the identity contract; the identity function itself is the JVM one Task 8 gates); skipped tests carry `@Disabled`; **the output compiles** with a hand-written witness class and behaves as in Task 8 when invoked; a counterexample containing `$name`, `"`, `\` and a newline compiles and survives into the message; the collision refusal; member grain; the renderer hook; a `filter` class keeps an L3 requirement the default drops; the capped uncovered warning and its switch; no requirements writes nothing. - [ ] **Step 3: Implement** per Table H and register `requirement-tests`. - [ ] **Step 4: Run** the `codegen-kotlin` unit suite for the new test, `GeneratorRegistryConformanceTest`, `CodegenCompileConformanceTest`, and the Exposed 1.x check module (`codegen-kotlin-exposed1x-check`) since it re-runs the Kotlin generators. Expected: PASS. - [ ] **Step 5: Add the Kotlin row** to the identity corpus README ("identity function inherits via Java; emitted names asserted by `KotlinRequirementTestsGeneratorTest`"). From cf1dc1479f2fe98dd00d413d51379d89c77ef8e2 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 5 Oct 2026 23:22:25 -0400 Subject: [PATCH 02/43] =?UTF-8?q?docs(plan):=20requirements=20slice=201=20?= =?UTF-8?q?=E2=80=94=20the=20requirement-tests=20generator=20ejects=20in?= =?UTF-8?q?=20every=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's added requirement: whatever ships for requirements and testing must eject and be owned by the application; the stock generator and the witness model are a recommended approach. Table L records how each port's existing eject mechanism takes the new generator and the byte-identity test that proves an unedited copy, and the port tasks gain the wiring. The open questions section becomes the record of the answers. Refs the plan and ADR-0057. --- ...ents-slice-1-checks-and-test-generators.md | 133 ++++++++++++------ 1 file changed, 92 insertions(+), 41 deletions(-) diff --git a/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md b/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md index e679cd005..daa86c3cb 100644 --- a/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md +++ b/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md @@ -12,6 +12,19 @@ **This is slice 1.** Not in this slice: publishing requirements across repositories, inherited requirements, the Python publisher and `deps` commands, transitive dependencies with the five-level test, and the response-model items. +## Amendments after the owner's answers (2026-10-05) + +The plan as merged asked nine questions. The owner answered them, and added one requirement while the work was starting. What changed in this document: + +| Change | Where | +|---|---| +| **Answer 8, "make it uniform".** The filter that selects which requirements get a test offers the same two options in all five ports: a predicate over one shared requirement view, and the uncovered-warning switch. | Table F, Table I (two new rows), Table J (the `filter` option, seven named predicates, seven `filter-*` cases), Tasks 3, 4, 6, 8, 10, 11 | +| **Added requirement: the generator is application-owned.** "Whatever we do for this around requirements and testing needs to also eject and be owned by the application. It may change or significantly. This is just a recommended approach." In every port the `requirement-tests` generator, with its default renderer, is ejectable through that port's existing eject mechanism, and a test proves an unedited ejected copy produces byte-identical output. The checks in `verify` stay stock: they are the shared contract. | Table L (new), Table K, Tasks 3, 6, 8, 10, 11, 12 | +| A separate identity case `worked-example` holds the model of Tables F, G and H; `concern-fanout` keeps its fan-out meaning. One case could not pin both. The identity corpus is 24 cases. | Table J, Tasks 4, 6, 8, 11 | +| Answers 1 to 7 and 9 take the plan's stated defaults, with answers 3 and 4 confirmed as written. | [Answered questions](#answered-questions) | + +Every port was confirmed to have an eject mechanism before Table L was written (`meta eject`, `metaobjects eject`, `mvn metaobjects:eject` for Java and Kotlin, `dotnet meta eject`); none had to be invented. + ## How this plan was verified Every path, function and test file cited below was read or located in the tree at `5153aa9dc`, unless it is marked **UNVERIFIED**. Read in full: `codegen-ts/src/requirement-walk.ts`, `codegen-ts/src/generators/requirement-tests.ts`, `codegen-ts/src/templates/requirement-test.ts`, `cli/src/lib/requirement-check.ts`, `cli/src/lib/requirement-lint.ts`, `metadata/src/core/requirement/resolve-claim.ts`, the `MetaRequirement` class, `requirement-constants.ts`, `cli/src/commands/verify.ts:670-800`, `fixtures/requirement-harness/README.md`, `scripts/generate-requirement-harness.ts`, `spec/metamodel/requirement.json` (all TypeScript paths are under `server/typescript/packages/`). The Python, Java and C# requirement classes, `verify` entry points and generator registries were read by outline and targeted search. @@ -47,7 +60,7 @@ What the code does that the documents do not say. Each row changes a task below: - **Bind at build time, never by runtime reflection** in JVM and .NET generated code (ADR-0001). Python's witness lookup is a by-name import in test code and is the one exception, stated in Table H. - ADR-0039: read effective properties with resolving accessors. Any `own*()` call carries a comment naming its sanctioned case. Python `attr()` is OWN; use `get_meta_attr()`. - TS: named constants for metamodel strings, no `any`, never `instanceof` a node from another package. -- The requirement-test generator is a **reference helper** (ADR-0034 Amendment 3). The requirement gate is **core** (`meta verify` is a drift gate). +- The requirement-test generator is a **reference helper** (ADR-0034 Amendment 3), and a **recommended approach, not a contract**: it is ejectable in every port (Table L) and an application may change its copy freely. The requirement gate is **core** (`meta verify` is a drift gate) and stays stock. - Public repo: no private project names, no absolute home paths, in code, fixtures, docs or commit messages. - **Release hold continues:** `main` carries `metamodelVersion 1.1`, so no 1.0.x PATCH is cut from it. This slice adds no vocabulary and can ship with 1.1. - Do not push implementation work until every port in the task's track is green. @@ -409,19 +422,35 @@ The checks live in each port's **core library**, not its CLI, because the genera |---|---|---|---|---| | TypeScript | existing: `cli/src/lib/requirement-check.ts`, `metadata/src/core/requirement/resolve-claim.ts` | existing: `cli/src/commands/verify.ts` | existing: `codegen-ts/src/generators/requirement-tests.ts`, `requirement-walk.ts` | existing | | Python (`server/python/src/metaobjects/`) | `meta/core/requirement/resolve_claim.py`, `requirement_check.py` | `cli.py` (`_cmd_verify`) | `codegen/requirement_walk.py`, `codegen/generators/requirement_tests_generator.py` | `codegen/generator_registry.py` | -| Java (`server/java/`) | `metadata/src/main/java/com/metaobjects/requirement/RequirementClaims.java`, `RequirementCheck.java`, `RequirementTestIdentities.java` | `maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java` | `codegen-base/src/main/java/com/metaobjects/generator/requirement/JUnitRequirementTestsGenerator.java` | `codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java` | +| Java (`server/java/`) | `metadata/src/main/java/com/metaobjects/requirement/RequirementClaims.java`, `RequirementCheck.java`, `RequirementTestIdentities.java` | `maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java` | `codegen-spring/src/main/java/com/metaobjects/generator/` (the package the other ejectable Java generators live in) `JUnitRequirementTestsGenerator.java`; the hook types in `codegen-base/src/main/java/com/metaobjects/generator/requirement/` | `codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java` | | Kotlin | Java's | Java's Maven goal | `codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRequirementTestsGenerator.kt` | `codegen-kotlin/.../GeneratorRegistry.kt` | | C# (`server/csharp/`) | `MetaObjects/Core/Requirement/RequirementClaims.cs`, `RequirementCheck.cs`, `RequirementTestIdentities.cs` | `MetaObjects.Cli/Program.cs`, `VerifyCommand.cs` | `MetaObjects.Codegen/Generators/RequirementTestsGenerator.cs` | `MetaObjects.Codegen/GeneratorRegistry.cs` | Each port also adds a library-package accessor beside its embedded manifests: Python `library_packages()` in `library/library_sources.py`, Java `LibrarySources.libraryPackages()`, C# `LibrarySources.LibraryPackages()`. +### Table L — eject: the application-owned copy + +Every port already has an eject mechanism; this slice wires the new generator into each and adds nothing to the mechanisms themselves. One rule makes that enough: **in every port the default rendering lives in the generator's own source file**, because every port's eject copies exactly one file per generator. One eject therefore takes the generator and its renderer together. + +| Port | What `eject` copies, and to where | What makes the generator ejectable | How the owned copy runs | The byte-identity test | +|---|---|---|---|---| +| TypeScript | `codegen-ts/src/reference/requirement-tests.ts` to `codegen/generators/requirement-tests.ts`. A new reference template holding the generator **and** the default stub renderer, importing only public exports of `@metaobjectsdev/codegen-ts` and `@metaobjectsdev/metadata` | `"requirement-tests"` in `REFERENCE_GENERATOR_NAMES` (`codegen-ts/src/reference-templates.ts`); the registry's `ejectable` flag is derived from it | the import swap in `metaobjects.config.ts` that `meta eject` prints | a `PAIRS["requirement-tests"]` entry in `codegen-ts/test/reference-byte-identical.test.ts`, over a model that has requirements, in both grains | +| Python | the module `codegen/generators/requirement_tests_generator.py`, whole, to `codegen/generators/requirement_tests_generator.py` | `source=` on the registry entry | `codegen.generators.requirement_tests_generator:` in `generators`. The factory named by `source=` takes the build context, so an owned copy still reads the `requirementTests` config block | in `tests/codegen/test_eject.py`: packaged and owned `gen` output compared byte for byte over the `worked-example` model with a `requirementTests` block set | +| Java | `JUnitRequirementTestsGenerator.java`, with its package line rewritten, to the project's `codegen/` module | `ejectPath(JUnitRequirementTestsGenerator.class)` on the `register(...)` line, an `` in `codegen-spring/pom.xml`, the name in `EjectedGeneratorsCompileTest` | the `` swap `metaobjects:eject` prints | a round trip on the pattern of `maven-plugin`'s `EjectRoundTripTest` proof B: the unchanged ejected copy, compiled, emits the same bytes as the packaged class | +| Kotlin | `KotlinRequirementTestsGenerator.kt`, package line rewritten | `ejectResourcePath = ejectPath("KotlinRequirementTestsGenerator")`, an `` in `codegen-kotlin/pom.xml`, the name in `ejectableSimpleNames` | the same `` swap | new in `codegen-kotlin`: the package-renamed copy is compiled with kotlin-compile-testing, run, and its output compared with the packaged generator's | +| C# | `Generators/RequirementTestsGenerator.cs`, namespace line rewritten, to `codegen/generators/` | `SourceFileName` on the registry entry and an `` item in `MetaObjects.Codegen.csproj` | the owned `codegen/Program.cs` lists `new RequirementTestsGenerator { … }` | on the pattern of `EjectedGeneratorCompileTests`: the rewritten copy compiled with Roslyn against the public API and compared file by file over the `worked-example` model (`meta.fitness.json` has no requirement, so the existing list alone would compare nothing) | + +What stays in the package, and why: the walk, the claim resolver, the identity function, the digest and the hook types. They are what the corpora pin and what the gate shares; an owned generator imports them from the package like any other code. An application that wants different identities writes them in its own copy. That is its right, and its `verify --codegen` then compares its own output with itself. + +The requirement checks are **not** ejectable in any port. They are the shared contract. + ## File structure **Shared, new:** `fixtures/requirement-check-conformance/` (a `README.md` and the 42 cases of Table J), `fixtures/requirement-test-identity-conformance/` (a `README.md` and 24 cases), `spec/decisions/ADR-0057-requirement-checks-and-tests-in-every-port.md`, `scripts/write-requirement-corpus-expected.ts`. **Shared, modified:** `fixtures/generator-registry-conformance/registry.json` (the `requirement-tests` entry's `ports`, one port per generator task). -**TypeScript, modified** (under `server/typescript/packages/`): `cli/src/lib/requirement-check.ts`, `cli/src/lib/args.ts`, `cli/src/commands/verify.ts`, `codegen-ts/src/requirement-walk.ts`, `codegen-ts/src/generators/requirement-tests.ts`, `codegen-ts/src/templates/requirement-test.ts`. **New tests:** `cli/test/requirement-check-conformance.test.ts`, `codegen-ts/test/requirement-test-identity-conformance.test.ts`. +**TypeScript, modified** (under `server/typescript/packages/`): `cli/src/lib/requirement-check.ts`, `cli/src/lib/args.ts`, `cli/src/commands/verify.ts`, `codegen-ts/src/requirement-walk.ts`, `codegen-ts/src/generators/requirement-tests.ts`, `codegen-ts/src/templates/requirement-test.ts`, `codegen-ts/src/reference-templates.ts`. **New:** `codegen-ts/src/reference/requirement-tests.ts` (Table L). **New tests:** `cli/test/requirement-check-conformance.test.ts`, `codegen-ts/test/requirement-test-identity-conformance.test.ts`. **Other ports:** Table K, and the test files named in Tasks 5 to 11. @@ -516,7 +545,9 @@ Run: `cd server/typescript/packages/cli && bun test test/unit/requirement-check. **Files:** - Modify: `server/typescript/packages/codegen-ts/src/requirement-walk.ts`, `src/generators/requirement-tests.ts`, `src/templates/requirement-test.ts` - Modify: the package's public exports (**UNVERIFIED** file; find where `walkRequirements` is exported and add the new names beside it) -- Test: `codegen-ts/test/requirement-walk.test.ts`, `requirement-tests-generator.test.ts`, `requirement-test-render.test.ts` +- Create: `server/typescript/packages/codegen-ts/src/reference/requirement-tests.ts`; modify `src/reference-templates.ts` (Table L) +- Modify: `spec/decisions/ADR-0034-codegen-scaffold-and-own.md` (the 2026-09-26 correction lists `requirement-tests` among the generators that ship no reference template; record that it now ships one) and `docs/features/own-your-codegen.md` (the same list) +- Test: `codegen-ts/test/requirement-walk.test.ts`, `requirement-tests-generator.test.ts`, `requirement-test-render.test.ts`, `reference-byte-identical.test.ts`, `reference-templates.test.ts`, `cli/test/eject-multi.test.ts` **Interfaces:** - Produces, in `requirement-walk.ts`: @@ -618,6 +649,11 @@ export function witnessKeyOf(qualifiedAddress: string, unit: string): string { - [ ] **Step 4: Thread the generator.** Move `defaultFilter` out of `requirement-tests.ts` to the exported `defaultRequirementTestFilter`. Under `grain: "member"`, the generator iterates references instead of concerns; the default path's last segment is the mangled unit, so a qualified reference never puts `::` in a filename. Fill the six new `RequirementTestArgs` fields for every call. `renderRequirementTest` ignores them. - [ ] **Step 5: Run** the Step 1 command, `bun test test/requirement-orphans-e2e.test.ts test/requirement-stub-executes.test.ts`, and the workspace typecheck. Expected: PASS, with the existing render expectations untouched. - [ ] **Step 6: Commit.** `git commit -m "feat(codegen-ts): requirement test identities, the requirement digest and a per-member grain"` +- [ ] **Step 7: Read the eject machinery.** `cli/src/commands/eject.ts` (one file per name, read from `/src/reference/.ts`, written verbatim), `codegen-ts/src/reference-templates.ts`, one existing reference template with its header (`src/reference/output-parser.ts`), `test/reference-byte-identical.test.ts` (`PAIRS`), `test/reference-templates.test.ts` (the hard-coded name list and the header rules), `scripts/check-reference-templates-lint.ts`, and `cli/src/lib/catalog-listing.ts` (the `use-when:` and `emits:` header lines it reads). +- [ ] **Step 8: Failing tests.** Add `"requirement-tests"` to the name list in `reference-templates.test.ts`; add `PAIRS["requirement-tests"]` in `reference-byte-identical.test.ts`, run over a model that holds requirements (functional L4 and L5, one planned, one with no targets, one architectural) under the default grain and under `grain: "member"`, with a non-default `filter` in one run; in `cli/test/eject-multi.test.ts` (or the eject test beside it) `meta eject requirement-tests` writes `codegen/generators/requirement-tests.ts` and no longer answers `package-only`. Run them. Expected: FAIL. +- [ ] **Step 9: Write the reference template.** `src/reference/requirement-tests.ts` holds the generator and the default stub renderer in one file (Table L's one-file rule: the eject copies one file, and the renderer is what an application is most likely to change). It imports only public exports of `@metaobjectsdev/codegen-ts` and `@metaobjectsdev/metadata`; export from `codegen-ts/src/index.ts` any primitive it needs that is not public yet (the identity and digest functions of Step 2 and 3). The built-in in `src/generators/` stays the oracle. Add the name to `REFERENCE_GENERATOR_NAMES`. Update the two documents named under Files, and nothing else in them. +- [ ] **Step 10: Run** the Step 8 tests, `bun scripts/check-reference-templates-lint.ts` from the repository root, `test-generators`' `owned-copies-current.test.ts`, `codegen-ts/test/generator-registry.test.ts`, the CLI's `gen --list` snapshot if one holds the `package-only` marker, and the workspace typecheck. Expected: PASS. +- [ ] **Step 11: Commit.** `git commit -m "feat(codegen-ts): a reference template for requirement-tests, so meta eject can hand it to the application"` --- @@ -700,7 +736,7 @@ Run: `cd server/python && uv run pytest tests/conformance/test_requirement_check **Files:** - Create: `server/python/src/metaobjects/codegen/requirement_walk.py`, `codegen/generators/requirement_tests_generator.py` - Modify: `codegen/generator_registry.py`, `codegen/project_config.py`, `codegen/metaobjects-config.schema.json`, `cli.py` (pass the config block to the factory), `fixtures/generator-registry-conformance/registry.json` (add `"python"` to `requirement-tests`' `ports`) -- Test: `server/python/tests/conformance/test_requirement_test_identity_conformance.py`, `tests/codegen/test_requirement_tests_generator.py` +- Test: `server/python/tests/conformance/test_requirement_test_identity_conformance.py`, `tests/codegen/test_requirement_tests_generator.py`, `tests/codegen/test_eject.py` **Interfaces:** - Consumes: `collect_addressed_requirements`, `resolve_claim` (Task 5). @@ -752,8 +788,15 @@ class RenderedTest: def requirement_tests(*, witness_module: str = "tests.requirement_witnesses", grain: str = "concern", renderer=None, filter=None, warn_uncovered: bool = True) -> Generator: ... + +def requirement_tests_generator(ctx: GeneratorBuildContext) -> Generator: ... + # The registry's `source=`, and so the symbol an ejected copy is wired by. It takes + # the build context (one required positional parameter, which is what `build_owned` + # passes the context to) and builds requirement_tests(...) from the config block. ``` +The module holds the generator **and** the default renderer and nothing unrelated: `metaobjects eject` copies the module file whole (Table L). + Config block, in `metaobjects.config.yaml`: ```yaml @@ -765,7 +808,7 @@ requirementTests: warnUncovered: false ``` -- [ ] **Step 1: Read first.** `requirement-walk.ts`, `generators/requirement-tests.ts` and `templates/requirement-test.ts` (the escaping comments are the specification of Review Focus 1), then an existing Python generator factory and its registry entry, `eject.build_owned` (how a `module:symbol` is imported relative to the config), and `test_schema_and_loader_accept_EXACTLY_the_same_keys`. **UNVERIFIED:** how `metaobjects gen` reports or removes a generated file that is no longer emitted (`_diff_report`, `_is_ours_for` in `cli.py`), and whether a generator entry needs `source=` to appear in `eject`. +- [ ] **Step 1: Read first.** `requirement-walk.ts`, `generators/requirement-tests.ts` and `templates/requirement-test.ts` (the escaping comments are the specification of Review Focus 1), then an existing Python generator factory and its registry entry, `eject.build_owned` (how a `module:symbol` is imported relative to the config), and `test_schema_and_loader_accept_EXACTLY_the_same_keys`. **UNVERIFIED:** how `metaobjects gen` reports or removes a generated file that is no longer emitted (`_diff_report`, `_is_ours_for` in `cli.py`). Confirmed since the plan was written: a registry entry is ejectable when it carries `source=`, `tests/codegen/test_eject.py::test_every_ejectable_entry_names_its_source` fails on an entry without it, and `eject.build_owned` passes the build context to a factory with exactly one required positional parameter. Read `codegen/eject.py` and that test before writing the factory. - [ ] **Step 2: Failing identity runner,** parametrized over `fixtures/requirement-test-identity-conformance/`, comparing records as dicts with the corpus's field names (`witnessKey`, not `witness_key`). The runner maps the `filter` name of `options.json` to a predicate over `RequirementView` with a table of exactly the seven rows of Table J, and passes it as `filter=`. Run it. Expected: FAIL. - [ ] **Step 3: Implement `requirement_walk.py`** from Tables F and G. Digest lengths use `len(value.encode("utf-8"))`. Sort by `id` with plain string comparison. Run the identity runner. Expected: PASS for all 24 cases. - [ ] **Step 4: Failing generator tests** in `test_requirement_tests_generator.py`: @@ -781,8 +824,9 @@ requirementTests: - `a renderer hook replaces one test and receives the digest`; `a hook returning None keeps the default`; `the hook's imports are merged, deduplicated and sorted`. - `a model with no requirement emits nothing and warns nothing`. - `requirements the filter excludes produce one capped warning`; `warn_uncovered=False` silences it; `a filter keeps an L3 requirement the default drops, and it renders with unit *`; `requirementTests.filter in the config is imported as module:symbol and applied`. + - In `tests/codegen/test_eject.py`: `an unchanged ejected requirement-tests copy generates identical output` (eject, wire the owned `module:symbol`, run `gen` over the identity corpus's `worked-example` model with a `requirementTests` block that sets `witnessModule` and `grain`, and compare with the packaged generator's output byte for byte; the non-default block is what proves the owned copy still reads its options), and `an edited copy drives gen` (change the failure message in the copy and see it in the output). - `stale file`: generate for two packages, remove one package's requirements, generate again, and assert what `gen` and `verify --codegen` report for the file that is no longer emitted. Write the assertion to match the port's existing behaviour for any generator and name that behaviour in the test title. -- [ ] **Step 5: Implement the generator.** The factory returns a generator that reads `ctx.loaded_root`, returns `[]` when it is `None` or holds no requirement, refuses on a collision, groups identities by package and renders each file per Table H. The default renderer is a function with the hook's signature. Register it: name `requirement-tests`, tier `native`, layer `capability`, description equal to the manifest's `concept`. +- [ ] **Step 5: Implement the generator.** The factory returns a generator that reads `ctx.loaded_root`, returns `[]` when it is `None` or holds no requirement, refuses on a collision, groups identities by package and renders each file per Table H. The default renderer is a function with the hook's signature. Register it: name `requirement-tests`, tier `native`, layer `capability`, description equal to the manifest's `concept`, `factory=requirement_tests_generator` and `source=requirement_tests_generator`. - [ ] **Step 6: Config.** Add `requirementTests` to `TOP_LEVEL_KEYS` and to the JSON Schema with its five keys (`witnessModule`, `renderer` and `filter` strings, `grain` an enum of `concern` and `member`, `warnUncovered` a boolean), reject unknown keys, resolve `renderer` and `filter` the way `providers` are resolved, and carry the block on `GeneratorBuildContext` so the registry factory builds `requirement_tests(...)` from it. With no block, the defaults apply. - [ ] **Step 7: Run** `uv run pytest tests/conformance/test_requirement_test_identity_conformance.py tests/conformance/test_generator_registry_conformance.py tests/codegen/test_requirement_tests_generator.py tests/codegen/test_codegen_compile_conformance.py -q` and the config-key parity test. Expected: PASS. The compile gate's model has no requirement, so its output set is unchanged. - [ ] **Step 8: Add the Python row** to the identity corpus README. @@ -839,9 +883,10 @@ Run: `mvn -q -f server/java/pom.xml -pl metadata test -Dtest=RequirementCheckCon **Files:** - Create: `server/java/metadata/src/main/java/com/metaobjects/requirement/RequirementTestIdentities.java`, `RequirementTestFilter.java` -- Create: `server/java/codegen-base/src/main/java/com/metaobjects/generator/requirement/JUnitRequirementTestsGenerator.java`, `RequirementTestRenderer.java`, `RequirementTestArgs.java`, `RenderedTest.java` -- Modify: `codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java`, `codegen-base/pom.xml` (JUnit Jupiter API, test scope, for compiling generated output in the test), `fixtures/generator-registry-conformance/registry.json` (add `"java"`) -- Test: `metadata/src/test/java/com/metaobjects/conformance/RequirementTestIdentityConformanceTest.java`, `codegen-base/src/test/java/com/metaobjects/generator/requirement/JUnitRequirementTestsGeneratorTest.java` +- Create: `server/java/codegen-base/src/main/java/com/metaobjects/generator/requirement/RequirementTestRenderer.java`, `RequirementTestArgs.java`, `RenderedTest.java` (the hook types: package API, not ejected, and visible to `codegen-kotlin`) +- Create: `JUnitRequirementTestsGenerator.java` in `codegen-spring`, in the package the other ejectable Java generators live in. One file, holding the default rendering (Table L). It lives in `codegen-spring` because that module's `pom.xml` is what ships generator sources as eject resources. +- Modify: `codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java`, `codegen-spring/pom.xml` (the eject ``; JUnit Jupiter API, test scope, for compiling generated output in the test), `docs/ports/java.md` (the registry conformance test requires every registered generator to be named there by stable name and class), `fixtures/generator-registry-conformance/registry.json` (add `"java"`) +- Test: `metadata/src/test/java/com/metaobjects/conformance/RequirementTestIdentityConformanceTest.java`, `JUnitRequirementTestsGeneratorTest.java` beside the generator in `codegen-spring`, `codegen-spring`'s `EjectedGeneratorsCompileTest.java` (the name list), and a round-trip test in `maven-plugin` on the pattern of `EjectRoundTripTest.java` **Interfaces:** - Consumes: `RequirementCheck.collectAddressed`, `RequirementClaims.resolveClaim` (Task 7). @@ -876,11 +921,11 @@ public interface RequirementTestRenderer { Generator args: `testPackage` (required), `witnessClass` (required, a fully-qualified class name), `grain` (`concern` or `member`), `renderer` (optional class name), `filter` (optional class name of a `RequirementTestFilter`), `warnUncovered` (`true` by default). -- [ ] **Step 1: Read first.** `GeneratorBase.java` (`getArg`, the output-directory args), one existing `codegen-base` generator that writes Java source and its test, `CodegenCompileConformanceTest.java` in `codegen-spring` (how generated Java is compiled in a test), and how `MetaDataGeneratorMojo` instantiates a generator class by name. **UNVERIFIED:** whether `codegen-base` or the registry module should own the generator given `GeneratorRegistry` lives in `codegen-spring`; how a renderer class named in an arg can be loaded with the same class loader as the generator; how the JUnit Jupiter version is managed in `server/java/pom.xml` (it is used today only by the Kotlin and integration modules). +- [ ] **Step 1: Read first.** `GeneratorBase.java` (`getArg`, the output-directory args), one existing `codegen-base` generator that writes Java source and its test, `CodegenCompileConformanceTest.java` in `codegen-spring` (how generated Java is compiled in a test), and how `MetaDataGeneratorMojo` instantiates a generator class by name. Also read `maven-plugin`'s `MetaDataEjectMojo.java`, `EjectSupport.java` and `EjectRoundTripTest.java`, and `codegen-spring/pom.xml`'s eject `` list. Settled since the plan was written: the generator lives in `codegen-spring` (Table L). **UNVERIFIED:** how a renderer or filter class named in an arg can be loaded so that a class on the **project's** classpath is found. The mojo loads the generator with a project class loader whose parent is the plugin's, so a packaged generator's own loader does not see project classes; read `AbstractMetaDataMojo.buildGenerators` and `createProjectClassLoader`, and pass or set the loader rather than guess. No generator arg names a loadable class today, so this is new ground: stop and report if it cannot be done without changing how every generator is constructed; how the JUnit Jupiter version is managed in `server/java/pom.xml` (it is used today only by the Kotlin and integration modules). - [ ] **Step 2: Failing identity runner,** then implement `RequirementTestIdentities` from Tables F and G (`value.getBytes(StandardCharsets.UTF_8).length`, `MessageDigest` SHA-256, sort with `String.compareTo`). The runner maps the `filter` name of `options.json` to a `RequirementTestFilter` with a table of exactly the seven rows of Table J. Run: `mvn -q -f server/java/pom.xml -pl metadata test -Dtest=RequirementTestIdentityConformanceTest`. Expected: FAIL, then PASS for all 24 cases. -- [ ] **Step 3: Failing generator tests:** the worked example (the identity corpus's `worked-example` input) emits `Requirements_acme_shop_Witnesses.java` and `Requirements_acme_shop_Test.java` in `testPackage`; the interface has one default member per non-skipped test and none for a skipped one; a skipped test carries `@Disabled` with the reason of Table H; the output imports only `org.junit.jupiter.api.*`; **the output compiles** with `javax.tools` together with a hand-written witness class, and invoking the test method on the compiled class throws `AssertionError` whose message holds `unimplemented requirement:` and the counterexample when the witness class does not override it, and returns normally when it does; prose with `"`, `\`, a newline and `*/` still compiles; a collision throws `GeneratorException` naming `ERR_REQUIREMENT_WITNESS_KEY_COLLISION` and both ids; `grain=member`; a renderer class replaces one test and receives the digest; a `filter` class keeps an L3 requirement the default drops and it renders with unit `*`; the excluded requirements produce one capped warning and `warnUncovered=false` silences it; a `filter` class that cannot be loaded is a clear `GeneratorException`; a missing `witnessClass` arg is a clear `GeneratorException`; a model with no requirement writes nothing; `stale file` as in Task 6. -- [ ] **Step 4: Implement** per Table H. The test class holds `private final Requirements__Witnesses witnesses = new ();`. Register `requirement-tests` with `Tier.NATIVE` and `Layer.CAPABILITY` (the enum's capability constant; read its name). -- [ ] **Step 5: Run** `mvn -q -f server/java/pom.xml -pl metadata,codegen-base,codegen-spring -am test -Dtest='RequirementTestIdentityConformanceTest,JUnitRequirementTestsGeneratorTest,GeneratorRegistryConformanceTest,CodegenCompileConformanceTest'`. Expected: PASS. +- [ ] **Step 3: Failing generator tests:** the worked example (the identity corpus's `worked-example` input) emits `Requirements_acme_shop_Witnesses.java` and `Requirements_acme_shop_Test.java` in `testPackage`; the interface has one default member per non-skipped test and none for a skipped one; a skipped test carries `@Disabled` with the reason of Table H; the output imports only `org.junit.jupiter.api.*`; **the output compiles** with `javax.tools` together with a hand-written witness class, and invoking the test method on the compiled class throws `AssertionError` whose message holds `unimplemented requirement:` and the counterexample when the witness class does not override it, and returns normally when it does; prose with `"`, `\`, a newline and `*/` still compiles; a collision throws `GeneratorException` naming `ERR_REQUIREMENT_WITNESS_KEY_COLLISION` and both ids; `grain=member`; a renderer class replaces one test and receives the digest; a `filter` class keeps an L3 requirement the default drops and it renders with unit `*`; the excluded requirements produce one capped warning and `warnUncovered=false` silences it; a `filter` class that cannot be loaded is a clear `GeneratorException`; a missing `witnessClass` arg is a clear `GeneratorException`; a model with no requirement writes nothing; `stale file` as in Task 6. **Eject:** `requirement-tests` is in `EjectedGeneratorsCompileTest`'s list and compiles package-renamed against the published API; the round-trip test ejects it, compiles the unchanged copy and gets byte-identical output over the `worked-example` model with non-default `grain` and `testPackage` args; `EjectSupportTest` still passes with its hard-coded non-ejectable set unchanged. +- [ ] **Step 4: Implement** per Table H. The test class holds `private final Requirements__Witnesses witnesses = new ();`. Register `requirement-tests` with `Tier.NATIVE`, `Layer.CAPABILITY` (the enum's capability constant; read its name) and `ejectPath(JUnitRequirementTestsGenerator.class)`, add the `` to `codegen-spring/pom.xml`, and name the generator in `docs/ports/java.md`. Nothing in the generated header may carry the generator's own class or package name: the ejected copy has a different one, and its output must be byte-identical. +- [ ] **Step 5: Run** `mvn -q -f server/java/pom.xml -pl metadata,codegen-base,codegen-spring,maven-plugin -am test -Dtest='RequirementTestIdentityConformanceTest,JUnitRequirementTestsGeneratorTest,GeneratorRegistryConformanceTest,CodegenCompileConformanceTest,EjectedGeneratorsCompileTest,EjectSupportTest,EjectRoundTripTest,MetaDataEjectMojoTest'` plus the new round-trip test by name (add `-Dsurefire.failIfNoSpecifiedTests=false` if a module holds none of them). Expected: PASS. - [ ] **Step 6: Add the Java row** to the identity corpus README. - [ ] **Step 7: Commit.** `git commit -m "feat(java): requirement-tests generator emitting JUnit with a typed witness interface"` @@ -912,18 +957,18 @@ Generator args: `testPackage` (required), `witnessClass` (required, a fully-qual **Files:** - Create: `server/csharp/MetaObjects/Core/Requirement/RequirementTestIdentities.cs`, `MetaObjects.Codegen/Generators/RequirementTestsGenerator.cs` -- Modify: `MetaObjects.Codegen/GeneratorRegistry.cs`, `fixtures/generator-registry-conformance/registry.json` (add `"csharp"`) -- Test: `MetaObjects.Conformance.Tests/RequirementTestIdentityConformanceTests.cs`, `MetaObjects.Codegen.Tests/RequirementTestsGeneratorTests.cs` +- Modify: `MetaObjects.Codegen/GeneratorRegistry.cs` (the entry, with `SourceFileName`), `MetaObjects.Codegen/MetaObjects.Codegen.csproj` (the `` item), `fixtures/generator-registry-conformance/registry.json` (add `"csharp"`) +- Test: `MetaObjects.Conformance.Tests/RequirementTestIdentityConformanceTests.cs`, `MetaObjects.Codegen.Tests/RequirementTestsGeneratorTests.cs`, `MetaObjects.Codegen.Tests/EjectedGeneratorCompileTests.cs`, `EjectableGeneratorsTests.cs` **Interfaces:** - Consumes: Task 9's walk and resolver. - Produces: `RequirementTestIdentities.Digest`, `WitnessKeyOf`, `DefaultFilter`, `Identities(root, grain, filter: null)`, `WitnessKeyCollisions`; `record RequirementView(string SubType, int? Level, string? Status, string Path, string Package, IReadOnlyList ImplementedByTypes)`; `interface IRequirementTestFilter { bool Include(RequirementView view); }`; `record RequirementTestIdentity(string Package, string Path, string Unit, string Id, string WitnessKey, string? Status, string? Skip, string Digest)`; `interface IRequirementTestRenderer { RenderedTest? Render(RequirementTestArgs args); }`. -- [ ] **Step 1: Read first.** `Generator.cs` (`IGenerator`, `GenContext`), an existing generator and its registry entry, `CodegenCompileConformanceTests.cs` (the Roslyn compile helper), `MetaObjects.Cli/GenCommand.cs` and `OwnedCopy.cs`. **UNVERIFIED:** how a per-generator option (`testNamespace`, `witnessClass`, `grain`, a renderer, a filter, `warnUncovered`) reaches a C# generator from the CLI or a config file; decide from the code, keep the six names of Table I, and state the surface in the commit. **UNVERIFIED:** that `Xunit.Sdk.XunitException(string)` is usable from generated code under xUnit 2.9.2; if not, throw `System.InvalidOperationException` and say so in Table H when updating the docs. +- [ ] **Step 1: Read first.** `Generator.cs` (`IGenerator`, `GenContext`), an existing generator and its registry entry, `CodegenCompileConformanceTests.cs` (the Roslyn compile helper), `MetaObjects.Cli/GenCommand.cs` and `OwnedCopy.cs`. **UNVERIFIED:** how a per-generator option (`testNamespace`, `witnessClass`, `grain`, a renderer, a filter, `warnUncovered`) reaches a C# generator from the CLI or a config file; decide from the code, keep the six names of Table I, and state the surface in the commit. Known since the plan was written: no per-generator option channel exists (`GenConfig` is global to the run, `.metaobjects/config.json` carries `sources` and `libraries` only), an owned `codegen/Program.cs` constructs generators itself (`new X { … }`), and the packaged tool cannot load a project's classes. The surface that follows from that: **public init properties on `RequirementTestsGenerator`**, with defaults that make the packaged `dotnet meta gen --generators requirement-tests` run work with no option at all (derive the test namespace and the witness class name from `GenConfig.Namespace`), and the project sets any of the six in its `codegen/Program.cs`. Do not add keys to `.metaobjects/config.json`: it is the neutral config every port reads. Also read `EjectableGenerators.cs` (`RewriteForEject` needs the exact line `namespace MetaObjects.Codegen.Generators;`, and the file may use nothing `internal`), `EjectCommand.cs`, `EjectedGeneratorCompileTests.cs` and `EjectableGeneratorsTests.cs`. **UNVERIFIED:** that `Xunit.Sdk.XunitException(string)` is usable from generated code under xUnit 2.9.2; if not, throw `System.InvalidOperationException` and say so in Table H when updating the docs. - [ ] **Step 2: Failing identity runner,** the runner mapping the `filter` name of `options.json` to an `IRequirementTestFilter` with a table of exactly the seven rows of Table J; then implement `RequirementTestIdentities` (`Encoding.UTF8.GetByteCount`, `SHA256.HashData`, lower-case hex, `StringComparer.Ordinal`). Expected: PASS for all 24 cases. -- [ ] **Step 3: Failing generator tests,** the C# equivalents of Task 8 Step 3: the two files per package; interface members only for non-skipped tests; `[Fact(Skip = "…")]`; only `Xunit` imported; **the output compiles** with Roslyn beside a hand-written witness class, and invoking the test method throws with the unimplemented message when the member is not implemented and returns when it is; the escaping case (`"`, `\`, a newline, `*/`); the collision refusal; member grain; the renderer hook; a filter keeps an L3 requirement the default drops; the capped uncovered warning and its switch; no requirements writes nothing; `stale file`. -- [ ] **Step 4: Implement** per Table H and register `requirement-tests` (`Tier` native, `Layer` capability). -- [ ] **Step 5: Run** `dotnet test server/csharp --filter "RequirementTestIdentityConformance|RequirementTestsGenerator|GeneratorRegistryConformance|CodegenCompileConformance"`. Expected: PASS. +- [ ] **Step 3: Failing generator tests,** the C# equivalents of Task 8 Step 3: the two files per package; interface members only for non-skipped tests; `[Fact(Skip = "…")]`; only `Xunit` imported; **the output compiles** with Roslyn beside a hand-written witness class, and invoking the test method throws with the unimplemented message when the member is not implemented and returns when it is; the escaping case (`"`, `\`, a newline, `*/`); the collision refusal; member grain; the renderer hook; a filter keeps an L3 requirement the default drops; the capped uncovered warning and its switch; no requirements writes nothing; `stale file`. **Eject:** the embedded copy is byte-identical to `Generators/RequirementTestsGenerator.cs` and the embedded set still equals the registry's ejectable set (`EjectableGeneratorsTests`); the rewritten copy compiles with Roslyn against the public API only and, with non-default `Grain` and `TestNamespace`, emits the same bytes as the packaged generator over the `worked-example` model. +- [ ] **Step 4: Implement** per Table H, in one file that holds the default rendering (Table L), and register `requirement-tests` (`Tier` native, `Layer` capability, `SourceFileName = "RequirementTestsGenerator.cs"`) with its `` item. +- [ ] **Step 5: Run** `dotnet test server/csharp --filter "RequirementTestIdentityConformance|RequirementTestsGenerator|GeneratorRegistryConformance|CodegenCompileConformance|EjectableGenerators|EjectedGeneratorCompile|EjectEndToEnd"`. Expected: PASS. - [ ] **Step 6: Add the C# row** to the identity corpus README. - [ ] **Step 7: Commit.** `git commit -m "feat(csharp): requirement-tests generator emitting xUnit with a typed witness interface"` @@ -933,15 +978,15 @@ Generator args: `testPackage` (required), `witnessClass` (required, a fully-qual **Files:** - Create: `server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRequirementTestsGenerator.kt` -- Modify: `codegen-kotlin/.../GeneratorRegistry.kt`, `fixtures/generator-registry-conformance/registry.json` (add `"kotlin"`) -- Test: `codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinRequirementTestsGeneratorTest.kt` +- Modify: `codegen-kotlin/.../GeneratorRegistry.kt` (the entry, with `ejectResourcePath`), `codegen-kotlin/pom.xml` (the eject ``), `fixtures/generator-registry-conformance/registry.json` (add `"kotlin"`), `docs/ports/kotlin.md` if its registry conformance test requires the generator to be named there +- Test: `codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinRequirementTestsGeneratorTest.kt`, `EjectedGeneratorsCompileTest.kt` (`ejectableSimpleNames`) **Interfaces:** - Consumes: `RequirementTestIdentities`, `RequirementTestRenderer`, `RequirementTestArgs`, `RenderedTest` (Task 8). Same six generator args as Java, the `filter` arg naming a `RequirementTestFilter` class. - [ ] **Step 1: Read first.** `KotlinNamesGenerator.kt` (a small generator and how it writes), `KotlinGenUtil.kt` (string escaping helpers, including `$`), `CodegenCompileConformanceTest.kt` and `KotlinSpringControllerGeneratorTest.kt` (kotlin-compile-testing). **UNVERIFIED:** that `codegen-kotlin` can see `codegen-base`'s `generator.requirement` types. -- [ ] **Step 2: Failing tests:** the worked example emits `Requirements_acme_shop_Witnesses.kt` (an interface whose members have default bodies) and `Requirements_acme_shop_Test.kt`; the emitted test function names equal the `witnessKey` values of the identity corpus's `worked-example` and `concern-fanout` cases (this is how Kotlin asserts the identity contract; the identity function itself is the JVM one Task 8 gates); skipped tests carry `@Disabled`; **the output compiles** with a hand-written witness class and behaves as in Task 8 when invoked; a counterexample containing `$name`, `"`, `\` and a newline compiles and survives into the message; the collision refusal; member grain; the renderer hook; a `filter` class keeps an L3 requirement the default drops; the capped uncovered warning and its switch; no requirements writes nothing. -- [ ] **Step 3: Implement** per Table H and register `requirement-tests`. +- [ ] **Step 2: Failing tests:** the worked example emits `Requirements_acme_shop_Witnesses.kt` (an interface whose members have default bodies) and `Requirements_acme_shop_Test.kt`; the emitted test function names equal the `witnessKey` values of the identity corpus's `worked-example` and `concern-fanout` cases (this is how Kotlin asserts the identity contract; the identity function itself is the JVM one Task 8 gates); skipped tests carry `@Disabled`; **the output compiles** with a hand-written witness class and behaves as in Task 8 when invoked; a counterexample containing `$name`, `"`, `\` and a newline compiles and survives into the message; the collision refusal; member grain; the renderer hook; a `filter` class keeps an L3 requirement the default drops; the capped uncovered warning and its switch; no requirements writes nothing. **Eject:** `KotlinRequirementTestsGenerator` is in `ejectableSimpleNames` and compiles package-renamed; and, new for this port, the package-renamed copy is instantiated from the compiled result, run over the `worked-example` model with non-default `grain` and `testPackage`, and its output is byte-identical to the packaged generator's. +- [ ] **Step 3: Implement** per Table H, in one file that holds the default rendering (Table L), and register `requirement-tests` with `ejectResourcePath = ejectPath("KotlinRequirementTestsGenerator")` and the `` in `codegen-kotlin/pom.xml`. - [ ] **Step 4: Run** the `codegen-kotlin` unit suite for the new test, `GeneratorRegistryConformanceTest`, `CodegenCompileConformanceTest`, and the Exposed 1.x check module (`codegen-kotlin-exposed1x-check`) since it re-runs the Kotlin generators. Expected: PASS. - [ ] **Step 5: Add the Kotlin row** to the identity corpus README ("identity function inherits via Java; emitted names asserted by `KotlinRequirementTestsGeneratorTest`"). - [ ] **Step 6: Commit.** `git commit -m "feat(kotlin): requirement-tests generator emitting JUnit with a typed witness interface"` @@ -960,6 +1005,8 @@ Generator args: `testPackage` (required), `witnessClass` (required, a fully-qual - Modify: `metaobjects/meta.requirements.yaml`, then regenerate `fixtures/requirement-harness/*` - [ ] **Step 1: `docs/features/requirements.md`.** State the gate as Tables C to E in prose, per port, with each port's command. State the witness model as Table H with one short example per ecosystem and the one-time setup (create the witness module or class). State the strict switch. Keep "What a green run does not prove" and add: a generated test that passes proves the witness ran, not that the witness tests the claim. +- [ ] **Step 1b: Present it as a recommendation.** In `docs/features/requirements.md`, the section on generated tests opens by saying what it is: the stock generator and the witness model are a **recommended approach**, shipped as a reference helper, and an application may change or replace them. Give each port's eject command (`meta eject requirement-tests`, `metaobjects eject requirement-tests`, `mvn metaobjects:eject -Dgenerator=requirement-tests` in the form that goal really takes, `dotnet meta eject requirement-tests`; **run each before documenting it**), say what the copy holds (the generator and its default renderer) and what stays in the package (the identity function, the digest and the checks), and say plainly that the checks in `verify` are not ejectable because they are the contract. Add the generator to `docs/features/own-your-codegen.md` where that page lists what each port can eject. The same framing goes into the per-port `metaobjects-codegen` skill paragraphs and the `docs/ports/*.md` rows. +- [ ] **Step 1c: `CLAUDE.md` and `AGENTS.md`, one more sentence this change makes false:** the "Core vs helpers" paragraph lists `requirement-tests` among the TypeScript generators that ship no reference copy. Remove that one name from the list. Nothing else. - [ ] **Step 2: `docs/ports/python.md`, "Selecting the interpreter".** `metaobjects` needs Python 3.11 or later to **run**. The tests it generates need only pytest and run on the project's own interpreter, 3.9 or later. A project on an older interpreter runs the tool with a different one, without changing its own: `uv tool run --python 3.12 metaobjects gen` and `uv tool run --python 3.12 metaobjects verify` (**UNVERIFIED:** run both before documenting them, and add the `pipx run --python` form only if it is confirmed). Say plainly that the floor is not being lowered. - [ ] **Step 3: Skills, then regenerate.** @@ -973,7 +1020,7 @@ cd ../../../.. && bun scripts/check-doc-examples.ts Expected: PASS. - [ ] **Step 4: Counts.** Two new corpora take the shared-corpus count from 26 to 28 wherever it is stated (`docs/CONFORMANCE.md`, `CLAUDE.md`, `AGENTS.md`). Run `bun test scripts/site/counts.test.ts`. **UNVERIFIED:** what that test counts; read it first. - [ ] **Step 5: The project's own ledger.** Read the entries in `metaobjects/meta.requirements.yaml` about the requirement gate and requirement tests. Move to a non-`planned` status only what this slice makes true, with an `implementedBy` that resolves, then run `bun scripts/generate-requirement-harness.ts` and `scripts/check-requirements-ledger.ts`. **UNVERIFIED:** which entries those are; if none, change nothing and say so in the commit. -- [ ] **Step 6: CHANGELOG `[Unreleased]`.** Added: the requirement gate in `metaobjects verify`, `metaobjects:verify` and `dotnet meta verify`; a `requirement-tests` generator in Python, Java, Kotlin and C#; `--require-implementers` in every port; `grain` and the digest on the TypeScript generator. State that a project with no `requirement.*` node sees no change, and that a project **with** requirements on a non-TypeScript port will now see diagnostics it did not see before, and may see a failing `verify`. +- [ ] **Step 6: CHANGELOG `[Unreleased]`.** Added: the requirement gate in `metaobjects verify`, `metaobjects:verify` and `dotnet meta verify`; a `requirement-tests` generator in Python, Java, Kotlin and C#, ejectable in each; a TypeScript reference template for `requirement-tests`, so `meta eject requirement-tests` works instead of answering `package-only`; `--require-implementers` in every port; `grain` and the digest on the TypeScript generator; the same filter and uncovered-warning options on the generator in every port. State that a project with no `requirement.*` node sees no change, and that a project **with** requirements on a non-TypeScript port will now see diagnostics it did not see before, and may see a failing `verify`. - [ ] **Step 7: Commit.** `git commit -m "docs(requirements): the gate and the requirement-test generator in every port (ADR-0057)"` --- @@ -1011,28 +1058,32 @@ Each is the first step of the task that touches it. |---|---| | No corpus case has been loaded yet; a case of Table J may need a different model shape to load strict, or may be unreachable | 2, 4 | | Where `codegen-ts` exports `walkRequirements` from, and whether `node:crypto` is already used in that package | 3 | -| What `ejectable("requirement-tests")` means in `codegen-ts/src/generator-registry.ts:404`, given no copy exists under `codegen-ts/src/reference/` | 3 | +| ~~What `ejectable("requirement-tests")` means~~ Resolved: the flag is derived from `REFERENCE_GENERATOR_NAMES`, so it is `false` until Task 3 Step 9 ships the reference template | 3 | | Python: the effective package of a nested requirement in a multi-file collection; the resolved-super accessor and the abstract flag on `MetaData`; that `in_scope` matches TypeScript's `inScope` | 5 | | That each port's did-you-mean hint is byte-identical to TypeScript's (the corpus pins it) | 5, 7, 9 | | How each port's `gen` and `verify --codegen` treat a generated file that is no longer emitted | 6, 8, 10, 11 | -| Whether a Python registry entry needs `source=` to be ejectable, and whether `requirement-tests` should be | 6 | +| ~~Whether a Python registry entry needs `source=`~~ Resolved: it does, and `requirement-tests` is ejectable in every port by the owner's requirement (Table L) | 6 | | Java: the file-default-package and abstract accessors on `MetaData`; whether `getPackage()` is set on a nested requirement; the Java language level of `metadata` | 7 | -| Java: which module should own the generator; loading a renderer class named in a generator arg; JUnit Jupiter version management | 8 | +| Java: loading a renderer or filter class named in a generator arg from the project's classpath; JUnit Jupiter version management. (Which module owns the generator is resolved: `codegen-spring`, Table L) | 8 | | C#: the output prefix of `FieldLint.RunAdvisory`; where `verify` computes its exit code | 9 | -| C#: how a per-generator option reaches a generator; `Xunit.Sdk.XunitException` from generated code | 10 | -| Kotlin: that `codegen-kotlin` sees `codegen-base`'s new types | 11 | +| C#: `Xunit.Sdk.XunitException` from generated code. (The option surface is resolved: init properties on the generator, Task 10 Step 1) | 10 | +| ~~Kotlin: that `codegen-kotlin` sees `codegen-base`'s new types~~ Resolved: it depends on `metaobjects-codegen-base` | 11 | | The `uv tool run --python` commands (not run) | 12 | | What `scripts/site/counts.test.ts` counts; whether `README.md` states the TypeScript-only split; which ledger entries this slice makes true | 12 | | That a requirement can nest only under a requirement or the root (read from `requirement.json`'s child rules only, not from a loader) | 2 | -## Open questions for the captain - -1. **The seven authoring-lint advisories** (`requirement-lint.ts`). The plan ports the eleven gate codes and the summary, and leaves the advisory lint TypeScript-only. It never fails a build, and it is about 380 lines of prose rules per port. Default: a follow-up slice. Say if "do them all" includes it now. -2. **The strict switch.** The plan raises `WARN_REQUIREMENT_NOTHING_IMPLEMENTS` to severity error and keeps the code, as the object-coverage constant already does, and names the flag `--require-implementers`. The alternative is a new `ERR_` code. Default: keep the code. -3. **How a JVM or .NET test finds its witness.** The plan generates an interface with a failing default per test and has the project name one class that implements it: no reflection, and a deleted requirement breaks a stale override at compile time. The cost is one compile error until the project creates that class. The alternative is a by-name lookup at run time, as Python does. Default: the interface. -4. **Java's test framework.** Generated Java and Kotlin tests use JUnit Jupiter. A JUnit 4 project would use the renderer hook. Default: Jupiter only. -5. **What the digest covers.** Subtype, level, status, statement, counterexample and the `implementedBy` list. A change to title, notes, disposition or `trackedBy` does not change it. Default: those six. -6. **Message text is pinned by the corpus.** Rewording a diagnostic becomes a five-port change. The field-lint corpus already works this way. Default: pinned. -7. **TypeScript keeps its own model.** TypeScript stubs stay hand-filled and merged; the witness model is not offered there in this slice. Default: unchanged. -8. **Filters outside TypeScript and Python.** Java, Kotlin and C# ship the default filter (functional, L4 and L5) with no way to change it short of the renderer hook. Default: accept for this slice. -9. **Tracking.** This work has no FR number or issue in the repository. Default: commits cite this plan and ADR-0057; assign a number if one is wanted before Task 1. +## Answered questions + +The plan as merged asked the owner nine questions. The answers, for the record: + +1. **The seven authoring-lint advisories** stay TypeScript-only in this slice. Porting them is a follow-up slice. +2. **The strict switch** keeps the `WARN_REQUIREMENT_NOTHING_IMPLEMENTS` code and raises it to severity `error` behind `--require-implementers`. +3. **How a JVM or .NET test finds its witness:** a generated interface with a failing default per test; the project names one implementing class. No run-time lookup by name. +4. **Java's test framework:** generated Java and Kotlin tests are JUnit Jupiter only. A JUnit 4 project uses the renderer hook, or ejects the generator. +5. **The digest** covers subtype, level, status, statement, counterexample and the `implementedBy` list. +6. **Message text is pinned by the corpus.** +7. **TypeScript keeps its own model:** hand-filled, merged stubs. +8. **Filters: "make it uniform."** The same two options in all five ports (Table I). This is the one answer that changed the plan. +9. **Tracking:** commits cite this plan and ADR-0057. + +Added after the questions were answered: the generator must eject and be owned by the application in every port; it is a recommended approach, not a contract (Table L). From 10c49a761be2134d81693a09047c5655fcb70315 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 5 Oct 2026 23:26:07 -0400 Subject: [PATCH 03/43] feat(verify): --require-implementers, and ADR-0057 reversing TypeScript-only requirement checks Refs docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md and ADR-0057. --- .../packages/cli/src/commands/verify.ts | 9 +- server/typescript/packages/cli/src/index.ts | 13 ++ .../typescript/packages/cli/src/lib/args.ts | 17 ++ .../packages/cli/src/lib/requirement-check.ts | 16 +- .../cli/test/__snapshots__/cli.test.ts.snap | 5 + .../cli/test/unit/args-verify.test.ts | 17 +- .../cli/test/unit/requirement-check.test.ts | 97 ++++++++- .../cli/test/verify-requirements-e2e.test.ts | 91 ++++++++ ...uirement-checks-and-tests-in-every-port.md | 195 ++++++++++++++++++ spec/decisions/README.md | 1 + 10 files changed, 452 insertions(+), 9 deletions(-) create mode 100644 spec/decisions/ADR-0057-requirement-checks-and-tests-in-every-port.md diff --git a/server/typescript/packages/cli/src/commands/verify.ts b/server/typescript/packages/cli/src/commands/verify.ts index 92404b10f..9002b9f4e 100644 --- a/server/typescript/packages/cli/src/commands/verify.ts +++ b/server/typescript/packages/cli/src/commands/verify.ts @@ -684,7 +684,14 @@ export async function verifyCommand( // ONE scan for all three passes. The gate and the summary each used to walk the // model AND resolve every @implementedBy claim for themselves — the resolution // being the expensive half — and the lint added a third walk on top. - const scan = scanRequirements(root, { coverable: collection.inScope }); + // The strict switch (ADR-0057) is decided here, once, and travels on the scan: the + // flag or META_REQUIRE_IMPLEMENTERS=1 — a flag-or-variable pair, as + // --no-requirement-lint has below, so a CI job can turn it on without editing a + // command line it shares with local runs. + const scan = scanRequirements(root, { + coverable: collection.inScope, + requireImplementers: flags.requireImplementers || process.env.META_REQUIRE_IMPLEMENTERS === "1", + }); const diags = [...checkRequirements(root, scan)]; // Printed on EVERY run, clean or not — a gate that says nothing when it diff --git a/server/typescript/packages/cli/src/index.ts b/server/typescript/packages/cli/src/index.ts index 9cf7ef350..519ed3c27 100644 --- a/server/typescript/packages/cli/src/index.ts +++ b/server/typescript/packages/cli/src/index.ts @@ -141,6 +141,11 @@ VERIFY FLAGS (ADR-0021 D2 — explicit subverbs; combine any; exit 1 on ANY drif --no-antipatterns Suppress the advisory "you hand-rolled what MetaObjects can model" pass (aggregate/currency/enum hints; warnings only) --no-requirement-lint Suppress the advisory requirement AUTHORING lint (not the gate) + --require-implementers + Fail (exit 1) when a live functional requirement has nothing + implementing it. That finding is a warning without this flag; + with it, the same WARN_REQUIREMENT_NOTHING_IMPLEMENTS code is + reported as an error. Also: META_REQUIRE_IMPLEMENTERS=1. --no-overlay-lint Suppress the advisory overlay-redeclaration AUTHORING lint (never a gate — this lint can't fail the build) --no-name-lint Suppress the advisory node-name AUTHORING lint (whitespace @@ -341,6 +346,11 @@ FLAGS: migrating a model onto a registered provider. --no-antipatterns Suppress the advisory "hand-rolled what MetaObjects can model" pass --no-requirement-lint Suppress the advisory requirement AUTHORING lint (not the gate) + --require-implementers + Fail (exit 1) when a live functional requirement has nothing + implementing it. That finding is a warning without this flag; + with it, the same WARN_REQUIREMENT_NOTHING_IMPLEMENTS code is + reported as an error. Also: META_REQUIRE_IMPLEMENTERS=1. --no-overlay-lint Suppress the advisory overlay-redeclaration AUTHORING lint — never a gate; this lint can't fail the build --no-name-lint Suppress the advisory node-name AUTHORING lint (whitespace in @@ -373,6 +383,9 @@ lint in its own section: names that are not addressable, prose slots holding one sentence twice, content written where no surface reads it. Warnings only — it can never fail the build. Opt out with --no-requirement-lint or META_NO_REQUIREMENT_LINT=1. The requirements GATE itself (dangling refs, link floor, levels) always runs. +One of its findings is a warning you can raise: a live functional requirement that +nothing implements fails the build under --require-implementers or +META_REQUIRE_IMPLEMENTERS=1. The diagnostic keeps its code; only its severity moves. verify also prints an ADVISORY overlay authoring lint, in its own section: a top-level declaration redeclared in two or more files where more than one diff --git a/server/typescript/packages/cli/src/lib/args.ts b/server/typescript/packages/cli/src/lib/args.ts index b0815b662..54cb051b7 100644 --- a/server/typescript/packages/cli/src/lib/args.ts +++ b/server/typescript/packages/cli/src/lib/args.ts @@ -381,6 +381,21 @@ export interface VerifyFlags { * the half that CAN fail a build switched on. Same shape as --no-antipatterns. */ noRequirementLint: boolean; + /** + * The requirement gate's strict switch (ADR-0057): report a live functional + * requirement that nothing implements (`WARN_REQUIREMENT_NOTHING_IMPLEMENTS`) at + * severity `error`, so it fails the build. The code is unchanged and no other + * diagnostic moves. Off by default because a ledger authored ahead of its links is + * the normal incremental state. + * + * Named for what it requires, not `--strict`: `--lax` below is a different axis + * (ADR-0023 attribute strictness), and a `--strict` beside it would read as that + * flag's opposite rather than as a requirement-gate setting — the same reason + * `--replay-snapshot` above is a subverb and not a `--strict` modifier. It is not + * an explicit subverb either: the requirement gate runs on every `verify`, so this + * flag modifies a gate that is already selected. + */ + requireImplementers: boolean; /** * Suppress the advisory overlay AUTHORING lint (FR-023 §11.1 item 4) — the * finding that an unflagged cross-file redeclaration works today only @@ -452,6 +467,7 @@ export const VERIFY_OPTIONS = { "replay-snapshot": { type: "boolean", default: false }, "no-antipatterns": { type: "boolean", default: false }, "no-requirement-lint": { type: "boolean", default: false }, + "require-implementers": { type: "boolean", default: false }, "no-overlay-lint": { type: "boolean", default: false }, "no-name-lint": { type: "boolean", default: false }, "no-deprecation-lint": { type: "boolean", default: false }, @@ -539,6 +555,7 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags { anyExplicit, noAntipatterns: !!values["no-antipatterns"], noRequirementLint: !!values["no-requirement-lint"], + requireImplementers: !!values["require-implementers"], noOverlayLint: !!values["no-overlay-lint"], noNameLint: !!values["no-name-lint"], noDeprecationLint: !!values["no-deprecation-lint"], diff --git a/server/typescript/packages/cli/src/lib/requirement-check.ts b/server/typescript/packages/cli/src/lib/requirement-check.ts index 35018ab13..dfe86abf3 100644 --- a/server/typescript/packages/cli/src/lib/requirement-check.ts +++ b/server/typescript/packages/cli/src/lib/requirement-check.ts @@ -279,6 +279,11 @@ export interface RequirementScan { * printed a ratio the gate had not enforced would be a measurement nobody could * reconcile with the diagnostics beneath it. */ readonly measureCoverage: boolean; + /** ADR-0057 — the strict switch. When true, the functional EXISTENCE finding + * (`WARN_REQUIREMENT_NOTHING_IMPLEMENTS`) is reported at severity `error`; the code, + * path and message do not change, and no other diagnostic is touched. Carried on + * the scan, like `measureCoverage`, so a caller decides it once per run. */ + readonly requireImplementers: boolean; } /** @@ -321,6 +326,8 @@ export function scanRequirements( * by its own ledger" is exactly what is being asserted — so the derivation would * switch the check off precisely where it is the point. */ measureCoverage?: boolean; + /** Raise WARN_REQUIREMENT_NOTHING_IMPLEMENTS to severity "error". Default false. */ + requireImplementers?: boolean; }, ): RequirementScan { const addressed = collectAddressedRequirements(root); @@ -328,6 +335,7 @@ export function scanRequirements( addressed, claimedObjects: claimedObjectKeys(root, addressed.map((r) => r.node)), measureCoverage: opts?.measureCoverage ?? projectAuthoredRequirements(addressed), + requireImplementers: opts?.requireImplementers ?? false, // `exactOptionalPropertyTypes` — an omitted key, never an explicit `undefined`. ...(opts?.coverable !== undefined ? { coverable: opts.coverable } : {}), }; @@ -593,9 +601,15 @@ export function checkRequirements(root: MetaData, scan: RequirementScan = scanRe // tree. So the question is not "does this node claim anything" but "does // anything in this subtree claim anything". A live L1 whose entire subtree // is empty is a capability declared and built by nobody. + // + // The strict switch (ADR-0057, `meta verify --require-implementers`) raises the + // SEVERITY for a project whose ledger has caught up with its links. The code + // keeps its `WARN_` name on purpose: it identifies the finding, and one finding + // under two codes would split every suppression and corpus case that keys on it. if (!architectural && live && !subtreeClaimsAnything(req)) { out.push({ - severity: "warn", code: WARN_REQUIREMENT_NOTHING_IMPLEMENTS, path: reqPath, + severity: scan.requireImplementers ? "error" : "warn", + code: WARN_REQUIREMENT_NOTHING_IMPLEMENTS, path: reqPath, message: `is '${String(status)}' but neither it nor anything nested under it names an ` + `implementing node. A functional requirement's check is existence — a subtree that claims ` + `nothing is a capability nobody built.`, diff --git a/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap b/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap index 9b18bde00..8e96556ff 100644 --- a/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap +++ b/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap @@ -121,6 +121,11 @@ VERIFY FLAGS (ADR-0021 D2 — explicit subverbs; combine any; exit 1 on ANY drif --no-antipatterns Suppress the advisory "you hand-rolled what MetaObjects can model" pass (aggregate/currency/enum hints; warnings only) --no-requirement-lint Suppress the advisory requirement AUTHORING lint (not the gate) + --require-implementers + Fail (exit 1) when a live functional requirement has nothing + implementing it. That finding is a warning without this flag; + with it, the same WARN_REQUIREMENT_NOTHING_IMPLEMENTS code is + reported as an error. Also: META_REQUIRE_IMPLEMENTERS=1. --no-overlay-lint Suppress the advisory overlay-redeclaration AUTHORING lint (never a gate — this lint can't fail the build) --no-name-lint Suppress the advisory node-name AUTHORING lint (whitespace diff --git a/server/typescript/packages/cli/test/unit/args-verify.test.ts b/server/typescript/packages/cli/test/unit/args-verify.test.ts index 38d850fa5..300528fce 100644 --- a/server/typescript/packages/cli/test/unit/args-verify.test.ts +++ b/server/typescript/packages/cli/test/unit/args-verify.test.ts @@ -6,7 +6,7 @@ describe("parseVerifyArgs", () => { test("defaults: prompts/db/dialect undefined, allow empty, skipSchema false, no explicit subverb", () => { expect(parseVerifyArgs([])).toEqual({ prompts: undefined, db: undefined, dialect: undefined, allow: [], skipSchema: false, - templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: false, noAntipatterns: false, noRequirementLint: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, noFieldLint: false, lax: false, + templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: false, noAntipatterns: false, noRequirementLint: false, requireImplementers: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, noFieldLint: false, lax: false, replay: false, replaySnapshot: false, d1: undefined, remote: false, limit: DEFAULT_ADVISORY_LIMIT, }); @@ -14,7 +14,7 @@ describe("parseVerifyArgs", () => { test("--prompts is captured", () => { expect(parseVerifyArgs(["--prompts", "templates"])).toEqual({ prompts: "templates", db: undefined, dialect: undefined, allow: [], skipSchema: false, - templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: false, noAntipatterns: false, noRequirementLint: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, noFieldLint: false, lax: false, + templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: false, noAntipatterns: false, noRequirementLint: false, requireImplementers: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, noFieldLint: false, lax: false, replay: false, replaySnapshot: false, d1: undefined, remote: false, limit: DEFAULT_ADVISORY_LIMIT, }); @@ -22,7 +22,7 @@ describe("parseVerifyArgs", () => { test("--db / --dialect / --skip-schema are captured", () => { expect(parseVerifyArgs(["--db", "file:x.db", "--dialect", "sqlite", "--skip-schema"])).toEqual({ prompts: undefined, db: "file:x.db", dialect: "sqlite", allow: [], skipSchema: true, - templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: true, noAntipatterns: false, noRequirementLint: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, noFieldLint: false, lax: false, + templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: true, noAntipatterns: false, noRequirementLint: false, requireImplementers: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, noFieldLint: false, lax: false, replay: false, replaySnapshot: false, d1: undefined, remote: false, limit: DEFAULT_ADVISORY_LIMIT, }); @@ -32,7 +32,7 @@ describe("parseVerifyArgs", () => { test("--dialect d1 --d1 --remote are captured; --dialect d1 alone is an explicit subverb", () => { expect(parseVerifyArgs(["--dialect", "d1", "--d1", "DB", "--remote"])).toEqual({ prompts: undefined, db: undefined, dialect: "d1", allow: [], skipSchema: false, - templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: true, noAntipatterns: false, noRequirementLint: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, noFieldLint: false, lax: false, + templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: true, noAntipatterns: false, noRequirementLint: false, requireImplementers: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, noFieldLint: false, lax: false, replay: false, replaySnapshot: false, d1: "DB", remote: true, limit: DEFAULT_ADVISORY_LIMIT, }); @@ -61,7 +61,7 @@ describe("parseVerifyArgs", () => { expect(parseVerifyArgs(["--allow", "drop-column,drop-table"])).toEqual({ prompts: undefined, db: undefined, dialect: undefined, allow: ["drop-column", "drop-table"], skipSchema: false, - templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: false, noAntipatterns: false, noRequirementLint: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, noFieldLint: false, lax: false, + templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: false, noAntipatterns: false, noRequirementLint: false, requireImplementers: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, noFieldLint: false, lax: false, replay: false, replaySnapshot: false, d1: undefined, remote: false, limit: DEFAULT_ADVISORY_LIMIT, }); @@ -134,6 +134,13 @@ describe("parseVerifyArgs", () => { // is the whole reason the two print as separate sections. expect(parseVerifyArgs(["--no-requirement-lint"]).anyExplicit).toBe(false); }); + test("--require-implementers parses to requireImplementers: true, and defaults false", () => { + expect(parseVerifyArgs([]).requireImplementers).toBe(false); + expect(parseVerifyArgs(["--require-implementers"]).requireImplementers).toBe(true); + // A modifier on the requirement gate, which runs on every verify — not a subverb, + // so it must not switch the bare-verify template default off. + expect(parseVerifyArgs(["--require-implementers"]).anyExplicit).toBe(false); + }); test("--forbid-hand-edits rides on --codegen, and is refused without it", () => { expect(parseVerifyArgs(["--codegen"]).forbidHandEdits).toBe(false); expect(parseVerifyArgs(["--codegen", "--forbid-hand-edits"]).forbidHandEdits).toBe(true); diff --git a/server/typescript/packages/cli/test/unit/requirement-check.test.ts b/server/typescript/packages/cli/test/unit/requirement-check.test.ts index 65ec67db5..ab2ad96f4 100644 --- a/server/typescript/packages/cli/test/unit/requirement-check.test.ts +++ b/server/typescript/packages/cli/test/unit/requirement-check.test.ts @@ -23,6 +23,7 @@ import { } from "@metaobjectsdev/metadata"; import { checkRequirements, + scanRequirements, summariseRequirements, collectRequirements, splitMemberRef, @@ -36,6 +37,8 @@ import { ERR_REQUIREMENT_L5_NOT_MEMBER, ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS, WARN_REQUIREMENT_OBJECT_UNCLAIMED, + WARN_REQUIREMENT_NOTHING_IMPLEMENTS, + WARN_REQUIREMENT_DEFERRED_UNTRACKED, type Diagnostic, } from "../../src/lib/requirement-check.js"; @@ -134,7 +137,13 @@ interface Loaded { diags: Diagnostic[]; } /** Load a model + requirement declarations and run the check. Returns the loader * error instead when the load itself is rejected — several tests assert that the * LOADER, not this CLI, is what refuses bad input. */ -async function run(capsYaml: string, extraModel = ""): Promise { +async function run( + capsYaml: string, + extraModel = "", + /** Options for the scan the check reads. Omitted, the check builds its own default + * scan — the path every test that does not care about an option goes through. */ + scanOpts?: Parameters[1], +): Promise { const dir = mkdtempSync(join(tmpdir(), "req-check-")); try { mkdirSync(join(dir, "metaobjects")); @@ -147,7 +156,11 @@ async function run(capsYaml: string, extraModel = ""): Promise { expect(r.diags.map((d) => d.code)).not.toContain("WARN_REQUIREMENT_NOTHING_IMPLEMENTS"); }); + // -- the strict switch (ADR-0057) ------------------------------------------- + // `meta verify --require-implementers` reaches the check as this one scan option. + // It RAISES the existence warning; it does not rename it. A second `ERR_` code for + // the same finding would make every tool that keys on the code — a suppression, a + // dashboard, the cross-port corpus — treat one condition as two. + test("requireImplementers raises nothing-implements to an error and keeps the code", async () => { + const model = caps(COVER + ` + - requirement.functional: + name: BuiltByNobody + level: 4 + status: live + statement: "Customers can export their order history" + counterexample: "A customer who asks for their data and cannot be given it" +`); + const byDefault = await run(model, OTHER); + expect(byDefault.loadError).toBeUndefined(); + // Exactly one diagnostic: COVER claims every entity, so nothing else is in play. + expect(byDefault.diags).toHaveLength(1); + const warned = byDefault.diags[0]!; + expect(warned.severity).toBe("warn"); + expect(warned.code).toBe(WARN_REQUIREMENT_NOTHING_IMPLEMENTS); + expect(warned.path).toBe("Solution.OrderService.BuiltByNobody"); + + const strict = await run(model, OTHER, { requireImplementers: true }); + // Same code, same path, same message — the severity is the only thing that moved. + expect(strict.diags).toEqual([{ ...warned, severity: "error" }]); + + // An explicit `false` is the default, not a third state. + expect((await run(model, OTHER, { requireImplementers: false })).diags).toEqual(byDefault.diags); + }); + + test("requireImplementers changes no other diagnostic", async () => { + // One of each neighbour the switch must leave alone: a gate ERROR (dangling ref), + // a different gate WARNING (deferred, untracked) and the coverage warning (neither + // entity is claimed by a reference that resolves) — beside one nothing-implements. + const model = caps(` + - requirement.functional: + name: OrderRecording + level: 4 + status: live + statement: "Orders are recorded" + counterexample: "An order placed and never stored" + implementedBy: ["acme::shop::Ordr"] + - requirement.functional: + name: BuiltByNobody + level: 4 + status: live + statement: "Customers can export their order history" + counterexample: "A customer who asks for their data and cannot be given it" + - requirement.functional: + name: Refunds + level: 4 + status: planned + disposition: deferred + statement: "A refund is recorded against its order" + counterexample: "A refund with no order behind it" +`); + const byDefault = await run(model); + const strict = await run(model, "", { requireImplementers: true }); + expect(byDefault.loadError).toBeUndefined(); + + // The fixture really does hold the neighbours, at the severities they ship with — + // otherwise "nothing else changed" below would be true of an empty list. + const severityOf = (d: Diagnostic[], code: string): string[] => + d.filter((x) => x.code === code).map((x) => x.severity); + expect(severityOf(byDefault.diags, ERR_REQUIREMENT_DANGLING_REF)).toEqual(["error"]); + expect(severityOf(byDefault.diags, WARN_REQUIREMENT_DEFERRED_UNTRACKED)).toEqual(["warn"]); + expect(severityOf(byDefault.diags, WARN_REQUIREMENT_OBJECT_UNCLAIMED)) + .toEqual([OBJECT_COVERAGE_SEVERITY, OBJECT_COVERAGE_SEVERITY]); + expect(severityOf(byDefault.diags, WARN_REQUIREMENT_NOTHING_IMPLEMENTS)).toEqual(["warn"]); + + // The switch reached its own row... + expect(severityOf(strict.diags, WARN_REQUIREMENT_NOTHING_IMPLEMENTS)).toEqual(["error"]); + // ...and nothing else: same diagnostics, same order, once that one row is set back. + const lowered = strict.diags.map((d) => + d.code === WARN_REQUIREMENT_NOTHING_IMPLEMENTS ? { ...d, severity: "warn" as const } : d, + ); + expect(lowered).toEqual(byDefault.diags); + }); + test("an ARCHITECTURAL claim on an abstract base covers everything extending it", async () => { // The documented BaseEntity pattern was previously WORSE than not using it: // claiming the base covered none of its subtypes, and the abstract was itself diff --git a/server/typescript/packages/cli/test/verify-requirements-e2e.test.ts b/server/typescript/packages/cli/test/verify-requirements-e2e.test.ts index 5ab5e8e1f..d9a736694 100644 --- a/server/typescript/packages/cli/test/verify-requirements-e2e.test.ts +++ b/server/typescript/packages/cli/test/verify-requirements-e2e.test.ts @@ -33,6 +33,14 @@ async function captureWarn(fn: () => Promise): Promise { return seen; } +/** Collects `log.error` lines emitted while `fn` runs. */ +async function captureError(fn: () => Promise): Promise { + const seen: string[] = []; + const spy = spyOn(log, "error").mockImplementation((m: string) => { seen.push(m); }); + try { await fn(); } finally { spy.mockRestore(); } + return seen; +} + // verify lazily imports its (heavy) command module on first dispatch; a cold runner // can exceed bun's default 5s. Generous timeout, still fails loudly on a real hang. const TIMEOUT_MS = 30_000; @@ -268,6 +276,89 @@ describe("meta verify — requirements exit-code contract", () => { } }, TIMEOUT_MS); + // -- the strict switch (ADR-0057) ------------------------------------------- + // The unit tests prove the scan option raises the severity. These prove the flag + // and the environment variable each REACH that option, and that the raised + // diagnostic reaches the exit code. `Order` is claimed by a second requirement so + // the existence warning is the only finding: an unclaimed-entity warning beside it + // would leave it unclear which of the two the switch had acted on. + const NOTHING_IMPLEMENTS_ONLY = JSON.stringify({ + "metadata.root": { + package: "acme::shop", + children: [ + { "requirement.functional": { ...L4, "@implementedBy": ["Order"] } }, + { + "requirement.functional": { + name: "orderExport", + "@level": 4, + "@status": "live", + "@statement": "A customer can export their order history.", + "@counterexample": "A customer asks for their orders and gets nothing.", + }, + }, + ], + }, + }); + const NOTHING_IMPLEMENTS_LINE = "WARN_REQUIREMENT_NOTHING_IMPLEMENTS [orderExport]"; + + test("a live requirement nothing implements WARNS and exits 0 by default", async () => { + const dir = project(NOTHING_IMPLEMENTS_ONLY); + let warns: string[] = []; + const errors = await captureError(async () => { + warns = await captureWarn(async () => { + expect(await run(["verify", "--cwd", dir])).toBe(0); + }); + }); + expect(warns.some((w) => w.includes(NOTHING_IMPLEMENTS_LINE))).toBe(true); + // The ONLY finding — which is what makes the two tests below about this warning. + expect(warns.filter((w) => w.includes("_REQUIREMENT_"))).toHaveLength(1); + expect(errors.filter((e) => e.includes("_REQUIREMENT_"))).toEqual([]); + }, TIMEOUT_MS); + + test("--require-implementers turns that same run into exit 1, under the same code", async () => { + const dir = project(NOTHING_IMPLEMENTS_ONLY); + let warns: string[] = []; + const errors = await captureError(async () => { + warns = await captureWarn(async () => { + expect(await run(["verify", "--cwd", dir, "--require-implementers"])).toBe(1); + }); + }); + // Printed as an ERROR now, and no longer among the warnings — one finding, moved. + expect(errors.some((e) => e.includes(NOTHING_IMPLEMENTS_LINE))).toBe(true); + expect(warns.some((w) => w.includes(NOTHING_IMPLEMENTS_LINE))).toBe(false); + }, TIMEOUT_MS); + + test("META_REQUIRE_IMPLEMENTERS=1 does the same without the flag", async () => { + const dir = project(NOTHING_IMPLEMENTS_ONLY); + process.env.META_REQUIRE_IMPLEMENTERS = "1"; + try { + const errors = await captureError(async () => { + expect(await run(["verify", "--cwd", dir])).toBe(1); + }); + expect(errors.some((e) => e.includes(NOTHING_IMPLEMENTS_LINE))).toBe(true); + } finally { + delete process.env.META_REQUIRE_IMPLEMENTERS; + } + }, TIMEOUT_MS); + + test("--require-implementers is not warnings-as-errors: other warnings still exit 0", async () => { + // A planned, deferred, untracked requirement: it is not live, so the existence + // check does not apply, and a planned claim never counts toward coverage. That + // leaves two OTHER warnings (deferred-untracked, and `Order` unclaimed) and the + // switch must raise neither. + const dir = project(req({ ...L4, "@status": "planned", "@disposition": "deferred" })); + const warns = await captureWarn(async () => { + expect(await run(["verify", "--cwd", dir, "--require-implementers"])).toBe(0); + }); + expect(warns.some((w) => w.includes("WARN_REQUIREMENT_DEFERRED_UNTRACKED"))).toBe(true); + expect(warns.some((w) => w.includes("WARN_REQUIREMENT_OBJECT_UNCLAIMED"))).toBe(true); + }, TIMEOUT_MS); + + test("--require-implementers changes nothing for a model with no requirements", async () => { + const dir = project(); + expect(await run(["verify", "--cwd", dir, "--require-implementers"])).toBe(0); + }, TIMEOUT_MS); + test("a project with no requirements is never linted", async () => { const dir = project(); const warns = await captureWarn(async () => { diff --git a/spec/decisions/ADR-0057-requirement-checks-and-tests-in-every-port.md b/spec/decisions/ADR-0057-requirement-checks-and-tests-in-every-port.md new file mode 100644 index 000000000..2943d6686 --- /dev/null +++ b/spec/decisions/ADR-0057-requirement-checks-and-tests-in-every-port.md @@ -0,0 +1,195 @@ +# ADR-0057: Requirement checks and requirement tests run in every port + +## Status + +**Accepted** (2026-10-05). Reverses the "Checks — TypeScript only, by decision" boundary stated +in `docs/CONFORMANCE.md` ("Split coverage"), a decision that never had an ADR of its own. +Registers no vocabulary: `metamodelVersion` stays `1.1` and +`fixtures/registry-conformance/expected-registry.json` is untouched. +[ADR-0015](ADR-0015-single-shared-migrate-engine.md) is unchanged. + +Contract tables, per-port work and the corpus case lists: +[`docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md`](../../docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md). + +## Context + +A requirement (`requirement.functional`, `requirement.architectural`) is metadata, and all five +ports load and validate it. Two things were built on top of that vocabulary, and both shipped in +TypeScript only: + +- **The requirement gate.** `meta verify` checks that every claim resolves, that links sit at or + below the link floor, that nesting agrees with levels, that a live policy is applied to + something, and it reports the ledger on every run. +- **The test scaffold.** `requirementTests()` emits one test stub per claim, which the project + fills in by hand and the three-way merge preserves. + +The split was a decision, and the only place it was written down is `docs/CONFORMANCE.md`, +"Split coverage": + +> *Checks — TypeScript only, by decision.* The `meta verify` diagnostics over requirements ship +> in the TypeScript CLI; the other ports load and validate and stop there. Same call as ADR-0015: +> one implementation of a build-time gate rather than five, where the gate is not a per-port +> runtime concern. + +The reasoning was the one ADR-0015 applied to schema migrations. The gate reads canonical +metadata, which is identical in every port, and prints diagnostics. A function with identical +input and identical output wants one implementation, not five kept in agreement by a test suite. + +That reasoning does not carry over, for three reasons. + +1. **A requirement test is code in the project's own language.** ADR-0015 holds because a + migration's output is SQL, the same artifact whichever port the application is written in. A + test is not: a pytest project needs a pytest file, a JUnit project a JUnit class, an xUnit + project an xUnit class. One implementation of the scaffold serves one port by construction. +2. **The scaffold needs the gate's machinery anyway.** Generating a test from a requirement + takes the same walk, the same address and the same claim resolver the gate uses. A port that + scaffolds tests therefore already holds everything the gate is built from, and a port that + generated tests from claims its own `verify` never checked would be scaffolding from a ledger + it had not validated. +3. **Each port already runs a `verify` in the project's own build**, and already runs a shared + check there: the field authoring lint, held across the ports by + `fixtures/field-lint-conformance/`. The cost the original decision avoided, five + implementations drifting, has a remedy this repository already uses. + +## Decision + +The owner has ruled that **every language runs the requirement checks and scaffolds tests from +requirements.** The clauses below are that ruling and the rulings that follow from it. None of +them is open. + +### The gate + +1. **Every port's `verify` runs the requirement gate**, on every run and with no subverb: + `meta verify` (TypeScript), `metaobjects verify` (Python), `mvn metaobjects:verify` (Java and + Kotlin, which share the Maven goal) and `dotnet meta verify` (C#). The gate is implemented + per port, in the port's core library. +2. **The gate is held to the TypeScript reference by a shared corpus**, + `fixtures/requirement-check-conformance/`. Its expectations are produced from the TypeScript + implementation and committed. A port that disagrees with a committed expectation is wrong + unless the reference is shown to be wrong first. +3. **Diagnostic message text is pinned by that corpus**, with the code, the severity, the path + and the summary counts. The field-lint corpus already works this way. +4. **The strict switch keeps the code and raises the severity.** A live functional requirement + that nothing implements is `WARN_REQUIREMENT_NOTHING_IMPLEMENTS` at severity `warn`. Under + `--require-implementers` (or `META_REQUIRE_IMPLEMENTERS=1`; + `-Dmeta.verify.requireImplementers=true` on the Maven goal) the same code is reported at + severity `error`. There is no new `ERR_` code. + The flag is not called `--strict`, because `verify` already has `--lax` on a different axis + (ADR-0023 attribute strictness) and a `--strict` beside it would read as that flag's opposite. +5. **A model with no `requirement.*` node sees no change in any port**: no line printed, no + exit-code change, no file generated. + +### The generator + +6. **Every port ships a `requirement-tests` generator**: the existing TypeScript one, pytest for + Python, JUnit Jupiter for Java and for Kotlin, xUnit for C#. It is implemented per port. +7. **The generator is held by a second shared corpus**, + `fixtures/requirement-test-identity-conformance/`. It pins the *identity* of every generated + test (its package, path, unit, id, witness key, status, skip state and digest), which is the + same in five ports. It does not pin emitted bytes, which are each port's own language. +8. **Generated Java and Kotlin tests are JUnit Jupiter only.** A JUnit 4 project uses the + renderer hook or ejects the generator. +9. **The digest covers six things**: subtype, level, status, statement, counterexample and the + `implementedBy` list. It does not cover the title, description, notes, disposition, + `trackedBy`, `supersededBy`, the name, the package or nested requirements. It answers "did the + claim change", not "did the entry move". It is written into the generated file, which is what + turns `verify --codegen` red when a claim changes. +10. **The filter that selects which requirements get a test is uniform.** Every port offers the + same two options: a predicate over one shared *requirement view* (subtype, level, status, + path, effective package and the distinct concerns of the resolved targets), and a switch for + the warning that names what the filter excluded. With no predicate, the default is + functional requirements at level 4 and 5. +11. **Generated tests import nothing from MetaObjects.** Python output imports `importlib` and + `pytest`; JVM output imports JUnit; C# output imports xUnit. + +### The witness model + +12. **Outside TypeScript the generated test file is fully machine-owned** and rewritten whole on + every run. No three-way merge is ported. Project code lives in a *witness*: a function the + project owns, which the generated test calls. A requirement that is live or partial and has + no witness is a failing test that names the function to write. +13. **A JVM or .NET generated test finds its witness through a generated interface**, with one + member per test that is not skipped and a failing default for each. The project names one + class that implements it. There is no run-time lookup by name (ADR-0001). A requirement that + becomes live adds a failing default, which is a red test and not a compile break. A + requirement that is retired or deleted removes its member, so a stale override stops + compiling. +14. **A Python generated test looks its witness up by name** in a configured module. Python has + no static binding to offer, and this is test code, so it is the one by-name lookup in the + design. +15. **TypeScript keeps its own model**: hand-filled stubs, preserved by the three-way merge. The + witness model is not offered there. +16. **The Python interpreter floor is documented, not lowered.** The tool needs Python 3.11 or + later to run. The tests it generates parse under the 3.9 grammar and need only pytest, so a + project on an older interpreter runs the tool with a different one. + +### What is a contract and what is a recommendation + +17. **The generator, its renderer and the witness model are a recommended approach, not a + contract.** The generator is a reference helper (ADR-0034 Amendment 3). It is ejectable in + every port through that port's existing eject mechanism (`meta eject`, `metaobjects eject`, + `mvn metaobjects:eject`, `dotnet meta eject`), and an application may change or replace its + copy freely. +18. **The checks in `verify` stay stock.** They are not ejectable in any port, because they are + the shared contract. +19. **`verify` still never reads test results.** The generator emits the tests, the codegen + drift gate proves the set matches the ledger, an unfilled live test fails, and the project's + own test run supplies "passing". + +## Consequences + +- **A change to a gate code, a message, the test identity or the digest is now a five-port + change with a corpus edit.** Rewording one diagnostic touches five implementations and the + committed expectations. That cost is the price of clause 3 and is accepted. +- **The analogy with ADR-0015 no longer applies to requirements.** ADR-0015 itself is + unchanged: schema migrations stay owned by the TypeScript toolchain, for the reason that ADR + gives. +- **The seven authoring-lint advisories stay TypeScript-only in this slice**, by the owner's + ruling. They never fail a build. Porting them is a follow-up slice. Until then the split is + narrower, not gone: the gate is in every port, its advisory lint is in one. +- **A project that declares requirements on a non-TypeScript port will see diagnostics it did + not see before, and its `verify` may now fail.** That is the purpose of the change, and the + release notes say so. +- **JVM and .NET projects pay one setup cost.** The generated test module does not compile + until the project creates its witness class. +- **A passing generated test proves that the witness ran**, not that the witness tests the + claim. The boundary `docs/features/requirements.md` draws under "What a green run does not + prove" still holds. +- **TypeScript and the other four ports differ in how a project supplies its test body.** + TypeScript's emitted bytes do not change; its test names do not carry the package, and the + other ports' identities do. + +## Realization status + +At acceptance, TypeScript has the gate, the generator and (with this ADR) the strict switch. The +two corpora and the Python, Java, C# and Kotlin implementations land in the order the plan +gives. `docs/CONFORMANCE.md`, `docs/features/requirements.md` and the pillar summaries still +state the TypeScript-only split until the ports that make it false have landed; they are +corrected with them, not ahead of them. + +## Alternatives considered + +- **Keep the checks TypeScript-only and have other ports run the Node CLI for them.** Rejected: + the scaffold cannot be single-port (Context, reason 1), and once a port has the scaffold's walk + and resolver the gate is a thin layer over them. +- **A new `ERR_REQUIREMENT_NOTHING_IMPLEMENTS` code for the strict switch.** Rejected: one + finding under two codes splits every suppression, report and corpus case keyed on the code. + Object coverage already works the other way, with one code and a named severity constant. +- **A run-time lookup by name for JVM and .NET witnesses, as Python does.** Rejected: it is + runtime reflection (ADR-0001), and a witness for a deleted requirement would go on existing + unnoticed. The interface makes that a compile error. +- **Port the three-way merge to four more languages.** Rejected: none of those ports has one, + and the witness model keeps hand-written code out of the generated file altogether. +- **Emit JUnit 4 as well as Jupiter.** Rejected: the renderer hook and eject already cover a + JUnit 4 project, and a second stock output is a second thing to test and document in two ports. +- **A digest over every attribute.** Rejected: an edit to a title, a note or a disposition would + turn every generated file stale without the claim having changed. +- **Pin codes in the corpus and leave message text to each port.** Rejected: the message is what + an adopter reads, and unpinned prose is where the ports would drift first. +- **Lower the Python floor to 3.9.** Rejected: the generated output already runs there, and the + tool can be run with a different interpreter than the project's own. +- **Per-port filter options**, with the predicate offered in TypeScript and Python only. + Rejected by the owner: the filter is uniform. +- **Make the generator's output a cross-port contract.** Rejected: it writes code into the + adopter's repository, which makes it a helper the adopter owns. The identities are pinned so + the ports agree on *what* is tested; how the test is written is the application's to change. diff --git a/spec/decisions/README.md b/spec/decisions/README.md index e7d1c17f0..44006d3ab 100644 --- a/spec/decisions/README.md +++ b/spec/decisions/README.md @@ -53,6 +53,7 @@ Write one when a decision is **cross-cutting** (affects more than one language p - [ADR-0054 — A `.base` subtype is a registry anchor, not an authorable node](ADR-0054-base-subtypes-are-not-authorable.md) — *Accepted* - [ADR-0055 — Overlay application is a deferred pass — plain declarations first, then overlays, each in source order](ADR-0055-deferred-overlay-application.md) — *Accepted* - [ADR-0056 — A value object's type is generated once — the template tier references it](ADR-0056-value-object-types-are-generated-once.md) — *Accepted* (supersedes the payload-tier half of ADR-0044) +- [ADR-0057 — Requirement checks and requirement tests run in every port](ADR-0057-requirement-checks-and-tests-in-every-port.md) — *Accepted* (reverses the "Checks — TypeScript only" split recorded in `docs/CONFORMANCE.md`) > **Index gap — ADR-0031 through ADR-0051 are on disk but not listed above.** The index stopped > being maintained after ADR-0030; the files are authoritative, this list is not. Read From c63b57ba76a667ec8b9711e3b8fc3c1e5e35c80f Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 5 Oct 2026 23:36:15 -0400 Subject: [PATCH 04/43] feat(codegen-ts): requirement test identities, the requirement digest and a per-member grain The requirement walk gains the record every port's test generator agrees on (package, path, unit, id, witnessKey, status, skip, digest), the requirement-digest/v1 fingerprint of a claim, and a grain option that fans a requirement out per resolving reference instead of per concern. The default grain keeps its paths, test names and bytes; the renderer hook now receives the identity. Refs docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md and ADR-0057. --- .../src/generators/requirement-tests.ts | 68 ++-- .../packages/codegen-ts/src/index.ts | 12 + .../codegen-ts/src/requirement-walk.ts | 209 +++++++++++- .../src/templates/requirement-test.ts | 15 + .../codegen-ts/test/ownership-seam.test.ts | 7 + .../test/requirement-stub-executes.test.ts | 7 + .../test/requirement-test-escaping.test.ts | 15 + .../test/requirement-test-render.test.ts | 55 +++ .../test/requirement-tests-generator.test.ts | 75 ++++ .../codegen-ts/test/requirement-walk.test.ts | 322 +++++++++++++++++- 10 files changed, 761 insertions(+), 24 deletions(-) diff --git a/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts b/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts index f1af28030..281d7a9f0 100644 --- a/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts +++ b/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts @@ -16,16 +16,17 @@ // package. import { - REQUIREMENT_SUBTYPE_FUNCTIONAL, - REQUIREMENT_LINK_FLOOR_LEVEL, REQUIREMENT_ATTR_STATEMENT, REQUIREMENT_ATTR_COUNTEREXAMPLE, } from "@metaobjectsdev/metadata"; import type { Generator, EmittedFile, GenContext } from "../generator.js"; import { walkRequirements, - groupByConcern, + defaultRequirementTestFilter, + requirementTestUnits, + requirementTestIdentity, NO_CONCERN, + type RequirementTestGrain, type RequirementView, } from "../requirement-walk.js"; import { @@ -40,11 +41,20 @@ export interface RequirementTestsOpts { name?: string; /** WHICH requirements get stubs. This is the app's policy declaration. */ filter?: (r: RequirementView) => boolean; - /** Renderer per concern key: exact `type.subType`, `type.*`, or `*`. */ + /** + * What one stub stands for. `"concern"` (the default): one per distinct + * `type.subType` a requirement claims. `"member"`: one per distinct `@implementedBy` + * reference that resolves, for an application that wants a test per claimed node. + */ + grain?: RequirementTestGrain; + /** Renderer per concern key: exact `type.subType`, `type.*`, or `*`. In both grains + * the key is what the stub's target RESOLVES to, never the reference text. */ renderers?: Record; /** Full control over renderer selection — beats `renderers` when it returns one. */ resolveRenderer?: (concern: string) => RequirementTestRenderer | undefined; - /** Where each stub lands. */ + /** Where each stub lands. The second argument is the stub's fan-out key: the + * concern, or under `grain: "member"` the reference exactly as authored — which + * may hold `::`, so mangle it before it reaches a filename. */ path?: (view: RequirementView, concern: string) => string; /** Named output target (registry key). */ target?: string; @@ -83,22 +93,23 @@ const MAX_NAMED_UNCOVERED = 5; * default policy claims. Kept beside it so the pair cannot drift. */ const DEFAULT_STUB_DIR = "requirements/"; -/** - * RECOMMENDATION, not a rule: functional requirements at or below the link floor. - * - * Architectural requirements are excluded by default because `verify`'s - * universality check already proves them structurally, so a test there is usually - * redundant — usually, not never, which is exactly why this is overridable. - */ -const defaultFilter = (r: RequirementView): boolean => - r.subType === REQUIREMENT_SUBTYPE_FUNCTIONAL && - (r.level ?? 0) >= REQUIREMENT_LINK_FLOOR_LEVEL; - const defaultPath = (view: RequirementView, concern: string): string => concern === NO_CONCERN ? `${DEFAULT_STUB_DIR}${view.path}.test.ts` : `${DEFAULT_STUB_DIR}${view.path}.${concern}.test.ts`; +/** + * The default path under `grain: "member"`, where the fan-out key is a reference. + * + * The reference is MANGLED into the last segment — every run of characters outside + * `[A-Za-z0-9]` becomes one `_` — because a reference may be package-qualified and + * `::` is not a legal filename on every platform this output is checked out on. The + * concern grain keeps its unmangled segment: those paths are already in adopters' + * repositories with hand-written bodies in them. + */ +const defaultMemberPath = (view: RequirementView, ref: string): string => + defaultPath(view, ref === NO_CONCERN ? ref : ref.replace(/[^A-Za-z0-9]+/g, "_")); + /** Exact concern → `type.*` → `*` → the built-in renderer. */ function pickRenderer( concern: string, @@ -117,8 +128,9 @@ function attrString(node: { attr: (n: string) => unknown }, name: string): strin } export function requirementTests(opts: RequirementTestsOpts = {}): Generator { - const filter = opts.filter ?? defaultFilter; - const toPath = opts.path ?? defaultPath; + const filter = opts.filter ?? defaultRequirementTestFilter; + const grain = opts.grain ?? "concern"; + const toPath = opts.path ?? (grain === "member" ? defaultMemberPath : defaultPath); // A custom `path` with no custom `owns` leaves the default namespace pointing // somewhere the generator no longer writes, so reconciliation matches nothing. // That degrades safely — it can only ever delete less — but silently, and a @@ -144,19 +156,31 @@ export function requirementTests(opts: RequirementTestsOpts = {}): Generator { uncovered.push(walked.view.path); continue; } - for (const [concern, targets] of groupByConcern(walked)) { + for (const [unit, targets] of requirementTestUnits(walked, grain)) { + const identity = requirementTestIdentity(walked, unit); const args: RequirementTestArgs = { view: walked.view, - concern, + concern: unit, targets, statement: attrString(walked.node, REQUIREMENT_ATTR_STATEMENT), counterexample: attrString(walked.node, REQUIREMENT_ATTR_COUNTEREXAMPLE), disposition: walked.node.disposition(), trackedBy: walked.node.trackedBy(), + package: identity.package, + unit, + id: identity.id, + witnessKey: identity.witnessKey, + skip: identity.skip, + digest: identity.digest, }; + // The renderer is chosen by what the stub's target IS, in both grains. Under + // the concern grain that is the unit itself; under the member grain the unit + // is a reference, and keying on it would match no `type.subType` entry and + // silently retire every renderer the application registered. + const rendererKey = targets[0]?.concern ?? NO_CONCERN; files.push({ - path: toPath(walked.view, concern), - content: pickRenderer(concern, opts)(args), + path: toPath(walked.view, unit), + content: pickRenderer(rendererKey, opts)(args), }); } } diff --git a/server/typescript/packages/codegen-ts/src/index.ts b/server/typescript/packages/codegen-ts/src/index.ts index 0e6686b79..b7a7f9113 100644 --- a/server/typescript/packages/codegen-ts/src/index.ts +++ b/server/typescript/packages/codegen-ts/src/index.ts @@ -355,11 +355,23 @@ export { groupByConcern, concernOf, NO_CONCERN, + // The test identity, the digest and the grain: what every language port's + // requirement-test generator agrees on, and what an owned (ejected) generator + // keeps importing from here. + defaultRequirementTestFilter, + requirementDigest, + witnessKeyOf, + requirementTestUnits, + requirementTestIdentity, + requirementTestIdentities, + witnessKeyCollisions, } from "./requirement-walk.js"; export type { RequirementView, ResolvedClaim, WalkedRequirement, + RequirementTestGrain, + RequirementTestIdentity, } from "./requirement-walk.js"; export { renderRequirementTest } from "./templates/requirement-test.js"; export type { RequirementTestArgs } from "./templates/requirement-test.js"; diff --git a/server/typescript/packages/codegen-ts/src/requirement-walk.ts b/server/typescript/packages/codegen-ts/src/requirement-walk.ts index 9da1e1471..4a11539e5 100644 --- a/server/typescript/packages/codegen-ts/src/requirement-walk.ts +++ b/server/typescript/packages/codegen-ts/src/requirement-walk.ts @@ -12,7 +12,16 @@ // metamodel internals and export the ADR-0039 own-vs-resolving accessor trap. The // projection is additive — it can grow, but it never hands out the node. -import { TYPE_REQUIREMENT, resolveClaim } from "@metaobjectsdev/metadata"; +import { createHash } from "node:crypto"; +import { + TYPE_REQUIREMENT, + REQUIREMENT_SUBTYPE_FUNCTIONAL, + REQUIREMENT_LINK_FLOOR_LEVEL, + REQUIREMENT_ATTR_STATEMENT, + REQUIREMENT_ATTR_COUNTEREXAMPLE, + REQUIREMENT_STATUSES_REQUIRING_LIVE_NODES, + resolveClaim, +} from "@metaobjectsdev/metadata"; import type { MetaData, MetaRequirement } from "@metaobjectsdev/metadata"; /** The shape an app's `filter` receives. Never the node itself. */ @@ -25,6 +34,8 @@ export interface RequirementView { readonly status: string | undefined; /** Dotted path from the root through nesting ancestors — hierarchy is nesting. */ readonly path: string; + /** The EFFECTIVE package: the node's own, else its file's default, else "". */ + readonly package: string; /** DISTINCT `type.subType` concerns among the resolved targets. */ readonly implementedByTypes: readonly string[]; } @@ -88,6 +99,7 @@ export function walkRequirements(root: MetaData): WalkedRequirement[] { level: req.level(), status: req.status(), path, + package: referrerPkg, implementedByTypes: [...new Set(targets.map((t) => t.concern))], }, targets, @@ -122,3 +134,198 @@ export function groupByConcern(w: WalkedRequirement): Map.` a requirement claims. + * - `member`: one test per distinct `@implementedBy` reference that resolves. + */ +export type RequirementTestGrain = "concern" | "member"; + +/** One generated test. The same record in every language port. */ +export interface RequirementTestIdentity { + /** The requirement's effective package. */ + readonly package: string; + /** The requirement's dotted path, without the package. */ + readonly path: string; + /** `.` under the `concern` grain, the reference exactly as authored + * under `member`, and `*` for a requirement that resolves no target. */ + readonly unit: string; + /** ` []` — unique per test. */ + readonly id: string; + /** An identifier-safe spelling of `id`, for ports that bind a test to a function. */ + readonly witnessKey: string; + readonly status: string | undefined; + /** Why the test is skipped, or null when the requirement claims the capability + * works right now. */ + readonly skip: "planned" | "retired" | null; + /** `requirementDigest` of the requirement — the same for each of its tests. */ + readonly digest: string; +} + +/** + * RECOMMENDATION, not a rule: functional requirements at or below the link floor. + * + * Architectural requirements are excluded by default because `verify`'s + * universality check already proves them structurally, so a test there is usually + * redundant — usually, not never, which is exactly why this is overridable. + */ +export function defaultRequirementTestFilter(r: RequirementView): boolean { + return ( + r.subType === REQUIREMENT_SUBTYPE_FUNCTIONAL && + (r.level ?? 0) >= REQUIREMENT_LINK_FLOOR_LEVEL + ); +} + +const DIGEST_VERSION = "requirement-digest/v1"; + +/** ` \n\n` — length-prefixed, so no value can be confused + * with the field that follows it, whatever it contains. */ +function digestField(name: string, value: string): string { + return `${name} ${Buffer.byteLength(value, "utf8")}\n${value}\n`; +} + +/** + * A fingerprint of the CLAIM: lowercase hex SHA-256 over the requirement's subtype, + * level, status, statement, counterexample and `@implementedBy` list. + * + * It answers "did the claim change", not "did the entry move": the name, the package, + * the title, the notes, the disposition, the tracking references and nested + * requirements are all left out, so renaming or annotating an entry does not disturb + * a test written against it, and rewording what it asserts does. + */ +export function requirementDigest(node: MetaRequirement): string { + // attr() RESOLVES in TypeScript (ADR-0039): the digest is over the effective claim. + const prose = (name: string): string => { + const v = node.attr(name); + return typeof v === "string" ? v.replace(/\r\n?/g, "\n") : ""; + }; + const level = node.level(); + const refs = node.implementedBy(); + const text = + `${DIGEST_VERSION}\n` + + digestField("subType", node.subType) + + digestField("level", level === undefined ? "" : String(level)) + + digestField("status", node.status() ?? "") + + digestField("statement", prose(REQUIREMENT_ATTR_STATEMENT)) + + digestField("counterexample", prose(REQUIREMENT_ATTR_COUNTEREXAMPLE)) + + `implementedBy ${refs.length}\n` + + refs.map((r) => digestField("ref", r)).join(""); + return createHash("sha256").update(text, "utf8").digest("hex"); +} + +/** Every maximal run of characters outside `[A-Za-z0-9]` becomes one `_`. */ +const mangle = (s: string): string => s.replace(/[^A-Za-z0-9]+/g, "_"); + +/** + * An identifier-safe key for one test: `req_
`, then `__` unless the + * requirement resolves no target. + * + * Mangling is lossy — `Orders.Recorded` and `Orders_Recorded` give one key — which is + * what `witnessKeyCollisions` exists to report. + */ +export function witnessKeyOf(qualifiedAddress: string, unit: string): string { + const base = `req_${mangle(qualifiedAddress)}`; + return unit === NO_CONCERN ? base : `${base}__${mangle(unit)}`; +} + +/** + * A requirement's targets, grouped by fan-out unit under the given grain — one entry + * per test, in first-seen order. + * + * Under `member` a reference authored twice is one test, and the bare and qualified + * spellings of one node are two: the unit is the reference as written, not what it + * resolves to. In both grains a requirement resolving NO targets still yields + * exactly one entry, keyed `NO_CONCERN` (see `groupByConcern`). + */ +export function requirementTestUnits( + w: WalkedRequirement, + grain: RequirementTestGrain = "concern", +): Map { + if (grain === "concern") return groupByConcern(w); + const units = new Map(); + for (const t of w.targets) { + if (!units.has(t.ref)) units.set(t.ref, [t]); + } + if (units.size === 0) units.set(NO_CONCERN, []); + return units; +} + +/** The identity of the one test `unit` stands for (a key of `requirementTestUnits`). */ +export function requirementTestIdentity( + w: WalkedRequirement, + unit: string, +): RequirementTestIdentity { + const { path, status } = w.view; + const pkg = w.view.package; + const address = pkg === "" ? path : `${pkg}::${path}`; + // Derived from the loader's status list rather than naming the two skipped statuses: + // a status that does not claim the capability works right now is skipped by + // construction, so a status added later cannot be left failing by omission. + const skips = + status !== undefined && + !(REQUIREMENT_STATUSES_REQUIRING_LIVE_NODES as readonly string[]).includes(status); + return { + package: pkg, + path, + unit, + id: `${address} [${unit}]`, + witnessKey: witnessKeyOf(address, unit), + status, + skip: skips ? (status as "planned" | "retired") : null, + digest: requirementDigest(w.node), + }; +} + +// Code units, never localeCompare: a collation differs between machines and between +// language ports, and these orders are pinned by a corpus all five ports run. +const compareCodeUnits = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + +/** Every test the generator would emit, sorted by id. */ +export function requirementTestIdentities( + root: MetaData, + opts: { grain?: RequirementTestGrain; filter?: (r: RequirementView) => boolean } = {}, +): RequirementTestIdentity[] { + const filter = opts.filter ?? defaultRequirementTestFilter; + const out: RequirementTestIdentity[] = []; + for (const walked of walkRequirements(root)) { + if (!filter(walked.view)) continue; + for (const unit of requirementTestUnits(walked, opts.grain).keys()) { + out.push(requirementTestIdentity(walked, unit)); + } + } + return out.sort((a, b) => compareCodeUnits(a.id, b.id)); +} + +/** Pairs of ids that share a witnessKey, each pair and the list sorted. */ +export function witnessKeyCollisions( + tests: readonly RequirementTestIdentity[], +): [string, string][] { + const byKey = new Map(); + for (const t of tests) { + const ids = byKey.get(t.witnessKey); + if (ids === undefined) byKey.set(t.witnessKey, [t.id]); + else ids.push(t.id); + } + const pairs: [string, string][] = []; + for (const ids of byKey.values()) { + const sorted = [...ids].sort(compareCodeUnits); + for (const [i, first] of sorted.entries()) { + for (const second of sorted.slice(i + 1)) pairs.push([first, second]); + } + } + return pairs.sort((a, b) => compareCodeUnits(a[0], b[0]) || compareCodeUnits(a[1], b[1])); +} diff --git a/server/typescript/packages/codegen-ts/src/templates/requirement-test.ts b/server/typescript/packages/codegen-ts/src/templates/requirement-test.ts index 96da1f4c3..dd55d76c2 100644 --- a/server/typescript/packages/codegen-ts/src/templates/requirement-test.ts +++ b/server/typescript/packages/codegen-ts/src/templates/requirement-test.ts @@ -20,12 +20,27 @@ import type { RequirementView, ResolvedClaim } from "../requirement-walk.js"; export interface RequirementTestArgs { readonly view: RequirementView; + /** The fan-out key this stub stands for: `.` under the default grain, + * the reference as authored under `grain: "member"`, `*` when the requirement + * resolves no target. Always equal to `unit`, which is the name the other language + * ports use for it. */ readonly concern: string; readonly statement: string; readonly counterexample: string; + /** The claims this stub covers. Each carries its own `concern`, which stays the + * target's `.` in both grains. */ readonly targets: readonly ResolvedClaim[]; readonly disposition?: string | undefined; readonly trackedBy?: readonly string[] | undefined; + // The test's identity record (`RequirementTestIdentity`) — the same six values in + // every language port. They are DATA for an application's renderer: the default + // renderer below reads none of them, so its output does not move when they do. + readonly package: string; + readonly unit: string; + readonly id: string; + readonly witnessKey: string; + readonly skip: "planned" | "retired" | null; + readonly digest: string; } /** diff --git a/server/typescript/packages/codegen-ts/test/ownership-seam.test.ts b/server/typescript/packages/codegen-ts/test/ownership-seam.test.ts index d615954df..f77563e31 100644 --- a/server/typescript/packages/codegen-ts/test/ownership-seam.test.ts +++ b/server/typescript/packages/codegen-ts/test/ownership-seam.test.ts @@ -146,12 +146,19 @@ describe("the requirementTests() stub header states the CONDITION on survival", level: 4, status: "live", path: "links.slugField", + package: "acme::probe", implementedByTypes: [], }, concern: "object.entity", statement: "A council has a human-readable slug.", counterexample: "a council with no slug", targets: [], + package: "acme::probe", + unit: "object.entity", + id: "acme::probe::links.slugField [object.entity]", + witnessKey: "req_acme_probe_links_slugField__object_entity", + skip: null, + digest: "0".repeat(64), }; test("it names the machine-local half, and no longer promises survival flat", () => { diff --git a/server/typescript/packages/codegen-ts/test/requirement-stub-executes.test.ts b/server/typescript/packages/codegen-ts/test/requirement-stub-executes.test.ts index 715095f50..574b85f57 100644 --- a/server/typescript/packages/codegen-ts/test/requirement-stub-executes.test.ts +++ b/server/typescript/packages/codegen-ts/test/requirement-stub-executes.test.ts @@ -19,12 +19,19 @@ function args(status: string): RequirementTestArgs { level: 4, status, path: "req.probe", + package: "acme::probe", implementedByTypes: [], }, concern: "object.entity", statement: "A council has a human-readable slug.", counterexample: "a council with no slug", targets: [], + package: "acme::probe", + unit: "object.entity", + id: "acme::probe::req.probe [object.entity]", + witnessKey: "req_acme_probe_req_probe__object_entity", + skip: null, + digest: "0".repeat(64), }; } diff --git a/server/typescript/packages/codegen-ts/test/requirement-test-escaping.test.ts b/server/typescript/packages/codegen-ts/test/requirement-test-escaping.test.ts index 4342134fa..3ddeee42d 100644 --- a/server/typescript/packages/codegen-ts/test/requirement-test-escaping.test.ts +++ b/server/typescript/packages/codegen-ts/test/requirement-test-escaping.test.ts @@ -25,9 +25,20 @@ const view = { level: 4, status: "live", path: "req.probe", + package: "acme::probe", implementedByTypes: [], } as const; +// The test identity every renderer is handed. The default renderer reads none of it. +const identity = { + package: "acme::probe", + unit: "object.entity", + id: "acme::probe::req.probe [object.entity]", + witnessKey: "req_acme_probe_req_probe__object_entity", + skip: null, + digest: "0".repeat(64), +} as const; + function render(statement: string, counterexample: string): string { return renderRequirementTest({ view: { ...view }, @@ -35,6 +46,7 @@ function render(statement: string, counterexample: string): string { statement, counterexample, targets: [], + ...identity, }); } @@ -107,6 +119,7 @@ describe("every author-supplied field is escaped, not just the two obvious ones" statement: "Notes are private.", counterexample: "the GM sees a player's notes", targets: [], + ...identity, ...extra, }); } @@ -186,6 +199,7 @@ describe("a retired requirement does not redden the suite forever", () => { statement: "s", counterexample: "v", targets: [], + ...identity, }); expect(src).toContain("test.skip"); expect(src).not.toContain("expect.unreachable"); @@ -200,6 +214,7 @@ describe("a retired requirement does not redden the suite forever", () => { statement: "s", counterexample: "v", targets: [], + ...identity, }); expect(src).toContain("expect.unreachable"); expect(src).not.toContain("test.skip"); diff --git a/server/typescript/packages/codegen-ts/test/requirement-test-render.test.ts b/server/typescript/packages/codegen-ts/test/requirement-test-render.test.ts index 3ed5e1c15..10ffc9b27 100644 --- a/server/typescript/packages/codegen-ts/test/requirement-test-render.test.ts +++ b/server/typescript/packages/codegen-ts/test/requirement-test-render.test.ts @@ -14,12 +14,19 @@ const base: RequirementTestArgs = { level: 4, status: "live", path: "links.slugField", + package: "acme::probe", implementedByTypes: [], }, concern: "object.entity", statement: "A council has a human-readable slug.", counterexample: "a council with no slug", targets: [], + package: "acme::probe", + unit: "object.entity", + id: "acme::probe::links.slugField [object.entity]", + witnessKey: "req_acme_probe_links_slugField__object_entity", + skip: null, + digest: "0".repeat(64), }; describe("renderRequirementTest", () => { @@ -103,3 +110,51 @@ describe("renderRequirementTest", () => { expect(src).toContain("object.entity"); }); }); + +describe("renderRequirementTest — the default stub keeps its bytes", () => { + // The whole stub, pinned. An application's hand-written test bodies live in these + // files and are merged against them, so a changed byte in the default output is a + // three-way merge on every stub an adopter has already filled in. + const EXPECTED = + `// @generated by @metaobjectsdev/codegen-ts.\n` + + `// The test IDENTITY is generated from the requirement; the BODY below is yours.\n` + + `// Do not rename the test — the name is the link.\n` + + `// Your body is never overwritten: MERGED where .metaobjects/.gen-state/ holds this\n` + + `// file's snapshot body, REFUSED (run exits 1, body kept) where it does not. Those\n` + + `// bodies are gitignored, so a fresh clone or CI is always the second case — see\n` + + `// docs/features/own-your-codegen.md for the recovery.\n` + + `import { test, expect } from "bun:test";\n` + + `\n` + + `/**\n` + + ` * A council has a human-readable slug.\n` + + ` *\n` + + ` * Counterexample: a council with no slug\n` + + ` *\n` + + ` * Claims:\n` + + ` * (none — this requirement names no model nodes)\n` + + ` */\n` + + `test("links.slugField [object.entity]", () => {\n` + + ` expect.unreachable(\n` + + ` "unimplemented requirement stub: links.slugField [object.entity] — " +\n` + + ` "replace this with an assertion that fails when: a council with no slug",\n` + + ` );\n` + + `});\n`; + + test("the default output is byte-identical with the new args present", () => { + expect(renderRequirementTest(base)).toBe(EXPECTED); + // …and the identity fields are DATA for an application's renderer, not input to + // this one: whatever they hold, the default stub does not move. + expect( + renderRequirementTest({ + ...base, + view: { ...base.view, package: "other::pkg" }, + package: "other::pkg", + unit: "Other.member", + id: "other::pkg::Somewhere.Else [Other.member]", + witnessKey: "req_other_pkg_Somewhere_Else__Other_member", + skip: "planned", + digest: "f".repeat(64), + }), + ).toBe(EXPECTED); + }); +}); diff --git a/server/typescript/packages/codegen-ts/test/requirement-tests-generator.test.ts b/server/typescript/packages/codegen-ts/test/requirement-tests-generator.test.ts index d550d0dde..c1bcc694a 100644 --- a/server/typescript/packages/codegen-ts/test/requirement-tests-generator.test.ts +++ b/server/typescript/packages/codegen-ts/test/requirement-tests-generator.test.ts @@ -9,6 +9,7 @@ import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; import { requirementTests } from "../src/generators/requirement-tests.js"; import type { RequirementTestsOpts } from "../src/generators/requirement-tests.js"; import type { EmittedFile, GenContext } from "../src/generator.js"; +import type { RequirementTestArgs } from "../src/templates/requirement-test.js"; const MODEL = { "metadata.root": { @@ -162,3 +163,77 @@ describe("requirementTests — uncovered warning", () => { expect(seen).toEqual([]); }); }); + +describe("requirementTests — grain", () => { + test('grain: "member" emits one file per reference', async () => { + const files = await emit({ grain: "member" }); + // The last segment is the MANGLED reference, so a qualified reference can never + // put "::" (or a path separator) into a filename. + expect(files.map((f) => f.path).sort()).toEqual([ + "requirements/links.slugField.Council.test.ts", + "requirements/links.slugField.Council_slug.test.ts", + "requirements/links.slugField.Council_slug_display.test.ts", + ]); + }); + + test("under member grain each stub is named for, and lists, its one reference", async () => { + const files = await emit({ grain: "member" }); + const slug = files.find((f) => f.path.endsWith(".Council_slug.test.ts"))?.content ?? ""; + expect(slug).toContain('test("links.slugField [Council.slug]"'); + expect(slug).toContain(" * - Council.slug (field.string)"); + expect(slug).not.toContain("Council.slug.display"); + }); + + test("a type-keyed renderer still applies under member grain", async () => { + // The renderer map is keyed by what the reference RESOLVES to, in both grains — + // otherwise switching grain would silently retire every renderer an app registered. + const files = await emit({ grain: "member", renderers: { "field.*": () => "FIELD" } }); + expect(files.filter((f) => f.content === "FIELD").map((f) => f.path)).toEqual([ + "requirements/links.slugField.Council_slug.test.ts", + ]); + }); + + test("the renderer receives the test identity, digest and witnessKey", async () => { + const seen: RequirementTestArgs[] = []; + const capture = (a: RequirementTestArgs): string => { + seen.push(a); + return ""; + }; + await emit({ grain: "member", renderers: { "*": capture } }); + const slug = seen.find((a) => a.unit === "Council.slug"); + expect(slug?.package).toBe("acme::probe"); + expect(slug?.id).toBe("acme::probe::links.slugField [Council.slug]"); + expect(slug?.witnessKey).toBe("req_acme_probe_links_slugField__Council_slug"); + expect(slug?.skip).toBeNull(); + expect(slug?.digest).toMatch(/^[0-9a-f]{64}$/); + // One claim, so one digest: every test of a requirement carries the same one. + expect(new Set(seen.map((a) => a.digest)).size).toBe(1); + }); + + test("the default grain hands the renderer the same record, keyed by concern", async () => { + const seen: RequirementTestArgs[] = []; + await emit({ + renderers: { "*": (a) => (seen.push(a), "") }, + }); + expect(seen.map((a) => [a.unit, a.concern, a.id, a.witnessKey])).toEqual([ + [ + "object.entity", + "object.entity", + "acme::probe::links.slugField [object.entity]", + "req_acme_probe_links_slugField__object_entity", + ], + [ + "field.string", + "field.string", + "acme::probe::links.slugField [field.string]", + "req_acme_probe_links_slugField__field_string", + ], + [ + "view.text", + "view.text", + "acme::probe::links.slugField [view.text]", + "req_acme_probe_links_slugField__view_text", + ], + ]); + }); +}); diff --git a/server/typescript/packages/codegen-ts/test/requirement-walk.test.ts b/server/typescript/packages/codegen-ts/test/requirement-walk.test.ts index f99ec70ec..b0b1d6f77 100644 --- a/server/typescript/packages/codegen-ts/test/requirement-walk.test.ts +++ b/server/typescript/packages/codegen-ts/test/requirement-walk.test.ts @@ -5,12 +5,16 @@ import { describe, test, expect } from "bun:test"; import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; -import type { MetaData } from "@metaobjectsdev/metadata"; +import type { MetaData, MetaRequirement } from "@metaobjectsdev/metadata"; import { walkRequirements, concernOf, groupByConcern, NO_CONCERN, + requirementDigest, + witnessKeyOf, + requirementTestIdentities, + witnessKeyCollisions, } from "../src/requirement-walk.js"; // The claimed nodes deliberately span THREE distinct types. A model whose targets @@ -158,3 +162,319 @@ describe("groupByConcern — the fan-out unit", () => { expect(groups.get("field.string")?.length).toBe(2); }); }); + +// --------------------------------------------------------------------------- +// Test identity, digest and grain — the records the other four ports copy. +// --------------------------------------------------------------------------- + +const ORDER = { + "object.entity": { + name: "Order", + children: [ + { "field.long": { name: "id" } }, + { "field.currency": { name: "total" } }, + { "source.rdb": { "@table": "orders" } }, + { "identity.primary": { name: "pk", "@fields": ["id"] } }, + ], + }, +}; + +type Json = Record; + +const functional = (name: string, attrs: Json, children: Json[] = []): Json => ({ + "requirement.functional": { + name, + "@statement": "s", + "@counterexample": "c", + ...attrs, + ...(children.length > 0 ? { children } : {}), + }, +}); + +/** The worked example of the plan's contract tables, exactly. */ +const RECORDED = functional("Recorded", { + "@level": 4, + "@status": "live", + "@statement": "An order is recorded when it is placed.", + "@counterexample": "A placed order has no row.", + "@implementedBy": ["Order"], +}); +const REFUNDED = functional("Refunded", { + "@level": 4, + "@status": "planned", + "@statement": "A refund is recorded against its order.", + "@counterexample": "A refund with no order.", +}); +const RECORDED_DIGEST = "2714aa3925a47959aa5e48ae39d80ed203fd4e2caa046a90aab9e04691d9881a"; +const REFUNDED_DIGEST = "4ddcd781ccc2fe462bc316711d5cedb88b803af1727a0df19fe3a69487aa6f86"; + +const shop = (...requirements: Json[]): Json => ({ + "metadata.root": { package: "acme::shop", children: [ORDER, ...requirements] }, +}); + +const WORKED_EXAMPLE = shop( + functional("Orders", { "@level": 3, "@status": "live" }, [RECORDED, REFUNDED]), +); + +async function loadDocs(...docs: Json[]): Promise { + const r = await new MetaDataLoader().load( + docs.map((d) => new InMemoryStringSource(JSON.stringify(d))), + ); + if (r.errors.length > 0) { + throw new Error(`Loader errors:\n${r.errors.map((e) => e.message).join("\n")}`); + } + return r.root; +} + +async function requirementAt(doc: Json, path: string): Promise { + const found = walkRequirements(await loadDocs(doc)).find((w) => w.view.path === path); + if (found === undefined) throw new Error(`no requirement at ${path}`); + return found.node; +} + +describe("requirementDigest — did the claim change", () => { + test("the digest of the worked example is pinned", async () => { + // functional, level 4, live, one ref "Order" — Table G + const recorded = await requirementAt(WORKED_EXAMPLE, "Orders.Recorded"); + expect(requirementDigest(recorded)).toBe( + "2714aa3925a47959aa5e48ae39d80ed203fd4e2caa046a90aab9e04691d9881a", + ); + }); + + test("a requirement with no links hashes an empty reference list", async () => { + const refunded = await requirementAt(WORKED_EXAMPLE, "Orders.Refunded"); + expect(requirementDigest(refunded)).toBe(REFUNDED_DIGEST); + }); + + test("the digest ignores title, notes, disposition and trackedBy", async () => { + const plain = functional("Gap", { "@level": 4, "@status": "partial" }); + const annotated = functional("Gap", { + "@level": 4, + "@status": "partial", + "@title": "A title", + "@notes": "Some notes.", + "@disposition": "deferred", + "@trackedBy": ["#42"], + }); + const reworded = functional("Gap", { + "@level": 4, + "@status": "partial", + "@statement": "a different claim", + }); + const base = requirementDigest(await requirementAt(shop(plain), "Gap")); + expect(requirementDigest(await requirementAt(shop(annotated), "Gap"))).toBe(base); + // …and the comparison is not vacuous: a changed claim does move it. + expect(requirementDigest(await requirementAt(shop(reworded), "Gap"))).not.toBe(base); + }); + + test("the digest normalises CRLF", async () => { + const withBreak = (br: string): Json => + functional("Gap", { + "@level": 4, + "@status": "live", + "@statement": `first${br}second`, + "@counterexample": `one${br}two`, + }); + const lf = requirementDigest(await requirementAt(shop(withBreak("\n")), "Gap")); + expect(requirementDigest(await requirementAt(shop(withBreak("\r\n")), "Gap"))).toBe(lf); + expect(requirementDigest(await requirementAt(shop(withBreak("\r")), "Gap"))).toBe(lf); + }); +}); + +describe("witnessKeyOf", () => { + test("witness keys follow Table F", () => { + expect(witnessKeyOf("acme::shop::Orders.Recorded", "object.entity")) + .toBe("req_acme_shop_Orders_Recorded__object_entity"); + expect(witnessKeyOf("acme::shop::Orders.Recorded", "Order.total")) + .toBe("req_acme_shop_Orders_Recorded__Order_total"); + expect(witnessKeyOf("acme::shop::Orders.Recorded", "*")) + .toBe("req_acme_shop_Orders_Recorded"); + }); +}); + +describe("the requirement view's package", () => { + test("the view carries the effective package", async () => { + // One requirement takes the file's default package, one declares its own. + const root = await loadDocs( + shop( + functional("Local", { "@level": 4, "@status": "live" }), + { + "requirement.functional": { + name: "Elsewhere", + package: "acme::billing", + "@level": 4, + "@status": "live", + "@statement": "s", + "@counterexample": "c", + }, + }, + ), + ); + const packages = Object.fromEntries( + walkRequirements(root).map((w) => [w.view.path, w.view.package]), + ); + expect(packages).toEqual({ Local: "acme::shop", Elsewhere: "acme::billing" }); + }); + + test("an unpackaged requirement has the empty package and a bare address", async () => { + const root = await loadDocs({ + "metadata.root": { + children: [functional("Bare", { "@level": 4, "@status": "live" })], + }, + }); + expect(walkRequirements(root)[0]?.view.package).toBe(""); + const [only] = requirementTestIdentities(root); + expect(only?.id).toBe("Bare [*]"); + expect(only?.witnessKey).toBe("req_Bare"); + }); +}); + +describe("requirementTestIdentities — one record per generated test", () => { + test("the worked example yields the records of Table F", async () => { + expect(requirementTestIdentities(await loadDocs(WORKED_EXAMPLE))).toEqual([ + { + package: "acme::shop", + path: "Orders.Recorded", + unit: "object.entity", + id: "acme::shop::Orders.Recorded [object.entity]", + witnessKey: "req_acme_shop_Orders_Recorded__object_entity", + status: "live", + skip: null, + digest: RECORDED_DIGEST, + }, + { + package: "acme::shop", + path: "Orders.Refunded", + unit: "*", + id: "acme::shop::Orders.Refunded [*]", + witnessKey: "req_acme_shop_Orders_Refunded", + status: "planned", + skip: "planned", + digest: REFUNDED_DIGEST, + }, + ]); + }); + + // "Missing" does not resolve; "Order" is repeated; the qualified spelling names the + // same entity as the bare one and is still a different reference AS AUTHORED. + const MEMBERS = shop( + functional("Recorded", { + "@level": 4, + "@status": "live", + "@implementedBy": ["Order", "acme::shop::Order", "Order", "Missing", "Order.total"], + }), + ); + + test("member grain yields one identity per distinct resolving reference", async () => { + const tests = requirementTestIdentities(await loadDocs(MEMBERS), { grain: "member" }); + // Sorted by id, and in code units "." sorts before "]". + expect(tests.map((t) => t.unit)).toEqual(["Order.total", "Order", "acme::shop::Order"]); + expect(tests.map((t) => t.witnessKey)).toEqual([ + "req_acme_shop_Recorded__Order_total", + "req_acme_shop_Recorded__Order", + "req_acme_shop_Recorded__acme_shop_Order", + ]); + // The default grain fans the same requirement out by concern instead. + expect(requirementTestIdentities(await loadDocs(MEMBERS)).map((t) => t.unit)).toEqual([ + "field.currency", + "object.entity", + ]); + }); + + test("a requirement with no resolved target yields unit *", async () => { + // Nothing declared, and a planned requirement naming only nodes that do not exist. + const root = await loadDocs( + shop( + functional("Unlinked", { "@level": 4, "@status": "live" }), + functional("Ahead", { "@level": 4, "@status": "planned", "@implementedBy": ["NotYet"] }), + ), + ); + for (const grain of ["concern", "member"] as const) { + expect(requirementTestIdentities(root, { grain }).map((t) => t.id)).toEqual([ + "acme::shop::Ahead [*]", + "acme::shop::Unlinked [*]", + ]); + } + }); + + test("skip is derived from the status lists", async () => { + const root = await loadDocs( + shop( + functional("A", { "@level": 4, "@status": "planned" }), + functional("B", { "@level": 4, "@status": "live" }), + functional("C", { "@level": 4, "@status": "partial" }), + functional("D", { "@level": 4, "@status": "retired" }), + ), + ); + expect(requirementTestIdentities(root).map((t) => [t.path, t.status, t.skip])).toEqual([ + ["A", "planned", "planned"], + ["B", "live", null], + ["C", "partial", null], + ["D", "retired", "retired"], + ]); + }); + + test("identities come back sorted by id", async () => { + // Declared out of order, and mixed-case on purpose: a code-unit comparison puts + // every capital before every lower-case letter, where a locale collation would + // interleave them — and a collation differs between machines and between ports. + const root = await loadDocs( + shop( + functional("beta", { "@level": 4, "@status": "live" }), + functional("Zeta", { "@level": 4, "@status": "live" }), + functional("alpha", { "@level": 4, "@status": "live" }), + functional("Beta", { "@level": 4, "@status": "live" }), + ), + ); + expect(requirementTestIdentities(root).map((t) => t.path)).toEqual([ + "Beta", + "Zeta", + "alpha", + "beta", + ]); + }); + + test("a filter replaces the default and its view carries the effective package", async () => { + const root = await loadDocs( + shop(functional("Orders", { "@level": 3, "@status": "live" }, [RECORDED])), + { + "metadata.root": { + package: "acme::billing", + children: [functional("Invoiced", { "@level": 4, "@status": "live" })], + }, + }, + ); + const tests = requirementTestIdentities(root, { + filter: (r) => r.package === "acme::shop", + }); + // The L3 parent is IN — the default would have dropped it, so the predicate + // replaced the default rather than narrowing it — and the other package is out. + expect(tests.map((t) => t.id)).toEqual([ + "acme::shop::Orders [*]", + "acme::shop::Orders.Recorded [object.entity]", + ]); + }); +}); + +describe("witnessKeyCollisions", () => { + test("two addresses that mangle alike are reported as a collision", async () => { + const root = await loadDocs( + shop( + functional("Orders_Recorded", { "@level": 4, "@status": "live", "@implementedBy": ["Order"] }), + functional("Orders", { "@level": 3, "@status": "live" }, [RECORDED]), + ), + ); + const tests = requirementTestIdentities(root); + expect(witnessKeyCollisions(tests)).toEqual([ + [ + "acme::shop::Orders.Recorded [object.entity]", + "acme::shop::Orders_Recorded [object.entity]", + ], + ]); + }); + + test("distinct keys report nothing", async () => { + const tests = requirementTestIdentities(await loadDocs(WORKED_EXAMPLE)); + expect(witnessKeyCollisions(tests)).toEqual([]); + }); +}); From fbd3e489fef0fdf4cdbed20429e5f1dd6faf403a Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 5 Oct 2026 23:43:38 -0400 Subject: [PATCH 05/43] feat(codegen-ts): a reference template for requirement-tests, so meta eject can hand it to the application requirement-tests was package-only because no reference template existed. It now ships one holding the generator and the default stub renderer in a single file, since an eject copies one file and the stub text is what an application is most likely to change. The built-in stays the oracle: the copy is byte-identity-gated over a ledger with requirements, in both grains. Refs docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md and ADR-0057. --- docs/features/own-your-codegen.md | 2 +- .../packages/cli/test/eject-multi.test.ts | 23 ++ .../codegen-ts/src/generator-registry.ts | 2 +- .../codegen-ts/src/reference-templates.ts | 4 + .../src/reference/requirement-tests.ts | 346 ++++++++++++++++++ .../test/reference-byte-identical.test.ts | 163 ++++++++- .../test/reference-templates.test.ts | 1 + .../ADR-0034-codegen-scaffold-and-own.md | 6 +- 8 files changed, 542 insertions(+), 5 deletions(-) create mode 100644 server/typescript/packages/codegen-ts/src/reference/requirement-tests.ts diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index e9214ba20..679a23f90 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -289,7 +289,7 @@ generator code. The 20-line programmatic shape for each port is in | Port | Invocation | Programmatic — write a `Generator` | Declarative — template + scope | |---|---|---|---| -| **TypeScript** | `meta init` → `meta gen --list --probe` → `meta eject ` → `meta gen` (Bun/Node CLI) | **Yes.** `meta generator new ` writes a working generator of your own into `codegen/generators/` and wires it — the first move for an output nothing ships. To start from a reference instead: `meta init` scaffolds the LAYOUT and an empty selection (ADR-0034 Amendment 2); `meta eject ...` copies each generator you choose into `codegen/generators/*.ts` and prints the import to add to `metaobjects.config.ts`. Edit them freely. The prompt tier (`prompt-render`, `output-parser`, `extractor`, `output-prompt`, `render-helper`) ejects like the rest: you own which templates get a module and where it lands, while the render and extract engines those modules call stay in the package. Not every registered generator is ejectable: `callable`, `trace-helper`, `requirement-tests`, the `template` primitive, the docs tier (`docs`, `api-docs`, `mermaid-er`, whose door is `meta docs`) and `shared-model` ship no reference template, so they are **package-only** — `meta gen --list` marks them so and `meta eject` names them as such. `shared-model` (FR-023's publisher generator) stays package-only deliberately, since it emits a hash-pinned cross-port contract artifact. | **Yes** — `templateGenerator({ template, scope, outputPattern })` in the config's `generators: [...]`. No CLI flag: the config already takes generator values. | +| **TypeScript** | `meta init` → `meta gen --list --probe` → `meta eject ` → `meta gen` (Bun/Node CLI) | **Yes.** `meta generator new ` writes a working generator of your own into `codegen/generators/` and wires it — the first move for an output nothing ships. To start from a reference instead: `meta init` scaffolds the LAYOUT and an empty selection (ADR-0034 Amendment 2); `meta eject ...` copies each generator you choose into `codegen/generators/*.ts` and prints the import to add to `metaobjects.config.ts`. Edit them freely. The prompt tier (`prompt-render`, `output-parser`, `extractor`, `output-prompt`, `render-helper`) ejects like the rest: you own which templates get a module and where it lands, while the render and extract engines those modules call stay in the package. Not every registered generator is ejectable: `callable`, `trace-helper`, the `template` primitive, the docs tier (`docs`, `api-docs`, `mermaid-er`, whose door is `meta docs`) and `shared-model` ship no reference template, so they are **package-only** — `meta gen --list` marks them so and `meta eject` names them as such. (As of 2026-10-05 `requirement-tests` is no longer one of them: it ships a reference template, and `meta eject requirement-tests` copies the generator together with its default stub renderer.) `shared-model` (FR-023's publisher generator) stays package-only deliberately, since it emits a hash-pinned cross-port contract artifact. | **Yes** — `templateGenerator({ template, scope, outputPattern })` in the config's `generators: [...]`. No CLI flag: the config already takes generator values. | | **Java / Kotlin** | `mvn metaobjects:generate` / `mvn metaobjects:verify` (`metaobjects-maven-plugin`) | **Yes.** Extend `FileEmittingGenerator` and read the model through `ModelWalk` (both in `metaobjects-codegen-base`) for one of your own. Every generator — built-in or your own — is named in `` and loaded from the project classpath: one seam, not two. There is no default suite, so `` is the complete list. Kotlin runs through the same goal. | **Yes** — `TemplateScopeGenerator` wired as an ordinary ``. No CLI flag: `` is already the seam. | | **C#** | `dotnet meta gen` / `dotnet meta verify` (.NET tool) | **Yes.** Implement `IGenerator` in the owned console project `codegen/` and list it in `codegen/Program.cs`; `dotnet meta gen` / `verify --codegen` hand off to that project whenever `codegen/Codegen.csproj` exists. `dotnet meta eject ` scaffolds the project, or write its two files by hand. An owned generator the `--generators` selection does not name still runs. | **Yes** — `dotnet meta gen --template-spec --template-root `. | | **Python** | `metaobjects gen` / `metaobjects verify` (console-script) | **Yes.** Name your generator as `module:symbol` in `--generators` or a target's `generators` in `metaobjects.config.yaml`; the symbol is an instance or a function returning one. Read the model through `metaobjects.codegen.model_walk`. (`--provider module:symbol` registers **metamodel vocabulary**, not a generator.) | **Yes** — `metaobjects gen --template-spec --templates `. | diff --git a/server/typescript/packages/cli/test/eject-multi.test.ts b/server/typescript/packages/cli/test/eject-multi.test.ts index e64690529..deae7a513 100644 --- a/server/typescript/packages/cli/test/eject-multi.test.ts +++ b/server/typescript/packages/cli/test/eject-multi.test.ts @@ -91,6 +91,29 @@ describe("meta eject takes many names", () => { } }); + test("requirement-tests ejects — it ships a reference template", async () => { + // It used to answer `package-only`: the registry listed it and no template existed. + const dir = tmp(); + try { + expect(await ejectCommand(["requirement-tests"], dir, "json")).toBe(0); + expect(erred.join("\n")).not.toContain("package-only"); + const copy = join(dir, "codegen/generators/requirement-tests.ts"); + expect(existsSync(copy)).toBe(true); + // One eject hands over the generator AND the default stub renderer. + const source = readFileSync(copy, "utf8"); + expect(source).toContain("export function requirementTests("); + expect(source).toContain("export function renderRequirementTest("); + const [row] = payload().ejected; + expect(row?.status).toBe("created"); + expect(row?.wire.entry).toBe("requirementTests()"); + expect(row?.wire.import).toBe( + 'import { requirementTests } from "./codegen/generators/requirement-tests.js";', + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("a repeated name is a usage error, not a silent second write", async () => { const dir = tmp(); try { diff --git a/server/typescript/packages/codegen-ts/src/generator-registry.ts b/server/typescript/packages/codegen-ts/src/generator-registry.ts index 8ab2a7ede..4a43ef827 100644 --- a/server/typescript/packages/codegen-ts/src/generator-registry.ts +++ b/server/typescript/packages/codegen-ts/src/generator-registry.ts @@ -400,7 +400,7 @@ export const generatorRegistry: Record = { description: "Per-requirement test stub, one per requirement.functional claim in the ledger.", tier: "native", factory: () => requirementTests(), - options: "filter?, target?", + options: "filter?, grain?, target?", ejectable: ejectable("requirement-tests"), }, "shared-model": { diff --git a/server/typescript/packages/codegen-ts/src/reference-templates.ts b/server/typescript/packages/codegen-ts/src/reference-templates.ts index 44951e817..112ab3ee5 100644 --- a/server/typescript/packages/codegen-ts/src/reference-templates.ts +++ b/server/typescript/packages/codegen-ts/src/reference-templates.ts @@ -18,6 +18,10 @@ export const REFERENCE_GENERATOR_NAMES = [ // The prompt tier (ADR-0034 Amendment 3): each is a thin generator over a public // `render*` composer, so an adopter owns WHICH prompts get a module and where it lands. "prompt-render", "output-parser", "extractor", "output-prompt", "render-helper", + // The requirement-test stubs: ONE file holding the generator and the default stub + // renderer, because an eject copies one file and the stub text is what an application + // is most likely to change. + "requirement-tests", ] as const; export type ReferenceGeneratorName = (typeof REFERENCE_GENERATOR_NAMES)[number]; diff --git a/server/typescript/packages/codegen-ts/src/reference/requirement-tests.ts b/server/typescript/packages/codegen-ts/src/reference/requirement-tests.ts new file mode 100644 index 000000000..861e609d3 --- /dev/null +++ b/server/typescript/packages/codegen-ts/src/reference/requirement-tests.ts @@ -0,0 +1,346 @@ +// REFERENCE TEMPLATE — copy this into your repo (e.g. codegen/generators/requirement-tests.ts) and own it. +// Then import it LOCALLY in metaobjects.config.ts: +// import { requirementTests } from "./codegen/generators/requirement-tests.js"; +// +// RUNTIME: this file executes under whatever runs `meta gen`, and the published CLI's +// shebang is `#!/usr/bin/env node` — so it runs under NODE even in a Bun project. Do not +// reach for `Bun.*` globals here; they are undefined and take the whole run down with +// `Bun is not defined`. Use `node:` builtins instead. +// targets: bun:test. Each emitted stub imports `test` and `expect` from "bun:test" +// and nothing from MetaObjects. For vitest, jest or node:test, change the +// import line and the failing call in `renderRequirementTest` below. +// use-when: the model declares `requirement.*` nodes and you want one test stub per +// requirement the model claims. A live or partial requirement gets a stub +// that FAILS until someone writes its assertion; a planned or retired one +// gets a skipped stub. +// emits: /requirements/..test.ts for each requirement the +// filter keeps, one per distinct `type.subType` it claims, and +// /requirements/.test.ts when it claims nothing. Under +// `grain: "member"` there is one stub per claimed reference instead. +// customize: the stub text (`renderRequirementTest`, below, is the default renderer), +// which requirements get a stub (`filter`), one stub per concern or one per +// reference (`grain`), and where stubs land (`path`, together with `owns`). +// What stays in the package is the walk, the grouping into tests, each +// test's identity and the digest of a claim. Every language port agrees on +// those, so replace them only if you mean to stop agreeing. +// composes-with: nothing. It reads the requirement ledger, not another generator's output. +// +// THE DIVISION OF LABOUR: the package owns the MECHANISM; this file is the POLICY, and it +// is yours. Which requirements get tests, at which levels, in what style, or none at all, +// is the application's decision. The filter IS the policy declaration, which is why there +// is no opt-out vocabulary: a requirement no generator matches expects no stub. +// +// Everything below imports ONLY from `@metaobjectsdev/codegen-ts` (the stable engine) and +// `@metaobjectsdev/metadata` (the named constants of the requirement vocabulary). + +import { + GENERATED_HEADER, + NO_CONCERN, + defaultRequirementTestFilter, + requirementTestIdentity, + requirementTestUnits, + walkRequirements, + type EmittedFile, + type GenContext, + type Generator, + type RequirementTestArgs, + type RequirementTestGrain, + type RequirementTestRenderer, + type RequirementView, + type ResolvedClaim, +} from "@metaobjectsdev/codegen-ts"; +import { + REQUIREMENT_ATTR_COUNTEREXAMPLE, + REQUIREMENT_ATTR_STATEMENT, + REQUIREMENT_STATUS_RETIRED, +} from "@metaobjectsdev/metadata"; + +// --------------------------------------------------------------------------- +// The default stub renderer. +// +// The package supplies DATA (statement, counterexample, status, claimed references, the +// test's identity and digest); a renderer supplies SYNTAX. This is the one you get when +// you register no renderer of your own, and the part of this file most worth editing. +// +// The load-bearing rule: an empty generated stub must NOT pass. A `live` entry claims the +// capability works, so an empty green test asserts the opposite of the claim. +// --------------------------------------------------------------------------- + +/** + * Escape an author-supplied value for a double-quoted TS string literal. + * + * Unescaped, a quote closes the literal and a newline breaks it, and the stub no longer + * parses. Applied to EVERY value that reaches a literal, not only the two that are + * obviously prose: the requirement path and the concern land in the same literals. + */ +function forStringLiteral(s: string): string { + return s + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\r?\n/g, "\\n") + .replace(/\r/g, "\\r"); +} + +/** + * Make an author-supplied value safe inside a JSDoc block, preserving what was written. + * + * A comment terminator would close the block early and spill the rest into code, so it + * is split by a space (never deleted, and never by an invisible character); a newline + * gets its own continuation marker. Every interpolated value goes through this, + * `@trackedBy` included: it is free-form by design. + */ +function forDocComment(s: string): string { + return s.replace(/\*\//g, "* /").replace(/\r?\n/g, "\n * "); +} + +function claimLines(targets: readonly ResolvedClaim[]): string { + if (targets.length === 0) { + return " * (none — this requirement names no model nodes)"; + } + return targets + .map((t) => ` * - ${forDocComment(t.ref)} (${forDocComment(t.concern)})`) + .join("\n"); +} + +function gapLine(a: RequirementTestArgs): string { + const tracked = a.trackedBy ?? []; + if (a.disposition === undefined && tracked.length === 0) return ""; + const decided = forDocComment(a.disposition ?? "undecided"); + const refs = tracked.length > 0 ? ` — ${tracked.map(forDocComment).join(", ")}` : ""; + return `\n *\n * Known gap: ${decided}${refs}`; +} + +export function renderRequirementTest(a: RequirementTestArgs): string { + // `skip` is null only for the statuses that claim the capability works right now + // (`live` and `partial`). A stub for anything else is skipped rather than failing: a + // red build for something nobody intends to build is noise an application silences + // wholesale, taking the live stubs with it. + const skipped = a.skip !== null; + const runner = skipped ? "test.skip" : "test"; + // The test NAME is the link between the ledger entry and the assertion, and it is a + // string literal: an unescaped quote in either value closes it. + const testName = `${forStringLiteral(a.view.path)} [${forStringLiteral(a.concern)}]`; + + // A live or partial stub asserts FAILURE until someone writes the real assertion over + // it, and names the requirement in the message, so a red run says which claim is + // unproven. The two skipped statuses mean OPPOSITE things and must not share a body: + // `planned` is intended and not built yet; `retired` was built and deliberately + // removed, and telling its reader to write the assertion "when this becomes live" + // would instruct them to revive it. + let body: string[]; + if (!skipped) { + body = [ + " expect.unreachable(", + ` "unimplemented requirement stub: ${testName} — " +`, + ` "replace this with an assertion that fails when: ${forStringLiteral(a.counterexample)}",`, + " );", + ]; + } else if (a.skip === REQUIREMENT_STATUS_RETIRED) { + body = [ + " // Retired: this capability was deliberately removed and must not be rebuilt.", + " // If you assert anything here, assert that it STAYS removed.", + ]; + } else { + body = [" // Intended, not built. Write the assertion when this becomes live."]; + } + + // The header states the CONDITION on the survival promise, not just the promise: a + // hand-written body is merged where this machine holds the file's snapshot, and the + // file is refused (never overwritten) where it does not. + const lines = [ + `// ${GENERATED_HEADER}.`, + "// The test IDENTITY is generated from the requirement; the BODY below is yours.", + "// Do not rename the test — the name is the link.", + "// Your body is never overwritten: MERGED where .metaobjects/.gen-state/ holds this", + "// file's snapshot body, REFUSED (run exits 1, body kept) where it does not. Those", + "// bodies are gitignored, so a fresh clone or CI is always the second case — see", + "// docs/features/own-your-codegen.md for the recovery.", + 'import { test, expect } from "bun:test";', + "", + "/**", + ` * ${forDocComment(a.statement)}`, + " *", + ` * Counterexample: ${forDocComment(a.counterexample)}${gapLine(a)}`, + " *", + " * Claims:", + claimLines(a.targets), + " */", + `${runner}("${testName}", () => {`, + ...body, + "});", + ]; + return `${lines.join("\n")}\n`; +} + +// --------------------------------------------------------------------------- +// The generator. +// --------------------------------------------------------------------------- + +export interface RequirementTestsOpts { + /** Generator name — surfaces in diagnostics and drift logs. */ + name?: string; + /** WHICH requirements get stubs. This is the app's policy declaration. */ + filter?: (r: RequirementView) => boolean; + /** + * What one stub stands for. `"concern"` (the default): one per distinct + * `type.subType` a requirement claims. `"member"`: one per distinct `@implementedBy` + * reference that resolves, for an application that wants a test per claimed node. + */ + grain?: RequirementTestGrain; + /** Renderer per concern key: exact `type.subType`, `type.*`, or `*`. In both grains + * the key is what the stub's target RESOLVES to, never the reference text. */ + renderers?: Record; + /** Full control over renderer selection — beats `renderers` when it returns one. */ + resolveRenderer?: (concern: string) => RequirementTestRenderer | undefined; + /** Where each stub lands. The second argument is the stub's fan-out key: the + * concern, or under `grain: "member"` the reference exactly as authored — which + * may hold `::`, so mangle it before it reaches a filename. */ + path?: (view: RequirementView, concern: string) => string; + /** Named output target (registry key). */ + target?: string; + /** Name requirements no filter covered. Default true; never fails the build. */ + warnUncovered?: boolean; + /** + * Which emitted paths this generator is the sole producer of, so the runner may + * remove a stub whose requirement was deleted. Given a path relative to this + * generator's output directory, `/`-separated. + * + * Defaults to the directory `defaultPath` writes into. Supply this whenever you + * supply `path`: the two describe the same namespace from opposite directions and + * only you can keep them in agreement. + */ + owns?: (relPathInTarget: string) => boolean; + /** Delete a hand-edited orphan rather than refusing it. Default false. */ + forceOrphanDelete?: boolean; + /** Turn orphan reconciliation off entirely. Default true — a stub whose requirement + * is gone is drift, and leaving it is how a deleted claim keeps a green test. */ + reconcileOrphans?: boolean; +} + +/** How many uncovered requirements to name before "…and N more". */ +const MAX_NAMED_UNCOVERED = 5; + +/** The directory `defaultPath` writes into — and therefore the namespace the default + * policy claims. Kept beside it so the pair cannot drift. */ +const DEFAULT_STUB_DIR = "requirements/"; + +const defaultPath = (view: RequirementView, concern: string): string => + concern === NO_CONCERN + ? `${DEFAULT_STUB_DIR}${view.path}.test.ts` + : `${DEFAULT_STUB_DIR}${view.path}.${concern}.test.ts`; + +/** + * The default path under `grain: "member"`, where the fan-out key is a reference. + * + * The reference is MANGLED into the last segment — every run of characters outside + * `[A-Za-z0-9]` becomes one `_` — because a reference may be package-qualified and + * `::` is not a legal filename on every platform this output is checked out on. + */ +const defaultMemberPath = (view: RequirementView, ref: string): string => + defaultPath(view, ref === NO_CONCERN ? ref : ref.replace(/[^A-Za-z0-9]+/g, "_")); + +/** Exact concern → `type.*` → `*` → the default renderer above. */ +function pickRenderer(concern: string, opts: RequirementTestsOpts): RequirementTestRenderer { + const viaFn = opts.resolveRenderer?.(concern); + if (viaFn !== undefined) return viaFn; + const map = opts.renderers ?? {}; + const typeOnly = `${concern.split(".")[0] ?? ""}.*`; + return map[concern] ?? map[typeOnly] ?? map[NO_CONCERN] ?? renderRequirementTest; +} + +// `attr()` RESOLVES in TypeScript, so a requirement that inherits its statement through +// `extends` renders what it effectively says. +function attrString(node: { attr: (n: string) => unknown }, name: string): string { + const v = node.attr(name); + return typeof v === "string" ? v : ""; +} + +export function requirementTests(opts: RequirementTestsOpts = {}): Generator { + const filter = opts.filter ?? defaultRequirementTestFilter; + const grain = opts.grain ?? "concern"; + const toPath = opts.path ?? (grain === "member" ? defaultMemberPath : defaultPath); + // A custom `path` with no custom `owns` leaves the default namespace pointing + // somewhere the generator no longer writes, so reconciliation matches nothing. That + // degrades safely — it can only ever delete less — but silently, hence the warning. + const namespaceUnknown = opts.path !== undefined && opts.owns === undefined; + + const generator: Generator = { + name: opts.name ?? "requirement-tests", + generate: (ctx: GenContext): EmittedFile[] => { + const files: EmittedFile[] = []; + const uncovered: string[] = []; + + if (namespaceUnknown && opts.reconcileOrphans !== false) { + ctx.warn( + "a custom 'path' was supplied without a matching 'owns', so a stub left " + + "behind by a deleted requirement will NOT be cleaned up. Supply 'owns' " + + "to describe where 'path' writes, or set reconcileOrphans: false.", + ); + } + + for (const walked of walkRequirements(ctx.loadedRoot)) { + if (!filter(walked.view)) { + uncovered.push(walked.view.path); + continue; + } + for (const [unit, targets] of requirementTestUnits(walked, grain)) { + const identity = requirementTestIdentity(walked, unit); + const args: RequirementTestArgs = { + view: walked.view, + concern: unit, + targets, + statement: attrString(walked.node, REQUIREMENT_ATTR_STATEMENT), + counterexample: attrString(walked.node, REQUIREMENT_ATTR_COUNTEREXAMPLE), + disposition: walked.node.disposition(), + trackedBy: walked.node.trackedBy(), + package: identity.package, + unit, + id: identity.id, + witnessKey: identity.witnessKey, + skip: identity.skip, + digest: identity.digest, + }; + // The renderer is chosen by what the stub's target IS, in both grains. Under + // the member grain the unit is a reference, and keying on it would match no + // `type.subType` entry and silently retire every renderer you registered. + const rendererKey = targets[0]?.concern ?? NO_CONCERN; + files.push({ + path: toPath(walked.view, unit), + content: pickRenderer(rendererKey, opts)(args), + }); + } + } + + // Policy living only in config means an uncovered requirement is indistinguishable + // from a deliberate exclusion. One warning, never failing, and CAPPED: the default + // filter excludes every architectural node and every L1-L3 functional one, so an + // uncapped list buries the one actionable sentence under a wall of dotted paths. + if ((opts.warnUncovered ?? true) && uncovered.length > 0) { + const shown = uncovered.slice(0, MAX_NAMED_UNCOVERED).join(", "); + const more = + uncovered.length > MAX_NAMED_UNCOVERED + ? `, and ${uncovered.length - MAX_NAMED_UNCOVERED} more` + : ""; + ctx.warn( + `${uncovered.length} requirement(s) matched no filter and get no stub. If that is deliberate, set warnUncovered: false to silence this. Uncovered: ${shown}${more}.`, + ); + } + + return files; + }, + }; + + if (opts.target !== undefined) generator.target = opts.target; + if (opts.reconcileOrphans !== false) { + generator.orphanPolicy = { + // With a custom `path` and no `owns`, claim NOTHING rather than guess: the default + // namespace would be a claim over a directory this generator does not write to, + // and a wrong claim deletes another generator's files. + owns: + opts.owns ?? + (namespaceUnknown ? () => false : (relPath) => relPath.startsWith(DEFAULT_STUB_DIR)), + ...(opts.forceOrphanDelete === true && { force: true }), + }; + } + return generator; +} diff --git a/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts b/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts index 9941d5f7a..388a03075 100644 --- a/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts +++ b/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts @@ -29,7 +29,10 @@ import { outputParser as refOutputParser } from "../src/reference/output-parser. import { extractor as refExtractor } from "../src/reference/extractor.js"; import { outputPrompt as refOutputPrompt } from "../src/reference/output-prompt.js"; import { renderHelper as refRenderHelper } from "../src/reference/render-helper.js"; -import { MetaDataLoader } from "@metaobjectsdev/metadata"; +import { requirementTests as builtinRequirementTests } from "../src/generators/requirement-tests.js"; +import { requirementTests as refRequirementTests } from "../src/reference/requirement-tests.js"; +import type { RequirementTestsOpts } from "../src/index.js"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; import { FileSource } from "@metaobjectsdev/metadata/core"; const FIXTURE_DIR = resolve(import.meta.dir, "fixtures"); @@ -114,6 +117,7 @@ const PAIRS: Record Generator; ref: () extractor: { builtin: builtinExtractor, ref: refExtractor }, "output-prompt": { builtin: builtinOutputPrompt, ref: refOutputPrompt }, "render-helper": { builtin: builtinRenderHelper, ref: refRenderHelper }, + "requirement-tests": { builtin: builtinRequirementTests, ref: refRequirementTests }, }; // The prompt tier emits nothing for the entity-shaped fixtures above — none declares a @@ -297,3 +301,160 @@ describe("ADR-0034 — the prompt-tier reference templates over corpora that dec }); } }); + +// The requirement-test reference is the one template that carries a RENDERER as well as a +// generator: an eject copies one file, and the stub text is what an application is most +// likely to change, so both live in it. That makes the copy larger than its siblings — +// every status branch, both escaping paths, the gap line, the uncovered warning — and no +// fixture above declares a requirement, so each of those would be compared over two empty +// sets. This ledger reaches every one of them. +const REQUIREMENT_LEDGER = { + "metadata.root": { + package: "acme::shop", + children: [ + { + "object.entity": { + name: "Order", + children: [ + { "field.long": { name: "id" } }, + { "field.string": { name: "note" } }, + { "field.string": { name: "memo" } }, + { "source.rdb": { "@table": "orders" } }, + { "identity.primary": { name: "pk", "@fields": ["id"] } }, + ], + }, + }, + { + "requirement.functional": { + name: "Orders", + "@level": 3, + "@status": "live", + "@statement": "Orders are taken.", + "@counterexample": "an order nobody can place", + children: [ + { + // L4, live, claiming the entity twice over — bare and package-qualified. + "requirement.functional": { + name: "Recorded", + "@level": 4, + "@status": "live", + "@statement": "An order is recorded when it is placed.", + "@counterexample": "A placed order has no row.", + "@implementedBy": ["Order", "acme::shop::Order"], + children: [ + { + // L5, partial with a tracked gap, two members of ONE concern, and + // prose that must be escaped in a string literal and in a comment. + "requirement.functional": { + name: "Annotated", + "@level": 5, + "@status": "partial", + "@disposition": "deferred", + "@trackedBy": ["#12", "a */ tracker"], + "@statement": "The note is kept \"verbatim\" */ as typed.\nOn every order.", + "@counterexample": "a note with a \\ dropped\r\nor a \"tidied\" one", + "@implementedBy": ["Order.note", "Order.memo"], + }, + }, + ], + }, + }, + { + // Planned: skipped, and naming a node that does not exist yet. + "requirement.functional": { + name: "Refunded", + "@level": 4, + "@status": "planned", + "@statement": "A refund is recorded against its order.", + "@counterexample": "A refund with no order.", + "@implementedBy": ["Refund"], + }, + }, + { + // Retired: skipped, with its own body. + "requirement.functional": { + name: "Faxed", + "@level": 4, + "@status": "retired", + "@statement": "An order can be faxed in.", + "@counterexample": "a fax line that answers", + }, + }, + { + // Live with no targets at all. + "requirement.functional": { + name: "Acknowledged", + "@level": 4, + "@status": "live", + "@statement": "An order is acknowledged.", + "@counterexample": "a silent checkout", + }, + }, + ], + }, + }, + { + "requirement.architectural": { + name: "Audited", + "@status": "live", + "@statement": "Every entity is audited.", + "@counterexample": "an entity with no audit trail", + "@implementedBy": ["Order"], + }, + }, + ], + }, +}; + +describe("ADR-0034 — the requirement-tests reference over a ledger that declares requirements", () => { + const RUNS: ReadonlyArray<{ label: string; opts: RequirementTestsOpts; files: number }> = [ + // Recorded, Annotated, Refunded, Faxed, Acknowledged — one concern each. + { label: "the defaults", opts: {}, files: 5 }, + // Every requirement (the L3 parent and the architectural policy included), one stub + // per reference: Orders 1, Recorded 2, Annotated 2, Refunded 1, Faxed 1, + // Acknowledged 1, Audited 1. + { label: 'grain: "member" under a filter that keeps everything', opts: { grain: "member", filter: () => true }, files: 9 }, + // A filter that drops requirements while the warning is on, in the default grain. + { label: "a filter by package and status", opts: { filter: (r) => r.package === "acme::shop" && r.status !== "retired" }, files: 6 }, + { label: "the uncovered warning switched off", opts: { warnUncovered: false }, files: 5 }, + ]; + + for (const run of RUNS) { + test(run.label, async () => { + const loaded = await new MetaDataLoader().load([ + new InMemoryStringSource(JSON.stringify(REQUIREMENT_LEDGER)), + ]); + expect(loaded.errors).toEqual([]); + + const emit = async (generator: Generator) => { + const dir = mkdtempSync(join(tmpdir(), "codegen-req-")); + try { + const result = await runGen({ + config: defineConfig({ outDir: join(dir, "out"), extStyle: "none", dbImport: "~/server/db", dialect: "sqlite", generators: [generator] }), + metadata: loaded.root, + projectRoot: dir, + }); + const files: Record = {}; + for (const f of readdirSync(join(dir, "out"), { recursive: true, withFileTypes: true })) { + if (!f.isFile()) continue; + const abs = join(f.parentPath, f.name); + files[abs.slice(join(dir, "out").length + 1)] = readFileSync(abs, "utf-8"); + } + return { files, warnings: result.warnings }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + + const a = await emit(builtinRequirementTests(run.opts)); + const b = await emit(refRequirementTests(run.opts)); + const aKeys = Object.keys(a.files).sort(); + // A gate over an empty emit passes trivially. + expect(aKeys.length).toBe(run.files); + expect(Object.keys(b.files).sort()).toEqual(aKeys); + for (const k of aKeys) expect(`${k}:\n${b.files[k]}`).toBe(`${k}:\n${a.files[k]}`); + // The warning text is duplicated in the copy too, so it is compared too. + expect(b.warnings).toEqual(a.warnings); + }); + } +}); diff --git a/server/typescript/packages/codegen-ts/test/reference-templates.test.ts b/server/typescript/packages/codegen-ts/test/reference-templates.test.ts index 65a5d15e9..8cd954149 100644 --- a/server/typescript/packages/codegen-ts/test/reference-templates.test.ts +++ b/server/typescript/packages/codegen-ts/test/reference-templates.test.ts @@ -15,6 +15,7 @@ describe("reference-templates reader", () => { expect([...REFERENCE_GENERATOR_NAMES]).toEqual([ "entity", "queries", "routes", "routes-hono", "barrel", "names", "prompt-render", "output-parser", "extractor", "output-prompt", "render-helper", + "requirement-tests", ]); }); diff --git a/spec/decisions/ADR-0034-codegen-scaffold-and-own.md b/spec/decisions/ADR-0034-codegen-scaffold-and-own.md index a20193823..2e187e5df 100644 --- a/spec/decisions/ADR-0034-codegen-scaffold-and-own.md +++ b/spec/decisions/ADR-0034-codegen-scaffold-and-own.md @@ -259,13 +259,15 @@ three UI templates, and every capability-tier generator was package-only. Two th C# and Python ports already ejected their equivalents. What an adopter owns is the thin generator — which templates get a module, and where it lands; the module body comes from a public `render*` composer, and the render and extract ENGINES it calls stay core. -- **What is still package-only says so.** `callable`, `trace-helper`, `requirement-tests`, +- **What is still package-only says so.** `callable`, `trace-helper`, the `template` primitive, the docs tier (`docs`, `api-docs`, `mermaid-er`) and `shared-model` ship no TypeScript reference template. `meta gen --list` marks each `package-only`, its JSON row carries `source.kind: "package-only"`, and `meta eject` names such an entry as package-only rather than as an unknown name. They remain helpers in the sense of this amendment — what they write is not a guarantee — but "copy it and own it" is - not available for them until a reference template ships. + not available for them until a reference template ships. (2026-10-05: `requirement-tests` + was on this list and has left it. It now ships a reference template holding the generator + and its default stub renderer in one file, so `meta eject requirement-tests` copies both.) The "condition this depends on" paragraph above is also out of date: every port now has an eject command (`docs/features/own-your-codegen.md`, "Per port"). From 27f283d2f18407f34b6ca0763518bb63e8c40ea5 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 5 Oct 2026 23:45:38 -0400 Subject: [PATCH 06/43] test(conformance): requirement-check corpus, run by the TypeScript reference 42 cases under fixtures/requirement-check-conformance pin the requirement gate's codes, severities, paths, message text and summary counts, so the four other ports can be held to the TypeScript reference. Expectations are written by scripts/write-requirement-corpus-expected.ts and reviewed by hand; the corpus gets its row in docs/CONFORMANCE.md and the site payload its count, which the site-counts gate requires of any new corpus. Refs docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md and ADR-0057. --- docs/CONFORMANCE.md | 3 +- examples/showcase/site-payload.json | 2 +- .../requirement-check-conformance/README.md | 130 +++++++++++++ .../arch-levelled-tree/expected.json | 34 ++++ .../arch-levelled-tree/input/meta.shop.yaml | 43 +++++ .../arch-live-no-implementers/expected.json | 29 +++ .../input/meta.shop.yaml | 28 +++ .../arch-mixed-grain-clean/expected.json | 15 ++ .../input/meta.shop.yaml | 25 +++ .../arch-planned-clean/expected.json | 16 ++ .../arch-planned-clean/input/meta.shop.yaml | 22 +++ .../arch-retired-clean/expected.json | 16 ++ .../arch-retired-clean/input/meta.shop.yaml | 22 +++ .../arch-unlevelled-exempt/expected.json | 15 ++ .../input/meta.shop.yaml | 33 ++++ .../bad-level-out-of-range/expected.json | 28 +++ .../input/meta.shop.yaml | 34 ++++ .../claim-on-root-template/expected.json | 15 ++ .../input/meta.shop.yaml | 29 +++ .../claim-package-local/expected.json | 15 ++ .../input/meta.billing.yaml | 16 ++ .../input/meta.shop.requirements.yaml | 10 + .../claim-package-local/input/meta.shop.yaml | 8 + .../clean-fully-claimed/expected.json | 15 ++ .../clean-fully-claimed/input/meta.shop.yaml | 59 ++++++ .../expected.json | 15 ++ .../input/meta.shop.yaml | 28 +++ .../coverage-exemptions/expected.json | 15 ++ .../coverage-exemptions/input/meta.shop.yaml | 33 ++++ .../expected.json | 26 +++ .../input/meta.shop.yaml | 29 +++ .../expected.json | 14 ++ .../input/meta.shop.yaml | 15 ++ .../options.json | 5 + .../expected.json | 14 ++ .../input/meta.iam.overlay.yaml | 11 ++ .../input/meta.shop.yaml | 15 ++ .../options.json | 5 + .../expected.json | 22 +++ .../input/meta.shop.yaml | 23 +++ .../options.json | 5 + .../expected.json | 22 +++ .../input/meta.shop.yaml | 31 +++ .../coverage-unclaimed-entity/expected.json | 21 ++ .../input/meta.shop.yaml | 23 +++ .../expected.json | 22 +++ .../input/meta.billing.yaml | 8 + .../input/meta.ledger.yaml | 27 +++ .../input/meta.shop.yaml | 8 + .../expected.json | 28 +++ .../input/meta.shop.yaml | 34 ++++ .../expected.json | 22 +++ .../input/meta.shop.yaml | 26 +++ .../expected.json | 22 +++ .../input/meta.billing.yaml | 8 + .../input/meta.shop.yaml | 34 ++++ .../dangling-object-live/expected.json | 22 +++ .../dangling-object-live/input/meta.shop.yaml | 39 ++++ .../dangling-object-partial/expected.json | 23 +++ .../input/meta.shop.yaml | 39 ++++ .../expected.json | 16 ++ .../input/meta.shop.yaml | 39 ++++ .../deferred-untracked/expected.json | 23 +++ .../deferred-untracked/input/meta.shop.yaml | 34 ++++ .../disposition-not-applicable/expected.json | 29 +++ .../input/meta.shop.yaml | 27 +++ .../l4-names-member/expected.json | 28 +++ .../l4-names-member/input/meta.shop.yaml | 26 +++ .../l5-member-resolves-clean/expected.json | 15 ++ .../input/meta.shop.yaml | 47 +++++ .../l5-names-object/expected.json | 28 +++ .../l5-names-object/input/meta.shop.yaml | 26 +++ .../level-nesting/expected.json | 28 +++ .../level-nesting/input/meta.shop.yaml | 42 ++++ .../link-above-floor/expected.json | 28 +++ .../link-above-floor/input/meta.shop.yaml | 27 +++ .../no-requirements/expected.json | 4 + .../no-requirements/input/meta.shop.yaml | 15 ++ .../expected.json | 16 ++ .../input/meta.shop.yaml | 46 +++++ .../nothing-implements-subtree/expected.json | 35 ++++ .../input/meta.shop.yaml | 39 ++++ .../require-implementers/expected.json | 35 ++++ .../require-implementers/input/meta.shop.yaml | 39 ++++ .../require-implementers/options.json | 3 + .../retired-clean/expected.json | 16 ++ .../retired-clean/input/meta.shop.yaml | 31 +++ .../same-name-in-two-branches/expected.json | 28 +++ .../input/meta.shop.yaml | 46 +++++ .../summary-undecided-rollup/expected.json | 16 ++ .../input/meta.shop.yaml | 87 +++++++++ .../superseded-by-dangling/expected.json | 35 ++++ .../input/meta.shop.yaml | 55 ++++++ .../superseded-by-package-local/expected.json | 16 ++ .../input/meta.acme.yaml | 11 ++ .../input/meta.archive.yaml | 11 ++ .../input/meta.billing.yaml | 30 +++ .../input/meta.shop.yaml | 32 ++++ .../expected.json | 16 ++ .../input/meta.shop.yaml | 47 +++++ scripts/write-requirement-corpus-expected.ts | 180 ++++++++++++++++++ .../requirement-check-conformance.test.ts | 98 ++++++++++ 102 files changed, 2844 insertions(+), 2 deletions(-) create mode 100644 fixtures/requirement-check-conformance/README.md create mode 100644 fixtures/requirement-check-conformance/arch-levelled-tree/expected.json create mode 100644 fixtures/requirement-check-conformance/arch-levelled-tree/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/arch-live-no-implementers/expected.json create mode 100644 fixtures/requirement-check-conformance/arch-live-no-implementers/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/arch-mixed-grain-clean/expected.json create mode 100644 fixtures/requirement-check-conformance/arch-mixed-grain-clean/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/arch-planned-clean/expected.json create mode 100644 fixtures/requirement-check-conformance/arch-planned-clean/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/arch-retired-clean/expected.json create mode 100644 fixtures/requirement-check-conformance/arch-retired-clean/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/arch-unlevelled-exempt/expected.json create mode 100644 fixtures/requirement-check-conformance/arch-unlevelled-exempt/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/bad-level-out-of-range/expected.json create mode 100644 fixtures/requirement-check-conformance/bad-level-out-of-range/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/claim-on-root-template/expected.json create mode 100644 fixtures/requirement-check-conformance/claim-on-root-template/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/claim-package-local/expected.json create mode 100644 fixtures/requirement-check-conformance/claim-package-local/input/meta.billing.yaml create mode 100644 fixtures/requirement-check-conformance/claim-package-local/input/meta.shop.requirements.yaml create mode 100644 fixtures/requirement-check-conformance/claim-package-local/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/clean-fully-claimed/expected.json create mode 100644 fixtures/requirement-check-conformance/clean-fully-claimed/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/coverage-arch-claim-propagates/expected.json create mode 100644 fixtures/requirement-check-conformance/coverage-arch-claim-propagates/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/coverage-exemptions/expected.json create mode 100644 fixtures/requirement-check-conformance/coverage-exemptions/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/expected.json create mode 100644 fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/coverage-library-only-not-measured/expected.json create mode 100644 fixtures/requirement-check-conformance/coverage-library-only-not-measured/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/coverage-library-only-not-measured/options.json create mode 100644 fixtures/requirement-check-conformance/coverage-library-overlay-does-not-activate/expected.json create mode 100644 fixtures/requirement-check-conformance/coverage-library-overlay-does-not-activate/input/meta.iam.overlay.yaml create mode 100644 fixtures/requirement-check-conformance/coverage-library-overlay-does-not-activate/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/coverage-library-overlay-does-not-activate/options.json create mode 100644 fixtures/requirement-check-conformance/coverage-library-plus-project/expected.json create mode 100644 fixtures/requirement-check-conformance/coverage-library-plus-project/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/coverage-library-plus-project/options.json create mode 100644 fixtures/requirement-check-conformance/coverage-planned-claim-does-not-count/expected.json create mode 100644 fixtures/requirement-check-conformance/coverage-planned-claim-does-not-count/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/coverage-unclaimed-entity/expected.json create mode 100644 fixtures/requirement-check-conformance/coverage-unclaimed-entity/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/dangling-bare-name-in-two-packages/expected.json create mode 100644 fixtures/requirement-check-conformance/dangling-bare-name-in-two-packages/input/meta.billing.yaml create mode 100644 fixtures/requirement-check-conformance/dangling-bare-name-in-two-packages/input/meta.ledger.yaml create mode 100644 fixtures/requirement-check-conformance/dangling-bare-name-in-two-packages/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/dangling-member-first-missing-segment/expected.json create mode 100644 fixtures/requirement-check-conformance/dangling-member-first-missing-segment/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/dangling-member-of-resolved-object/expected.json create mode 100644 fixtures/requirement-check-conformance/dangling-member-of-resolved-object/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/dangling-object-did-you-mean/expected.json create mode 100644 fixtures/requirement-check-conformance/dangling-object-did-you-mean/input/meta.billing.yaml create mode 100644 fixtures/requirement-check-conformance/dangling-object-did-you-mean/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/dangling-object-live/expected.json create mode 100644 fixtures/requirement-check-conformance/dangling-object-live/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/dangling-object-partial/expected.json create mode 100644 fixtures/requirement-check-conformance/dangling-object-partial/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/dangling-object-planned-clean/expected.json create mode 100644 fixtures/requirement-check-conformance/dangling-object-planned-clean/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/deferred-untracked/expected.json create mode 100644 fixtures/requirement-check-conformance/deferred-untracked/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/disposition-not-applicable/expected.json create mode 100644 fixtures/requirement-check-conformance/disposition-not-applicable/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/l4-names-member/expected.json create mode 100644 fixtures/requirement-check-conformance/l4-names-member/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/l5-member-resolves-clean/expected.json create mode 100644 fixtures/requirement-check-conformance/l5-member-resolves-clean/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/l5-names-object/expected.json create mode 100644 fixtures/requirement-check-conformance/l5-names-object/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/level-nesting/expected.json create mode 100644 fixtures/requirement-check-conformance/level-nesting/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/link-above-floor/expected.json create mode 100644 fixtures/requirement-check-conformance/link-above-floor/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/no-requirements/expected.json create mode 100644 fixtures/requirement-check-conformance/no-requirements/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/nothing-implements-delegating-parent-clean/expected.json create mode 100644 fixtures/requirement-check-conformance/nothing-implements-delegating-parent-clean/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/nothing-implements-subtree/expected.json create mode 100644 fixtures/requirement-check-conformance/nothing-implements-subtree/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/require-implementers/expected.json create mode 100644 fixtures/requirement-check-conformance/require-implementers/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/require-implementers/options.json create mode 100644 fixtures/requirement-check-conformance/retired-clean/expected.json create mode 100644 fixtures/requirement-check-conformance/retired-clean/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/same-name-in-two-branches/expected.json create mode 100644 fixtures/requirement-check-conformance/same-name-in-two-branches/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/summary-undecided-rollup/expected.json create mode 100644 fixtures/requirement-check-conformance/summary-undecided-rollup/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/superseded-by-dangling/expected.json create mode 100644 fixtures/requirement-check-conformance/superseded-by-dangling/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/superseded-by-package-local/expected.json create mode 100644 fixtures/requirement-check-conformance/superseded-by-package-local/input/meta.acme.yaml create mode 100644 fixtures/requirement-check-conformance/superseded-by-package-local/input/meta.archive.yaml create mode 100644 fixtures/requirement-check-conformance/superseded-by-package-local/input/meta.billing.yaml create mode 100644 fixtures/requirement-check-conformance/superseded-by-package-local/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/superseded-by-resolves-clean/expected.json create mode 100644 fixtures/requirement-check-conformance/superseded-by-resolves-clean/input/meta.shop.yaml create mode 100644 scripts/write-requirement-corpus-expected.ts create mode 100644 server/typescript/packages/cli/test/requirement-check-conformance.test.ts diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 1bcc00402..06b72372c 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -1,6 +1,6 @@ # Conformance coverage -The MetaObjects standard ships **26 shared conformance corpora** under +The MetaObjects standard ships **27 shared conformance corpora** under [`fixtures/`](../fixtures/). Every port runs every corpus that is *applicable to it* and asserts the same expected behaviour against the same fixtures. **This page is the inverse index**: fixture → feature doc + per-port pass status, and it is the @@ -50,6 +50,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/metamodel-docs/`](../fixtures/metamodel-docs/) | 1 | ✓ (docs emit is TS-owned) | — | — | — | — | | [`fixtures/fmt-conformance/`](../fixtures/fmt-conformance/) (#304 — `meta fmt`) | 12 | ✓ (reference) | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/field-lint-conformance/`](../fixtures/field-lint-conformance/) (the `verify` field authoring lint) | 15 | ✓ (reference) | ✓ | inherits via Java | ✓ | ✓ | +| [`fixtures/requirement-check-conformance/`](../fixtures/requirement-check-conformance/) (the `verify` requirement gate, ADR-0057) | 42 cases | ✓ (reference) | — (not yet) | — (not yet) | — (not yet) | — (not yet) | | [`fixtures/naming-conformance/`](../fixtures/naming-conformance/) | 8 cases | ✓ | ✓ | inherits via Java (`RouteNaming.pluralize`) | ✓ | ✓ | | [`fixtures/codegen-noop/`](../fixtures/codegen-noop/) (FR-044 — reporting vocabulary: what is lowered, what stays inert) | 1 model pair (`reporting/with` vs `reporting/without`) | ✓ (codegen + migrate) | ✓ | ✓ | ✓ | ✓ | diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index 6bb2339bc..fa3dfae22 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -8,7 +8,7 @@ }, "counts": { "fixtures": 364, - "corpora": 26, + "corpora": 27, "baseTypes": 17 }, "snippets": { diff --git a/fixtures/requirement-check-conformance/README.md b/fixtures/requirement-check-conformance/README.md new file mode 100644 index 000000000..4f1b7804c --- /dev/null +++ b/fixtures/requirement-check-conformance/README.md @@ -0,0 +1,130 @@ +# `verify` requirement-gate conformance corpus + +Every port's `verify` runs the **requirement gate**: it reads the `requirement.*` nodes of a +loaded model and reports what the loader cannot (ADR-0057). This corpus is the shared source +of truth for the gate's codes, severities, node addresses, message text and summary counts. + +TypeScript is the reference. The codes, their conditions and their order come from +`server/typescript/packages/cli/src/lib/requirement-check.ts`; a port reads that file before +it writes its own checks, and its test suite runs this corpus so the ports cannot drift. + +## Fixture format + +Each case is a directory: + +- `input/` holds one or more metadata documents (`.yaml` or `.json`). +- `options.json` is optional. Its keys are `libraries` (a string array: the shipped + libraries to load beside `input/`) and `requireImplementers` (a boolean: the strict + switch, `verify --require-implementers`). No other key is legal. +- `expected.json` holds `{ "diagnostics": [...], "summary": {...} | null }`. + +A runner does three things, in this order: + +1. Load `input/` with the port's loader, **strict**, with the libraries `options.json` + names, and assert **no load errors**. Load the files in ascending file-name order. +2. Run the gate over the loaded model, with the strict switch `options.json` sets + (default off), and compute the summary from the same run. +3. Compare both with `expected.json`. + +### What is compared + +`diagnostics` is an unordered multiset of `(severity, code, path, message)`. The files list +them in the order the reference reports them, which is not part of the contract. + +- `severity` is `error` or `warn`. +- `path` is the dotted chain of requirement names from the root, with no package: + `Shop.Orders.Recorded`. It is **absent** on `WARN_REQUIREMENT_OBJECT_UNCLAIMED`, whose + subject is an entity; compare an absent `path` as the empty string. +- `message` is compared exactly. + +`summary` is `null` when the model declares no requirement. Otherwise it holds `total`, +`functional`, `architectural`, `byStatus` (a count per status that occurs; a status with no +requirement has no key), `undecided` and `deferredUntracked`. `entitiesClaimed` and +`entitiesTotal` are present **only when coverage is measured**: their absence is the +statement that the project authored no requirement of its own. + +### Where the expectations come from + +`bun scripts/write-requirement-corpus-expected.ts check` writes every `expected.json` from +the TypeScript reference. Each written file is then reviewed by hand against the reference's +rules, and a case that reports more than its row below says gets its **input** fixed. + +A committed `expected.json` is never edited to make a port pass. A port that disagrees with +it is wrong, unless the reference is shown to be wrong first. + +## Things the cases pin that are easy to miss + +- **A rejected claim still counts toward coverage.** The claim set is built from every + requirement that is not `planned`, whatever its level and whatever the gate says about + the reference's grain. `link-above-floor`, `l4-names-member`, `l5-names-object` and + `arch-levelled-tree` each report an error on a claim and no unclaimed entity. +- **`ERR_REQUIREMENT_LINK_ABOVE_FLOOR` ends that requirement's checks.** Nothing else is + reported for the node (`link-above-floor`). +- **A grain error replaces the dangling check for that reference.** `l4-names-member` and + `l5-names-object` each hold a reference that does not resolve and report it once. +- **Existence is about naming, not resolving.** A requirement whose subtree holds any + non-empty `implementedBy` is not reported by `WARN_REQUIREMENT_NOTHING_IMPLEMENTS`, even + when the only one is on a `planned` child and names a node that does not exist yet + (`nothing-implements-delegating-parent-clean`). +- **`supersededBy` resolves against the ledger, not the model, and a bare path is looked up + across every package.** The name of an object does not resolve + (`superseded-by-dangling`); a path that exists only in another package does + (`superseded-by-package-local`). +- **The did-you-mean hint lists objects in load order.** That is why step 1 fixes the file + order (`dangling-bare-name-in-two-packages`). +- **The library cases count the library's own ledger.** The three `coverage-library-*` + summaries include the requirements the `iam` library ships, so a change to that ledger + changes those three files. Every port embeds the same library, so the ports still agree. + +## Cases + +| Case | What it pins | +|---|---| +| `no-requirements` | Entities and no requirement: no diagnostics, and `summary` is `null`. | +| `clean-fully-claimed` | An L1 to L5 tree that claims every entity: no diagnostics, and the full summary with both coverage counts. | +| `dangling-object-live` | `ERR_REQUIREMENT_DANGLING_REF` on a `live` requirement naming an object that does not exist. No object has that short name, so there is no hint. | +| `dangling-object-partial` | The same on `partial`. The message carries the status. | +| `dangling-object-planned-clean` | The same reference on `planned` reports nothing: a planned requirement may name nodes that do not exist yet. | +| `dangling-object-did-you-mean` | The hint when the short name exists in one other package. | +| `dangling-bare-name-in-two-packages` | A bare name that exists in two other packages binds neither. The hint lists both, in load order. | +| `dangling-member-of-resolved-object` | The object resolves and its member does not: the hint names the object's resolution key and the missing member. | +| `dangling-member-first-missing-segment` | Two-segment member paths. The hint names the first segment that did not resolve and the node it was looked for under: once when the first segment resolves, once when it does not. | +| `claim-on-root-template` | A root-level `template.prompt` is a claim target, by bare name and by qualified name. Coverage is measured over zero entities, so both counts are `0`. | +| `claim-package-local` | A bare reference binds in the requirement's own package across two files, while an object of the same name exists in another package and is claimed there the same way. | +| `link-above-floor` | `ERR_REQUIREMENT_LINK_ABOVE_FLOOR` at L1 and at L3. The `disposition` on the live L1 produces no `WARN_REQUIREMENT_DISPOSITION_NOT_APPLICABLE`. | +| `l4-names-member` | `ERR_REQUIREMENT_L4_NOT_OBJECT` on a functional L4, for a member that exists and for one that does not. | +| `l5-names-object` | `ERR_REQUIREMENT_L5_NOT_MEMBER` on a functional L5, for an object that exists and for one that does not. | +| `l5-member-resolves-clean` | A functional L5 naming a field, an identity, and a view under a field: no diagnostics. | +| `bad-level-out-of-range` | `ERR_REQUIREMENT_BAD_LEVEL` for levels 0 and 6 on functional requirements. The message has no architectural suffix. | +| `level-nesting` | `ERR_REQUIREMENT_LEVEL_NESTING` for a child at its parent's level and for a child above it. | +| `arch-live-no-implementers` | `ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS` on a flat policy that is `live` and on one that is `partial`. | +| `arch-planned-clean` | A `planned` flat policy with no implementer reports nothing. | +| `arch-retired-clean` | A `retired` flat policy with no implementer reports nothing. | +| `arch-levelled-tree` | Levelled architectural requirements: an L1 with links gets `ERR_REQUIREMENT_LINK_ABOVE_FLOOR`; an L1 and an L2 without links get no `ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS`; a child that re-ascends gets `ERR_REQUIREMENT_LEVEL_NESTING`; a level of 7 gets `ERR_REQUIREMENT_BAD_LEVEL` with the architectural suffix. | +| `arch-unlevelled-exempt` | A flat policy at the root, and one nested under a levelled parent, get neither the level error nor the nesting error. | +| `arch-mixed-grain-clean` | An architectural L4 may name a member and an architectural L5 may name an object: no diagnostics. | +| `disposition-not-applicable` | `WARN_REQUIREMENT_DISPOSITION_NOT_APPLICABLE` on `live` and on `retired`. The message carries the disposition and the status. | +| `deferred-untracked` | `WARN_REQUIREMENT_DEFERRED_UNTRACKED` on a deferral with no `trackedBy`, and nothing on the tracked deferral beside it. `deferredUntracked` is `1`. | +| `nothing-implements-subtree` | `WARN_REQUIREMENT_NOTHING_IMPLEMENTS`, severity `warn`, on every node of a subtree in which nothing names a node: a `live` L1, a `partial` L3 and a `live` L4. | +| `nothing-implements-delegating-parent-clean` | A parent whose child claims is not reported. Neither is one whose only claiming child is `planned`. | +| `require-implementers` | The input of `nothing-implements-subtree`, byte for byte, with `requireImplementers: true`: the same code, path and message, at severity `error`. | +| `superseded-by-resolves-clean` | A `supersededBy` given as the requirement's path, and as the path qualified by its package: no diagnostics. | +| `superseded-by-dangling` | `ERR_REQUIREMENT_DANGLING_REF` for a `supersededBy` that is a misspelt path, a requirement's name without its path, and the name of an object. | +| `superseded-by-package-local` | The ledger lookup over four packages, all resolving: a bare path that exists in two packages, from each of them; a bare path that exists only in another package; a reference completed with the referrer's package. | +| `retired-clean` | A retired requirement, and a retired tree with nothing claimed beneath it, report nothing. | +| `same-name-in-two-branches` | Two requirements named `Recorded` report the same error with the same message. Only `path` tells them apart. | +| `coverage-unclaimed-entity` | `WARN_REQUIREMENT_OBJECT_UNCLAIMED` for the one entity no requirement claims. It has no `path`. | +| `coverage-planned-claim-does-not-count` | An entity claimed only by a `planned` requirement is still unclaimed. | +| `coverage-arch-claim-propagates` | An architectural claim on an abstract base covers a subtype and a subtype of that subtype: no diagnostics, two of two entities claimed. | +| `coverage-functional-claim-does-not-propagate` | The same model with a functional claim leaves both subtypes unclaimed. | +| `coverage-exemptions` | An abstract entity, an `object.value` and an `object.projection` are not counted: one of one entities claimed. | +| `coverage-library-only-not-measured` | `libraries: ["iam"]` and no project requirement: no unclaimed-entity warning, and no `entities*` keys in the summary. | +| `coverage-library-plus-project` | One project requirement switches coverage on, over the library's entities too. The library's ledger claims its own, so only the project's unclaimed entity is reported. | +| `coverage-library-overlay-does-not-activate` | An overlay on a library requirement changes that requirement's status and does not switch coverage on. | +| `summary-undecided-rollup` | `undecided` is `3` over four trees: a partial parent over a partial child counts once, at the child; a partial grandchild excludes both ancestors; a child with a `disposition` is not counted and still excludes its parent; a partial parent over a `live` child counts at the parent. | + +## Who asserts it + +| Port | Runner | +|---|---| +| TypeScript (reference) | `server/typescript/packages/cli/test/requirement-check-conformance.test.ts` | diff --git a/fixtures/requirement-check-conformance/arch-levelled-tree/expected.json b/fixtures/requirement-check-conformance/arch-levelled-tree/expected.json new file mode 100644 index 000000000..324f78740 --- /dev/null +++ b/fixtures/requirement-check-conformance/arch-levelled-tree/expected.json @@ -0,0 +1,34 @@ +{ + "diagnostics": [ + { + "severity": "error", + "code": "ERR_REQUIREMENT_LINK_ABOVE_FLOOR", + "path": "Security", + "message": "'implementedBy' is legal at L4 (object) and L5 (member) only. L1-L3 are organisational and never reference the model — move the links to a nested L4 child." + }, + { + "severity": "error", + "code": "ERR_REQUIREMENT_LEVEL_NESTING", + "path": "Reliability.Recovery", + "message": "nested under \"Reliability\" (level 2) but declares level 1. Nesting is the hierarchy — a child sits strictly below its parent." + }, + { + "severity": "error", + "code": "ERR_REQUIREMENT_BAD_LEVEL", + "path": "Portability", + "message": "level must be an integer 1-5 (got 7). L1 solution, L2 segment (app/library), L3 service, L4 object, L5 member. On an architectural requirement the level is optional — omit it for a flat policy." + } + ], + "summary": { + "total": 4, + "functional": 0, + "architectural": 4, + "byStatus": { + "live": 4 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/arch-levelled-tree/input/meta.shop.yaml b/fixtures/requirement-check-conformance/arch-levelled-tree/input/meta.shop.yaml new file mode 100644 index 000000000..8f5ff7a67 --- /dev/null +++ b/fixtures/requirement-check-conformance/arch-levelled-tree/input/meta.shop.yaml @@ -0,0 +1,43 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + # An organisational tier that names the model. + - requirement.architectural: + name: Security + level: 1 + status: live + statement: The shop protects the data it holds. + counterexample: A record read by someone with no claim to it. + implementedBy: [Order] + + # An organisational tier that names nothing: not a policy applied to nothing. + - requirement.architectural: + name: Reliability + level: 2 + status: live + statement: The shop keeps working when a part fails. + counterexample: One failed part stops every sale. + children: + # Re-ascends: a level above its parent. + - requirement.architectural: + name: Recovery + level: 1 + status: live + statement: The shop can be restored from a backup. + counterexample: A backup that cannot be restored. + + # Above the ladder. + - requirement.architectural: + name: Portability + level: 7 + status: live + statement: The shop runs on more than one database. + counterexample: A query only one database accepts. + implementedBy: [Order] diff --git a/fixtures/requirement-check-conformance/arch-live-no-implementers/expected.json b/fixtures/requirement-check-conformance/arch-live-no-implementers/expected.json new file mode 100644 index 000000000..d968fd8ab --- /dev/null +++ b/fixtures/requirement-check-conformance/arch-live-no-implementers/expected.json @@ -0,0 +1,29 @@ +{ + "diagnostics": [ + { + "severity": "error", + "code": "ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS", + "path": "Audited", + "message": "architectural requirement is 'live' but nothing implements it. Its check is universality — a claim set of zero means the policy is declared and unapplied." + }, + { + "severity": "error", + "code": "ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS", + "path": "Attributed", + "message": "architectural requirement is 'partial' but nothing implements it. Its check is universality — a claim set of zero means the policy is declared and unapplied." + } + ], + "summary": { + "total": 3, + "functional": 0, + "architectural": 3, + "byStatus": { + "live": 2, + "partial": 1 + }, + "undecided": 1, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/arch-live-no-implementers/input/meta.shop.yaml b/fixtures/requirement-check-conformance/arch-live-no-implementers/input/meta.shop.yaml new file mode 100644 index 000000000..f9c085d08 --- /dev/null +++ b/fixtures/requirement-check-conformance/arch-live-no-implementers/input/meta.shop.yaml @@ -0,0 +1,28 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.architectural: + name: Identified + status: live + statement: Every row is addressed by a uuid. + counterexample: A row keyed by a name. + implementedBy: [Order] + + - requirement.architectural: + name: Audited + status: live + statement: Every row records who changed it. + counterexample: A row with no change attribution. + + - requirement.architectural: + name: Attributed + status: partial + statement: Every row records when it changed. + counterexample: A row with no change time. diff --git a/fixtures/requirement-check-conformance/arch-mixed-grain-clean/expected.json b/fixtures/requirement-check-conformance/arch-mixed-grain-clean/expected.json new file mode 100644 index 000000000..e1d0d0760 --- /dev/null +++ b/fixtures/requirement-check-conformance/arch-mixed-grain-clean/expected.json @@ -0,0 +1,15 @@ +{ + "diagnostics": [], + "summary": { + "total": 2, + "functional": 0, + "architectural": 2, + "byStatus": { + "live": 2 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/arch-mixed-grain-clean/input/meta.shop.yaml b/fixtures/requirement-check-conformance/arch-mixed-grain-clean/input/meta.shop.yaml new file mode 100644 index 000000000..7f6c754c7 --- /dev/null +++ b/fixtures/requirement-check-conformance/arch-mixed-grain-clean/input/meta.shop.yaml @@ -0,0 +1,25 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.architectural: + name: Identified + level: 4 + status: live + statement: Every row is addressed by a uuid. + counterexample: A row keyed by a name. + implementedBy: [Order, Order.id] + children: + - requirement.architectural: + name: Keyed + level: 5 + status: live + statement: Every key is a single uuid column. + counterexample: A key made of two columns. + implementedBy: [Order, Order.pk] diff --git a/fixtures/requirement-check-conformance/arch-planned-clean/expected.json b/fixtures/requirement-check-conformance/arch-planned-clean/expected.json new file mode 100644 index 000000000..4393b2132 --- /dev/null +++ b/fixtures/requirement-check-conformance/arch-planned-clean/expected.json @@ -0,0 +1,16 @@ +{ + "diagnostics": [], + "summary": { + "total": 2, + "functional": 0, + "architectural": 2, + "byStatus": { + "planned": 1, + "live": 1 + }, + "undecided": 1, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/arch-planned-clean/input/meta.shop.yaml b/fixtures/requirement-check-conformance/arch-planned-clean/input/meta.shop.yaml new file mode 100644 index 000000000..377da8654 --- /dev/null +++ b/fixtures/requirement-check-conformance/arch-planned-clean/input/meta.shop.yaml @@ -0,0 +1,22 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.architectural: + name: Identified + status: live + statement: Every row is addressed by a uuid. + counterexample: A row keyed by a name. + implementedBy: [Order] + + - requirement.architectural: + name: Audited + status: planned + statement: Every row records who changed it. + counterexample: A row with no change attribution. diff --git a/fixtures/requirement-check-conformance/arch-retired-clean/expected.json b/fixtures/requirement-check-conformance/arch-retired-clean/expected.json new file mode 100644 index 000000000..747006404 --- /dev/null +++ b/fixtures/requirement-check-conformance/arch-retired-clean/expected.json @@ -0,0 +1,16 @@ +{ + "diagnostics": [], + "summary": { + "total": 2, + "functional": 0, + "architectural": 2, + "byStatus": { + "live": 1, + "retired": 1 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/arch-retired-clean/input/meta.shop.yaml b/fixtures/requirement-check-conformance/arch-retired-clean/input/meta.shop.yaml new file mode 100644 index 000000000..2635a3a4b --- /dev/null +++ b/fixtures/requirement-check-conformance/arch-retired-clean/input/meta.shop.yaml @@ -0,0 +1,22 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.architectural: + name: Identified + status: live + statement: Every row is addressed by a uuid. + counterexample: A row keyed by a name. + implementedBy: [Order] + + - requirement.architectural: + name: Audited + status: retired + statement: Every row records who changed it. + counterexample: A row with no change attribution. diff --git a/fixtures/requirement-check-conformance/arch-unlevelled-exempt/expected.json b/fixtures/requirement-check-conformance/arch-unlevelled-exempt/expected.json new file mode 100644 index 000000000..01b7899d1 --- /dev/null +++ b/fixtures/requirement-check-conformance/arch-unlevelled-exempt/expected.json @@ -0,0 +1,15 @@ +{ + "diagnostics": [], + "summary": { + "total": 3, + "functional": 1, + "architectural": 2, + "byStatus": { + "live": 3 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/arch-unlevelled-exempt/input/meta.shop.yaml b/fixtures/requirement-check-conformance/arch-unlevelled-exempt/input/meta.shop.yaml new file mode 100644 index 000000000..8103c8ea9 --- /dev/null +++ b/fixtures/requirement-check-conformance/arch-unlevelled-exempt/input/meta.shop.yaml @@ -0,0 +1,33 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + # A flat policy: no level, and none is demanded. + - requirement.architectural: + name: Identified + status: live + statement: Every row is addressed by a uuid. + counterexample: A row keyed by a name. + implementedBy: [Order] + + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + children: + # A flat policy under a levelled parent: it has no level to compare. + - requirement.architectural: + name: Addressable + status: live + statement: An order is addressed by one stable key. + counterexample: An order that cannot be pointed at. + implementedBy: [Order] diff --git a/fixtures/requirement-check-conformance/bad-level-out-of-range/expected.json b/fixtures/requirement-check-conformance/bad-level-out-of-range/expected.json new file mode 100644 index 000000000..e814c5212 --- /dev/null +++ b/fixtures/requirement-check-conformance/bad-level-out-of-range/expected.json @@ -0,0 +1,28 @@ +{ + "diagnostics": [ + { + "severity": "error", + "code": "ERR_REQUIREMENT_BAD_LEVEL", + "path": "Shop", + "message": "level must be an integer 1-5 (got 0). L1 solution, L2 segment (app/library), L3 service, L4 object, L5 member." + }, + { + "severity": "error", + "code": "ERR_REQUIREMENT_BAD_LEVEL", + "path": "Audited", + "message": "level must be an integer 1-5 (got 6). L1 solution, L2 segment (app/library), L3 service, L4 object, L5 member." + } + ], + "summary": { + "total": 3, + "functional": 3, + "architectural": 0, + "byStatus": { + "live": 3 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/bad-level-out-of-range/input/meta.shop.yaml b/fixtures/requirement-check-conformance/bad-level-out-of-range/input/meta.shop.yaml new file mode 100644 index 000000000..34ec6ce74 --- /dev/null +++ b/fixtures/requirement-check-conformance/bad-level-out-of-range/input/meta.shop.yaml @@ -0,0 +1,34 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + # Below the ladder. + - requirement.functional: + name: Shop + level: 0 + status: live + statement: A customer can buy from the shop. + counterexample: A customer who cannot place an order. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + # Above the ladder. + - requirement.functional: + name: Audited + level: 6 + status: live + statement: Every row records who changed it. + counterexample: A row with no change attribution. + implementedBy: [Order] diff --git a/fixtures/requirement-check-conformance/claim-on-root-template/expected.json b/fixtures/requirement-check-conformance/claim-on-root-template/expected.json new file mode 100644 index 000000000..eb7ac65b7 --- /dev/null +++ b/fixtures/requirement-check-conformance/claim-on-root-template/expected.json @@ -0,0 +1,15 @@ +{ + "diagnostics": [], + "summary": { + "total": 2, + "functional": 2, + "architectural": 0, + "byStatus": { + "live": 2 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 0, + "entitiesTotal": 0 + } +} diff --git a/fixtures/requirement-check-conformance/claim-on-root-template/input/meta.shop.yaml b/fixtures/requirement-check-conformance/claim-on-root-template/input/meta.shop.yaml new file mode 100644 index 000000000..d5d90a009 --- /dev/null +++ b/fixtures/requirement-check-conformance/claim-on-root-template/input/meta.shop.yaml @@ -0,0 +1,29 @@ +metadata: + package: acme::shop + children: + - object.value: + name: OrderConfirmationPayload + children: + - field.string: { name: reference } + + - template.prompt: + name: orderConfirmation + payloadRef: OrderConfirmationPayload + textRef: orders/confirmation + format: xml + + - requirement.functional: + name: Confirmed + level: 4 + status: live + statement: A customer is told their order was placed. + counterexample: A placed order nobody confirmed. + implementedBy: [orderConfirmation] + + - requirement.functional: + name: Acknowledged + level: 4 + status: live + statement: The confirmation names the order it is about. + counterexample: A confirmation that names no order. + implementedBy: ["acme::shop::orderConfirmation"] diff --git a/fixtures/requirement-check-conformance/claim-package-local/expected.json b/fixtures/requirement-check-conformance/claim-package-local/expected.json new file mode 100644 index 000000000..a95564bd5 --- /dev/null +++ b/fixtures/requirement-check-conformance/claim-package-local/expected.json @@ -0,0 +1,15 @@ +{ + "diagnostics": [], + "summary": { + "total": 2, + "functional": 2, + "architectural": 0, + "byStatus": { + "live": 2 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 2, + "entitiesTotal": 2 + } +} diff --git a/fixtures/requirement-check-conformance/claim-package-local/input/meta.billing.yaml b/fixtures/requirement-check-conformance/claim-package-local/input/meta.billing.yaml new file mode 100644 index 000000000..81cb1e88b --- /dev/null +++ b/fixtures/requirement-check-conformance/claim-package-local/input/meta.billing.yaml @@ -0,0 +1,16 @@ +metadata: + package: acme::billing + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Billed + level: 4 + status: live + statement: A billing order is recorded when it is raised. + counterexample: A raised billing order has no row. + implementedBy: [Order] diff --git a/fixtures/requirement-check-conformance/claim-package-local/input/meta.shop.requirements.yaml b/fixtures/requirement-check-conformance/claim-package-local/input/meta.shop.requirements.yaml new file mode 100644 index 000000000..1ab6a84bd --- /dev/null +++ b/fixtures/requirement-check-conformance/claim-package-local/input/meta.shop.requirements.yaml @@ -0,0 +1,10 @@ +metadata: + package: acme::shop + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] diff --git a/fixtures/requirement-check-conformance/claim-package-local/input/meta.shop.yaml b/fixtures/requirement-check-conformance/claim-package-local/input/meta.shop.yaml new file mode 100644 index 000000000..0b986d2dd --- /dev/null +++ b/fixtures/requirement-check-conformance/claim-package-local/input/meta.shop.yaml @@ -0,0 +1,8 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } diff --git a/fixtures/requirement-check-conformance/clean-fully-claimed/expected.json b/fixtures/requirement-check-conformance/clean-fully-claimed/expected.json new file mode 100644 index 000000000..322a2148f --- /dev/null +++ b/fixtures/requirement-check-conformance/clean-fully-claimed/expected.json @@ -0,0 +1,15 @@ +{ + "diagnostics": [], + "summary": { + "total": 6, + "functional": 6, + "architectural": 0, + "byStatus": { + "live": 6 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 2, + "entitiesTotal": 2 + } +} diff --git a/fixtures/requirement-check-conformance/clean-fully-claimed/input/meta.shop.yaml b/fixtures/requirement-check-conformance/clean-fully-claimed/input/meta.shop.yaml new file mode 100644 index 000000000..c9b9dcb05 --- /dev/null +++ b/fixtures/requirement-check-conformance/clean-fully-claimed/input/meta.shop.yaml @@ -0,0 +1,59 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - object.entity: + name: Customer + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Shop + level: 1 + status: live + statement: A customer can buy from the shop. + counterexample: A customer who cannot place an order. + children: + - requirement.functional: + name: Sales + level: 2 + status: live + statement: Sales are taken through the shop. + counterexample: A sale made outside the shop. + children: + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + children: + - requirement.functional: + name: Quotable + level: 5 + status: live + statement: An order carries a reference the customer can quote. + counterexample: An order nobody can quote back. + implementedBy: [Order.reference] + - requirement.functional: + name: Enrolled + level: 4 + status: live + statement: A customer is recorded once. + counterexample: Two rows for one customer. + implementedBy: [Customer] diff --git a/fixtures/requirement-check-conformance/coverage-arch-claim-propagates/expected.json b/fixtures/requirement-check-conformance/coverage-arch-claim-propagates/expected.json new file mode 100644 index 000000000..4adc5ced4 --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-arch-claim-propagates/expected.json @@ -0,0 +1,15 @@ +{ + "diagnostics": [], + "summary": { + "total": 1, + "functional": 0, + "architectural": 1, + "byStatus": { + "live": 1 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 2, + "entitiesTotal": 2 + } +} diff --git a/fixtures/requirement-check-conformance/coverage-arch-claim-propagates/input/meta.shop.yaml b/fixtures/requirement-check-conformance/coverage-arch-claim-propagates/input/meta.shop.yaml new file mode 100644 index 000000000..1dae32394 --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-arch-claim-propagates/input/meta.shop.yaml @@ -0,0 +1,28 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: BaseEntity + abstract: true + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - object.entity: + name: Order + extends: BaseEntity + children: + - field.string: { name: reference } + + - object.entity: + name: ExpressOrder + extends: Order + children: + - field.string: { name: courier } + + - requirement.architectural: + name: Identified + status: live + statement: Every row is addressed by a uuid. + counterexample: A row keyed by a name. + implementedBy: [BaseEntity] diff --git a/fixtures/requirement-check-conformance/coverage-exemptions/expected.json b/fixtures/requirement-check-conformance/coverage-exemptions/expected.json new file mode 100644 index 000000000..d206e55f4 --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-exemptions/expected.json @@ -0,0 +1,15 @@ +{ + "diagnostics": [], + "summary": { + "total": 1, + "functional": 1, + "architectural": 0, + "byStatus": { + "live": 1 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/coverage-exemptions/input/meta.shop.yaml b/fixtures/requirement-check-conformance/coverage-exemptions/input/meta.shop.yaml new file mode 100644 index 000000000..5fae8f25e --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-exemptions/input/meta.shop.yaml @@ -0,0 +1,33 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: BaseEntity + abstract: true + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - object.entity: + name: Order + extends: BaseEntity + children: + - field.string: { name: reference } + + - object.value: + name: Address + children: + - field.string: { name: city } + + - object.projection: + name: OrderSummary + children: + - field.string: { name: reference, extends: Order.reference } + + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] diff --git a/fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/expected.json b/fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/expected.json new file mode 100644 index 000000000..b0b669b97 --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/expected.json @@ -0,0 +1,26 @@ +{ + "diagnostics": [ + { + "severity": "warn", + "code": "WARN_REQUIREMENT_OBJECT_UNCLAIMED", + "message": "no requirement claims 'acme::shop::Order'. Add it to an L4 requirement's 'implementedBy'." + }, + { + "severity": "warn", + "code": "WARN_REQUIREMENT_OBJECT_UNCLAIMED", + "message": "no requirement claims 'acme::shop::ExpressOrder'. Add it to an L4 requirement's 'implementedBy'." + } + ], + "summary": { + "total": 1, + "functional": 1, + "architectural": 0, + "byStatus": { + "live": 1 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 0, + "entitiesTotal": 2 + } +} diff --git a/fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/input/meta.shop.yaml b/fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/input/meta.shop.yaml new file mode 100644 index 000000000..42495f16d --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/input/meta.shop.yaml @@ -0,0 +1,29 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: BaseEntity + abstract: true + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - object.entity: + name: Order + extends: BaseEntity + children: + - field.string: { name: reference } + + - object.entity: + name: ExpressOrder + extends: Order + children: + - field.string: { name: courier } + + - requirement.functional: + name: Kept + level: 4 + status: live + statement: Every record is kept. + counterexample: A record that is lost. + implementedBy: [BaseEntity] diff --git a/fixtures/requirement-check-conformance/coverage-library-only-not-measured/expected.json b/fixtures/requirement-check-conformance/coverage-library-only-not-measured/expected.json new file mode 100644 index 000000000..96c707a2c --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-library-only-not-measured/expected.json @@ -0,0 +1,14 @@ +{ + "diagnostics": [], + "summary": { + "total": 12, + "functional": 8, + "architectural": 4, + "byStatus": { + "live": 10, + "partial": 2 + }, + "undecided": 0, + "deferredUntracked": 0 + } +} diff --git a/fixtures/requirement-check-conformance/coverage-library-only-not-measured/input/meta.shop.yaml b/fixtures/requirement-check-conformance/coverage-library-only-not-measured/input/meta.shop.yaml new file mode 100644 index 000000000..61c027d31 --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-library-only-not-measured/input/meta.shop.yaml @@ -0,0 +1,15 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - object.entity: + name: Customer + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } diff --git a/fixtures/requirement-check-conformance/coverage-library-only-not-measured/options.json b/fixtures/requirement-check-conformance/coverage-library-only-not-measured/options.json new file mode 100644 index 000000000..1df71b7f8 --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-library-only-not-measured/options.json @@ -0,0 +1,5 @@ +{ + "libraries": [ + "iam" + ] +} diff --git a/fixtures/requirement-check-conformance/coverage-library-overlay-does-not-activate/expected.json b/fixtures/requirement-check-conformance/coverage-library-overlay-does-not-activate/expected.json new file mode 100644 index 000000000..70440157b --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-library-overlay-does-not-activate/expected.json @@ -0,0 +1,14 @@ +{ + "diagnostics": [], + "summary": { + "total": 12, + "functional": 8, + "architectural": 4, + "byStatus": { + "live": 9, + "partial": 3 + }, + "undecided": 0, + "deferredUntracked": 0 + } +} diff --git a/fixtures/requirement-check-conformance/coverage-library-overlay-does-not-activate/input/meta.iam.overlay.yaml b/fixtures/requirement-check-conformance/coverage-library-overlay-does-not-activate/input/meta.iam.overlay.yaml new file mode 100644 index 000000000..e33acd032 --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-library-overlay-does-not-activate/input/meta.iam.overlay.yaml @@ -0,0 +1,11 @@ +# An adopter disagreeing with a requirement the iam library ships. It merges into the +# library's node, in the library's package, so the project has still authored none. +metadata: + package: metaobjects::iam + children: + - requirement.architectural: + name: noCredentialsOnUser + overlay: true + status: partial + disposition: accepted + notes: A password hash is stored on the user row because the login stack predates this library. diff --git a/fixtures/requirement-check-conformance/coverage-library-overlay-does-not-activate/input/meta.shop.yaml b/fixtures/requirement-check-conformance/coverage-library-overlay-does-not-activate/input/meta.shop.yaml new file mode 100644 index 000000000..61c027d31 --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-library-overlay-does-not-activate/input/meta.shop.yaml @@ -0,0 +1,15 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - object.entity: + name: Customer + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } diff --git a/fixtures/requirement-check-conformance/coverage-library-overlay-does-not-activate/options.json b/fixtures/requirement-check-conformance/coverage-library-overlay-does-not-activate/options.json new file mode 100644 index 000000000..1df71b7f8 --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-library-overlay-does-not-activate/options.json @@ -0,0 +1,5 @@ +{ + "libraries": [ + "iam" + ] +} diff --git a/fixtures/requirement-check-conformance/coverage-library-plus-project/expected.json b/fixtures/requirement-check-conformance/coverage-library-plus-project/expected.json new file mode 100644 index 000000000..ed83334ac --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-library-plus-project/expected.json @@ -0,0 +1,22 @@ +{ + "diagnostics": [ + { + "severity": "warn", + "code": "WARN_REQUIREMENT_OBJECT_UNCLAIMED", + "message": "no requirement claims 'acme::shop::Customer'. Add it to an L4 requirement's 'implementedBy'." + } + ], + "summary": { + "total": 13, + "functional": 9, + "architectural": 4, + "byStatus": { + "live": 11, + "partial": 2 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 10, + "entitiesTotal": 11 + } +} diff --git a/fixtures/requirement-check-conformance/coverage-library-plus-project/input/meta.shop.yaml b/fixtures/requirement-check-conformance/coverage-library-plus-project/input/meta.shop.yaml new file mode 100644 index 000000000..6eb85bff3 --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-library-plus-project/input/meta.shop.yaml @@ -0,0 +1,23 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - object.entity: + name: Customer + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] diff --git a/fixtures/requirement-check-conformance/coverage-library-plus-project/options.json b/fixtures/requirement-check-conformance/coverage-library-plus-project/options.json new file mode 100644 index 000000000..1df71b7f8 --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-library-plus-project/options.json @@ -0,0 +1,5 @@ +{ + "libraries": [ + "iam" + ] +} diff --git a/fixtures/requirement-check-conformance/coverage-planned-claim-does-not-count/expected.json b/fixtures/requirement-check-conformance/coverage-planned-claim-does-not-count/expected.json new file mode 100644 index 000000000..739d8b4a7 --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-planned-claim-does-not-count/expected.json @@ -0,0 +1,22 @@ +{ + "diagnostics": [ + { + "severity": "warn", + "code": "WARN_REQUIREMENT_OBJECT_UNCLAIMED", + "message": "no requirement claims 'acme::shop::Customer'. Add it to an L4 requirement's 'implementedBy'." + } + ], + "summary": { + "total": 2, + "functional": 2, + "architectural": 0, + "byStatus": { + "planned": 1, + "live": 1 + }, + "undecided": 1, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 2 + } +} diff --git a/fixtures/requirement-check-conformance/coverage-planned-claim-does-not-count/input/meta.shop.yaml b/fixtures/requirement-check-conformance/coverage-planned-claim-does-not-count/input/meta.shop.yaml new file mode 100644 index 000000000..5267e0f76 --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-planned-claim-does-not-count/input/meta.shop.yaml @@ -0,0 +1,31 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - object.entity: + name: Customer + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + - requirement.functional: + name: Enrolled + level: 4 + status: planned + statement: A customer is recorded once. + counterexample: Two rows for one customer. + implementedBy: [Customer] diff --git a/fixtures/requirement-check-conformance/coverage-unclaimed-entity/expected.json b/fixtures/requirement-check-conformance/coverage-unclaimed-entity/expected.json new file mode 100644 index 000000000..66cc8b261 --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-unclaimed-entity/expected.json @@ -0,0 +1,21 @@ +{ + "diagnostics": [ + { + "severity": "warn", + "code": "WARN_REQUIREMENT_OBJECT_UNCLAIMED", + "message": "no requirement claims 'acme::shop::Customer'. Add it to an L4 requirement's 'implementedBy'." + } + ], + "summary": { + "total": 1, + "functional": 1, + "architectural": 0, + "byStatus": { + "live": 1 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 2 + } +} diff --git a/fixtures/requirement-check-conformance/coverage-unclaimed-entity/input/meta.shop.yaml b/fixtures/requirement-check-conformance/coverage-unclaimed-entity/input/meta.shop.yaml new file mode 100644 index 000000000..6eb85bff3 --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-unclaimed-entity/input/meta.shop.yaml @@ -0,0 +1,23 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - object.entity: + name: Customer + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] diff --git a/fixtures/requirement-check-conformance/dangling-bare-name-in-two-packages/expected.json b/fixtures/requirement-check-conformance/dangling-bare-name-in-two-packages/expected.json new file mode 100644 index 000000000..3202b1431 --- /dev/null +++ b/fixtures/requirement-check-conformance/dangling-bare-name-in-two-packages/expected.json @@ -0,0 +1,22 @@ +{ + "diagnostics": [ + { + "severity": "error", + "code": "ERR_REQUIREMENT_DANGLING_REF", + "path": "Reconciled", + "message": "'Order' does not resolve in the loaded model (status 'live' — the model moved and the requirement is stale). An object named \"Order\" exists in: acme::billing::Order, acme::shop::Order. Qualify it with its package (FQN)." + } + ], + "summary": { + "total": 3, + "functional": 3, + "architectural": 0, + "byStatus": { + "live": 3 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 2, + "entitiesTotal": 2 + } +} diff --git a/fixtures/requirement-check-conformance/dangling-bare-name-in-two-packages/input/meta.billing.yaml b/fixtures/requirement-check-conformance/dangling-bare-name-in-two-packages/input/meta.billing.yaml new file mode 100644 index 000000000..13c41709a --- /dev/null +++ b/fixtures/requirement-check-conformance/dangling-bare-name-in-two-packages/input/meta.billing.yaml @@ -0,0 +1,8 @@ +metadata: + package: acme::billing + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } diff --git a/fixtures/requirement-check-conformance/dangling-bare-name-in-two-packages/input/meta.ledger.yaml b/fixtures/requirement-check-conformance/dangling-bare-name-in-two-packages/input/meta.ledger.yaml new file mode 100644 index 000000000..68f59b541 --- /dev/null +++ b/fixtures/requirement-check-conformance/dangling-bare-name-in-two-packages/input/meta.ledger.yaml @@ -0,0 +1,27 @@ +metadata: + package: acme::ledger + children: + - requirement.functional: + name: ShopOrders + level: 4 + status: live + statement: A shop order is recorded when it is placed. + counterexample: A placed shop order has no row. + implementedBy: ["acme::shop::Order"] + + - requirement.functional: + name: BillingOrders + level: 4 + status: live + statement: A billing order is recorded when it is invoiced. + counterexample: An invoice with no billing order. + implementedBy: ["acme::billing::Order"] + + # Order exists in acme::billing and in acme::shop, and in neither is it local to acme::ledger. + - requirement.functional: + name: Reconciled + level: 4 + status: live + statement: Every shop order is matched to a billing order. + counterexample: A shop order billing never saw. + implementedBy: [Order] diff --git a/fixtures/requirement-check-conformance/dangling-bare-name-in-two-packages/input/meta.shop.yaml b/fixtures/requirement-check-conformance/dangling-bare-name-in-two-packages/input/meta.shop.yaml new file mode 100644 index 000000000..0b986d2dd --- /dev/null +++ b/fixtures/requirement-check-conformance/dangling-bare-name-in-two-packages/input/meta.shop.yaml @@ -0,0 +1,8 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } diff --git a/fixtures/requirement-check-conformance/dangling-member-first-missing-segment/expected.json b/fixtures/requirement-check-conformance/dangling-member-first-missing-segment/expected.json new file mode 100644 index 000000000..2ceef26da --- /dev/null +++ b/fixtures/requirement-check-conformance/dangling-member-first-missing-segment/expected.json @@ -0,0 +1,28 @@ +{ + "diagnostics": [ + { + "severity": "error", + "code": "ERR_REQUIREMENT_DANGLING_REF", + "path": "Recorded.Readable", + "message": "'Order.reference.display' does not resolve in the loaded model (status 'live' — the model moved and the requirement is stale). 'acme::shop::Order.reference' has no member 'display'." + }, + { + "severity": "error", + "code": "ERR_REQUIREMENT_DANGLING_REF", + "path": "Recorded.Tracked", + "message": "'Order.shipment.carrier' does not resolve in the loaded model (status 'live' — the model moved and the requirement is stale). 'acme::shop::Order' has no member 'shipment'." + } + ], + "summary": { + "total": 3, + "functional": 3, + "architectural": 0, + "byStatus": { + "live": 3 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/dangling-member-first-missing-segment/input/meta.shop.yaml b/fixtures/requirement-check-conformance/dangling-member-first-missing-segment/input/meta.shop.yaml new file mode 100644 index 000000000..b106733e0 --- /dev/null +++ b/fixtures/requirement-check-conformance/dangling-member-first-missing-segment/input/meta.shop.yaml @@ -0,0 +1,34 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + children: + # Order.reference resolves; it has no member named display. + - requirement.functional: + name: Readable + level: 5 + status: live + statement: An order reference is shown in a form a person can read. + counterexample: A reference shown as a raw string. + implementedBy: [Order.reference.display] + # Order has no member named shipment, so carrier is never reached. + - requirement.functional: + name: Tracked + level: 5 + status: live + statement: An order names the carrier that ships it. + counterexample: A shipped order with no carrier. + implementedBy: [Order.shipment.carrier] diff --git a/fixtures/requirement-check-conformance/dangling-member-of-resolved-object/expected.json b/fixtures/requirement-check-conformance/dangling-member-of-resolved-object/expected.json new file mode 100644 index 000000000..24cfb28b8 --- /dev/null +++ b/fixtures/requirement-check-conformance/dangling-member-of-resolved-object/expected.json @@ -0,0 +1,22 @@ +{ + "diagnostics": [ + { + "severity": "error", + "code": "ERR_REQUIREMENT_DANGLING_REF", + "path": "Recorded.Totalled", + "message": "'Order.total' does not resolve in the loaded model (status 'live' — the model moved and the requirement is stale). 'acme::shop::Order' has no member 'total'." + } + ], + "summary": { + "total": 2, + "functional": 2, + "architectural": 0, + "byStatus": { + "live": 2 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/dangling-member-of-resolved-object/input/meta.shop.yaml b/fixtures/requirement-check-conformance/dangling-member-of-resolved-object/input/meta.shop.yaml new file mode 100644 index 000000000..e27840895 --- /dev/null +++ b/fixtures/requirement-check-conformance/dangling-member-of-resolved-object/input/meta.shop.yaml @@ -0,0 +1,26 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + children: + # Order resolves; it has no member named total. + - requirement.functional: + name: Totalled + level: 5 + status: live + statement: An order carries the total the customer pays. + counterexample: An order with no total. + implementedBy: [Order.total] diff --git a/fixtures/requirement-check-conformance/dangling-object-did-you-mean/expected.json b/fixtures/requirement-check-conformance/dangling-object-did-you-mean/expected.json new file mode 100644 index 000000000..0497c08ae --- /dev/null +++ b/fixtures/requirement-check-conformance/dangling-object-did-you-mean/expected.json @@ -0,0 +1,22 @@ +{ + "diagnostics": [ + { + "severity": "error", + "code": "ERR_REQUIREMENT_DANGLING_REF", + "path": "Paid", + "message": "'Invoice' does not resolve in the loaded model (status 'live' — the model moved and the requirement is stale). An object named \"Invoice\" exists in: acme::billing::Invoice. Qualify it with its package (FQN)." + } + ], + "summary": { + "total": 3, + "functional": 3, + "architectural": 0, + "byStatus": { + "live": 3 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 2, + "entitiesTotal": 2 + } +} diff --git a/fixtures/requirement-check-conformance/dangling-object-did-you-mean/input/meta.billing.yaml b/fixtures/requirement-check-conformance/dangling-object-did-you-mean/input/meta.billing.yaml new file mode 100644 index 000000000..344fbea33 --- /dev/null +++ b/fixtures/requirement-check-conformance/dangling-object-did-you-mean/input/meta.billing.yaml @@ -0,0 +1,8 @@ +metadata: + package: acme::billing + children: + - object.entity: + name: Invoice + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } diff --git a/fixtures/requirement-check-conformance/dangling-object-did-you-mean/input/meta.shop.yaml b/fixtures/requirement-check-conformance/dangling-object-did-you-mean/input/meta.shop.yaml new file mode 100644 index 000000000..6b25eb927 --- /dev/null +++ b/fixtures/requirement-check-conformance/dangling-object-did-you-mean/input/meta.shop.yaml @@ -0,0 +1,34 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + - requirement.functional: + name: Invoiced + level: 4 + status: live + statement: An invoice is raised for every order. + counterexample: An order with no invoice. + implementedBy: ["acme::billing::Invoice"] + + # Invoice lives in acme::billing, so the bare name does not bind from acme::shop. + - requirement.functional: + name: Paid + level: 4 + status: live + statement: A payment is recorded against its invoice. + counterexample: A payment with no invoice. + implementedBy: [Invoice] diff --git a/fixtures/requirement-check-conformance/dangling-object-live/expected.json b/fixtures/requirement-check-conformance/dangling-object-live/expected.json new file mode 100644 index 000000000..34fb753d3 --- /dev/null +++ b/fixtures/requirement-check-conformance/dangling-object-live/expected.json @@ -0,0 +1,22 @@ +{ + "diagnostics": [ + { + "severity": "error", + "code": "ERR_REQUIREMENT_DANGLING_REF", + "path": "Shop.Orders.Refunded", + "message": "'Refund' does not resolve in the loaded model (status 'live' — the model moved and the requirement is stale)." + } + ], + "summary": { + "total": 4, + "functional": 4, + "architectural": 0, + "byStatus": { + "live": 4 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/dangling-object-live/input/meta.shop.yaml b/fixtures/requirement-check-conformance/dangling-object-live/input/meta.shop.yaml new file mode 100644 index 000000000..7bff2b3d4 --- /dev/null +++ b/fixtures/requirement-check-conformance/dangling-object-live/input/meta.shop.yaml @@ -0,0 +1,39 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Shop + level: 1 + status: live + statement: A customer can buy from the shop. + counterexample: A customer who cannot place an order. + children: + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + # No object is named Refund. + - requirement.functional: + name: Refunded + level: 4 + status: live + statement: A refund is recorded against its order. + counterexample: A refund with no order. + implementedBy: [Refund] diff --git a/fixtures/requirement-check-conformance/dangling-object-partial/expected.json b/fixtures/requirement-check-conformance/dangling-object-partial/expected.json new file mode 100644 index 000000000..eeadd8c84 --- /dev/null +++ b/fixtures/requirement-check-conformance/dangling-object-partial/expected.json @@ -0,0 +1,23 @@ +{ + "diagnostics": [ + { + "severity": "error", + "code": "ERR_REQUIREMENT_DANGLING_REF", + "path": "Shop.Orders.Refunded", + "message": "'Refund' does not resolve in the loaded model (status 'partial' — the model moved and the requirement is stale)." + } + ], + "summary": { + "total": 4, + "functional": 4, + "architectural": 0, + "byStatus": { + "live": 3, + "partial": 1 + }, + "undecided": 1, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/dangling-object-partial/input/meta.shop.yaml b/fixtures/requirement-check-conformance/dangling-object-partial/input/meta.shop.yaml new file mode 100644 index 000000000..c1bcd398b --- /dev/null +++ b/fixtures/requirement-check-conformance/dangling-object-partial/input/meta.shop.yaml @@ -0,0 +1,39 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Shop + level: 1 + status: live + statement: A customer can buy from the shop. + counterexample: A customer who cannot place an order. + children: + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + # No object is named Refund. + - requirement.functional: + name: Refunded + level: 4 + status: partial + statement: A refund is recorded against its order. + counterexample: A refund with no order. + implementedBy: [Refund] diff --git a/fixtures/requirement-check-conformance/dangling-object-planned-clean/expected.json b/fixtures/requirement-check-conformance/dangling-object-planned-clean/expected.json new file mode 100644 index 000000000..916ad5ba1 --- /dev/null +++ b/fixtures/requirement-check-conformance/dangling-object-planned-clean/expected.json @@ -0,0 +1,16 @@ +{ + "diagnostics": [], + "summary": { + "total": 4, + "functional": 4, + "architectural": 0, + "byStatus": { + "planned": 1, + "live": 3 + }, + "undecided": 1, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/dangling-object-planned-clean/input/meta.shop.yaml b/fixtures/requirement-check-conformance/dangling-object-planned-clean/input/meta.shop.yaml new file mode 100644 index 000000000..cf9bf7196 --- /dev/null +++ b/fixtures/requirement-check-conformance/dangling-object-planned-clean/input/meta.shop.yaml @@ -0,0 +1,39 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Shop + level: 1 + status: live + statement: A customer can buy from the shop. + counterexample: A customer who cannot place an order. + children: + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + # No object is named Refund. + - requirement.functional: + name: Refunded + level: 4 + status: planned + statement: A refund is recorded against its order. + counterexample: A refund with no order. + implementedBy: [Refund] diff --git a/fixtures/requirement-check-conformance/deferred-untracked/expected.json b/fixtures/requirement-check-conformance/deferred-untracked/expected.json new file mode 100644 index 000000000..04e23c9b2 --- /dev/null +++ b/fixtures/requirement-check-conformance/deferred-untracked/expected.json @@ -0,0 +1,23 @@ +{ + "diagnostics": [ + { + "severity": "warn", + "code": "WARN_REQUIREMENT_DEFERRED_UNTRACKED", + "path": "Refunded", + "message": "is deferred but names no @trackedBy issue. Deferring without a ticket is how a known gap becomes an unknown one — nothing will raise it again." + } + ], + "summary": { + "total": 3, + "functional": 3, + "architectural": 0, + "byStatus": { + "planned": 2, + "live": 1 + }, + "undecided": 0, + "deferredUntracked": 1, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/deferred-untracked/input/meta.shop.yaml b/fixtures/requirement-check-conformance/deferred-untracked/input/meta.shop.yaml new file mode 100644 index 000000000..c46d28469 --- /dev/null +++ b/fixtures/requirement-check-conformance/deferred-untracked/input/meta.shop.yaml @@ -0,0 +1,34 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + - requirement.functional: + name: Refunded + level: 4 + status: planned + disposition: deferred + statement: A refund is recorded against its order. + counterexample: A refund with no order. + + - requirement.functional: + name: Exchanged + level: 4 + status: planned + disposition: deferred + trackedBy: [SHOP-412] + statement: An exchange is recorded against its order. + counterexample: An exchange with no order. diff --git a/fixtures/requirement-check-conformance/disposition-not-applicable/expected.json b/fixtures/requirement-check-conformance/disposition-not-applicable/expected.json new file mode 100644 index 000000000..de7cc9a40 --- /dev/null +++ b/fixtures/requirement-check-conformance/disposition-not-applicable/expected.json @@ -0,0 +1,29 @@ +{ + "diagnostics": [ + { + "severity": "warn", + "code": "WARN_REQUIREMENT_DISPOSITION_NOT_APPLICABLE", + "path": "Recorded", + "message": "carries @disposition 'accepted' but its status is 'live', which has no outstanding work to decide about. A disposition is meaningful on 'planned' and 'partial' only — on any other status the decision IS the status." + }, + { + "severity": "warn", + "code": "WARN_REQUIREMENT_DISPOSITION_NOT_APPLICABLE", + "path": "Faxed", + "message": "carries @disposition 'deferred' but its status is 'retired', which has no outstanding work to decide about. A disposition is meaningful on 'planned' and 'partial' only — on any other status the decision IS the status." + } + ], + "summary": { + "total": 2, + "functional": 2, + "architectural": 0, + "byStatus": { + "live": 1, + "retired": 1 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/disposition-not-applicable/input/meta.shop.yaml b/fixtures/requirement-check-conformance/disposition-not-applicable/input/meta.shop.yaml new file mode 100644 index 000000000..748a51808 --- /dev/null +++ b/fixtures/requirement-check-conformance/disposition-not-applicable/input/meta.shop.yaml @@ -0,0 +1,27 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Recorded + level: 4 + status: live + disposition: accepted + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + - requirement.functional: + name: Faxed + level: 4 + status: retired + disposition: deferred + trackedBy: [SHOP-12] + statement: An order confirmation is sent by fax. + counterexample: A confirmed order with no fax. diff --git a/fixtures/requirement-check-conformance/l4-names-member/expected.json b/fixtures/requirement-check-conformance/l4-names-member/expected.json new file mode 100644 index 000000000..ae46c1d8d --- /dev/null +++ b/fixtures/requirement-check-conformance/l4-names-member/expected.json @@ -0,0 +1,28 @@ +{ + "diagnostics": [ + { + "severity": "error", + "code": "ERR_REQUIREMENT_L4_NOT_OBJECT", + "path": "Quotable", + "message": "L4 references an object; 'Order.reference' names a member. Move it to a nested L5 child, or reference the object itself." + }, + { + "severity": "error", + "code": "ERR_REQUIREMENT_L4_NOT_OBJECT", + "path": "Totalled", + "message": "L4 references an object; 'Order.total' names a member. Move it to a nested L5 child, or reference the object itself." + } + ], + "summary": { + "total": 2, + "functional": 2, + "architectural": 0, + "byStatus": { + "live": 2 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/l4-names-member/input/meta.shop.yaml b/fixtures/requirement-check-conformance/l4-names-member/input/meta.shop.yaml new file mode 100644 index 000000000..a39d49de2 --- /dev/null +++ b/fixtures/requirement-check-conformance/l4-names-member/input/meta.shop.yaml @@ -0,0 +1,26 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Quotable + level: 4 + status: live + statement: An order carries a reference the customer can quote. + counterexample: An order nobody can quote back. + implementedBy: [Order.reference] + + # Order has no member named total. The grain error is reported and the dangling check is skipped. + - requirement.functional: + name: Totalled + level: 4 + status: live + statement: An order carries the total the customer pays. + counterexample: An order with no total. + implementedBy: [Order.total] diff --git a/fixtures/requirement-check-conformance/l5-member-resolves-clean/expected.json b/fixtures/requirement-check-conformance/l5-member-resolves-clean/expected.json new file mode 100644 index 000000000..a7968484d --- /dev/null +++ b/fixtures/requirement-check-conformance/l5-member-resolves-clean/expected.json @@ -0,0 +1,15 @@ +{ + "diagnostics": [], + "summary": { + "total": 4, + "functional": 4, + "architectural": 0, + "byStatus": { + "live": 4 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/l5-member-resolves-clean/input/meta.shop.yaml b/fixtures/requirement-check-conformance/l5-member-resolves-clean/input/meta.shop.yaml new file mode 100644 index 000000000..d12388acd --- /dev/null +++ b/fixtures/requirement-check-conformance/l5-member-resolves-clean/input/meta.shop.yaml @@ -0,0 +1,47 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - field.currency: + name: total + currency: USD + children: + - view.currency: { name: display, locale: en-US } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + children: + # A field. + - requirement.functional: + name: Quotable + level: 5 + status: live + statement: An order carries a reference the customer can quote. + counterexample: An order nobody can quote back. + implementedBy: [Order.reference] + # An identity. + - requirement.functional: + name: Addressable + level: 5 + status: live + statement: An order is addressed by one stable key. + counterexample: An order that cannot be pointed at. + implementedBy: [Order.pk] + # A view under a field. + - requirement.functional: + name: Priced + level: 5 + status: live + statement: An order total is shown in the customer currency. + counterexample: A total shown as a bare number. + implementedBy: [Order.total.display] diff --git a/fixtures/requirement-check-conformance/l5-names-object/expected.json b/fixtures/requirement-check-conformance/l5-names-object/expected.json new file mode 100644 index 000000000..5b044324b --- /dev/null +++ b/fixtures/requirement-check-conformance/l5-names-object/expected.json @@ -0,0 +1,28 @@ +{ + "diagnostics": [ + { + "severity": "error", + "code": "ERR_REQUIREMENT_L5_NOT_MEMBER", + "path": "Recorded", + "message": "L5 references a member (field, view or identity); 'Order' names an object. Move it to its L4 parent." + }, + { + "severity": "error", + "code": "ERR_REQUIREMENT_L5_NOT_MEMBER", + "path": "Refunded", + "message": "L5 references a member (field, view or identity); 'Refund' names an object. Move it to its L4 parent." + } + ], + "summary": { + "total": 2, + "functional": 2, + "architectural": 0, + "byStatus": { + "live": 2 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/l5-names-object/input/meta.shop.yaml b/fixtures/requirement-check-conformance/l5-names-object/input/meta.shop.yaml new file mode 100644 index 000000000..43f265d9b --- /dev/null +++ b/fixtures/requirement-check-conformance/l5-names-object/input/meta.shop.yaml @@ -0,0 +1,26 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Recorded + level: 5 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + # No object is named Refund. The grain error is reported and the dangling check is skipped. + - requirement.functional: + name: Refunded + level: 5 + status: live + statement: A refund is recorded against its order. + counterexample: A refund with no order. + implementedBy: [Refund] diff --git a/fixtures/requirement-check-conformance/level-nesting/expected.json b/fixtures/requirement-check-conformance/level-nesting/expected.json new file mode 100644 index 000000000..5f7a1be3e --- /dev/null +++ b/fixtures/requirement-check-conformance/level-nesting/expected.json @@ -0,0 +1,28 @@ +{ + "diagnostics": [ + { + "severity": "error", + "code": "ERR_REQUIREMENT_LEVEL_NESTING", + "path": "Recorded.Duplicated", + "message": "nested under \"Recorded\" (level 4) but declares level 4. Nesting is the hierarchy — a child sits strictly below its parent." + }, + { + "severity": "error", + "code": "ERR_REQUIREMENT_LEVEL_NESTING", + "path": "Recorded.Quotable.Inverted", + "message": "nested under \"Quotable\" (level 5) but declares level 4. Nesting is the hierarchy — a child sits strictly below its parent." + } + ], + "summary": { + "total": 4, + "functional": 4, + "architectural": 0, + "byStatus": { + "live": 4 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/level-nesting/input/meta.shop.yaml b/fixtures/requirement-check-conformance/level-nesting/input/meta.shop.yaml new file mode 100644 index 000000000..50d2dc1d6 --- /dev/null +++ b/fixtures/requirement-check-conformance/level-nesting/input/meta.shop.yaml @@ -0,0 +1,42 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + children: + # The same level as its parent. + - requirement.functional: + name: Duplicated + level: 4 + status: live + statement: An order is recorded once. + counterexample: Two rows for one order. + implementedBy: [Order] + - requirement.functional: + name: Quotable + level: 5 + status: live + statement: An order carries a reference the customer can quote. + counterexample: An order nobody can quote back. + implementedBy: [Order.reference] + children: + # A level above its parent. + - requirement.functional: + name: Inverted + level: 4 + status: live + statement: An order can be found again by its reference. + counterexample: A reference that finds no order. + implementedBy: [Order] diff --git a/fixtures/requirement-check-conformance/link-above-floor/expected.json b/fixtures/requirement-check-conformance/link-above-floor/expected.json new file mode 100644 index 000000000..34c301a99 --- /dev/null +++ b/fixtures/requirement-check-conformance/link-above-floor/expected.json @@ -0,0 +1,28 @@ +{ + "diagnostics": [ + { + "severity": "error", + "code": "ERR_REQUIREMENT_LINK_ABOVE_FLOOR", + "path": "Shop", + "message": "'implementedBy' is legal at L4 (object) and L5 (member) only. L1-L3 are organisational and never reference the model — move the links to a nested L4 child." + }, + { + "severity": "error", + "code": "ERR_REQUIREMENT_LINK_ABOVE_FLOOR", + "path": "Shop.Orders", + "message": "'implementedBy' is legal at L4 (object) and L5 (member) only. L1-L3 are organisational and never reference the model — move the links to a nested L4 child." + } + ], + "summary": { + "total": 2, + "functional": 2, + "architectural": 0, + "byStatus": { + "live": 2 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/link-above-floor/input/meta.shop.yaml b/fixtures/requirement-check-conformance/link-above-floor/input/meta.shop.yaml new file mode 100644 index 000000000..eebbc7720 --- /dev/null +++ b/fixtures/requirement-check-conformance/link-above-floor/input/meta.shop.yaml @@ -0,0 +1,27 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + # The disposition on a live node would be a warning of its own; the link error stops the node's checks first. + - requirement.functional: + name: Shop + level: 1 + status: live + disposition: accepted + statement: A customer can buy from the shop. + counterexample: A customer who cannot place an order. + implementedBy: [Order] + children: + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + implementedBy: [Order] diff --git a/fixtures/requirement-check-conformance/no-requirements/expected.json b/fixtures/requirement-check-conformance/no-requirements/expected.json new file mode 100644 index 000000000..7aba952d1 --- /dev/null +++ b/fixtures/requirement-check-conformance/no-requirements/expected.json @@ -0,0 +1,4 @@ +{ + "diagnostics": [], + "summary": null +} diff --git a/fixtures/requirement-check-conformance/no-requirements/input/meta.shop.yaml b/fixtures/requirement-check-conformance/no-requirements/input/meta.shop.yaml new file mode 100644 index 000000000..61c027d31 --- /dev/null +++ b/fixtures/requirement-check-conformance/no-requirements/input/meta.shop.yaml @@ -0,0 +1,15 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - object.entity: + name: Customer + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } diff --git a/fixtures/requirement-check-conformance/nothing-implements-delegating-parent-clean/expected.json b/fixtures/requirement-check-conformance/nothing-implements-delegating-parent-clean/expected.json new file mode 100644 index 000000000..fd6f689cf --- /dev/null +++ b/fixtures/requirement-check-conformance/nothing-implements-delegating-parent-clean/expected.json @@ -0,0 +1,16 @@ +{ + "diagnostics": [], + "summary": { + "total": 5, + "functional": 5, + "architectural": 0, + "byStatus": { + "planned": 1, + "live": 4 + }, + "undecided": 1, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/nothing-implements-delegating-parent-clean/input/meta.shop.yaml b/fixtures/requirement-check-conformance/nothing-implements-delegating-parent-clean/input/meta.shop.yaml new file mode 100644 index 000000000..c9bd43e05 --- /dev/null +++ b/fixtures/requirement-check-conformance/nothing-implements-delegating-parent-clean/input/meta.shop.yaml @@ -0,0 +1,46 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Shop + level: 1 + status: live + statement: A customer can buy from the shop. + counterexample: A customer who cannot place an order. + children: + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + - requirement.functional: + name: Refunds + level: 3 + status: live + statement: Every refund is kept. + counterexample: A refund that is lost. + children: + # Planned, and Refund does not exist yet. It still names a node, so Refunds delegates. + - requirement.functional: + name: Refunded + level: 4 + status: planned + statement: A refund is recorded against its order. + counterexample: A refund with no order. + implementedBy: [Refund] diff --git a/fixtures/requirement-check-conformance/nothing-implements-subtree/expected.json b/fixtures/requirement-check-conformance/nothing-implements-subtree/expected.json new file mode 100644 index 000000000..2efce487a --- /dev/null +++ b/fixtures/requirement-check-conformance/nothing-implements-subtree/expected.json @@ -0,0 +1,35 @@ +{ + "diagnostics": [ + { + "severity": "warn", + "code": "WARN_REQUIREMENT_NOTHING_IMPLEMENTS", + "path": "Returns", + "message": "is 'live' but neither it nor anything nested under it names an implementing node. A functional requirement's check is existence — a subtree that claims nothing is a capability nobody built." + }, + { + "severity": "warn", + "code": "WARN_REQUIREMENT_NOTHING_IMPLEMENTS", + "path": "Returns.Refunds", + "message": "is 'partial' but neither it nor anything nested under it names an implementing node. A functional requirement's check is existence — a subtree that claims nothing is a capability nobody built." + }, + { + "severity": "warn", + "code": "WARN_REQUIREMENT_NOTHING_IMPLEMENTS", + "path": "Returns.Refunds.Refunded", + "message": "is 'live' but neither it nor anything nested under it names an implementing node. A functional requirement's check is existence — a subtree that claims nothing is a capability nobody built." + } + ], + "summary": { + "total": 4, + "functional": 4, + "architectural": 0, + "byStatus": { + "live": 3, + "partial": 1 + }, + "undecided": 1, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/nothing-implements-subtree/input/meta.shop.yaml b/fixtures/requirement-check-conformance/nothing-implements-subtree/input/meta.shop.yaml new file mode 100644 index 000000000..504b85c85 --- /dev/null +++ b/fixtures/requirement-check-conformance/nothing-implements-subtree/input/meta.shop.yaml @@ -0,0 +1,39 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + # Nothing in this subtree names an implementing node. + - requirement.functional: + name: Returns + level: 1 + status: live + statement: A customer can send an order back. + counterexample: An order that cannot be returned. + children: + - requirement.functional: + name: Refunds + level: 3 + status: partial + statement: Every refund is kept. + counterexample: A refund that is lost. + children: + - requirement.functional: + name: Refunded + level: 4 + status: live + statement: A refund is recorded against its order. + counterexample: A refund with no order. diff --git a/fixtures/requirement-check-conformance/require-implementers/expected.json b/fixtures/requirement-check-conformance/require-implementers/expected.json new file mode 100644 index 000000000..2194ac2cc --- /dev/null +++ b/fixtures/requirement-check-conformance/require-implementers/expected.json @@ -0,0 +1,35 @@ +{ + "diagnostics": [ + { + "severity": "error", + "code": "WARN_REQUIREMENT_NOTHING_IMPLEMENTS", + "path": "Returns", + "message": "is 'live' but neither it nor anything nested under it names an implementing node. A functional requirement's check is existence — a subtree that claims nothing is a capability nobody built." + }, + { + "severity": "error", + "code": "WARN_REQUIREMENT_NOTHING_IMPLEMENTS", + "path": "Returns.Refunds", + "message": "is 'partial' but neither it nor anything nested under it names an implementing node. A functional requirement's check is existence — a subtree that claims nothing is a capability nobody built." + }, + { + "severity": "error", + "code": "WARN_REQUIREMENT_NOTHING_IMPLEMENTS", + "path": "Returns.Refunds.Refunded", + "message": "is 'live' but neither it nor anything nested under it names an implementing node. A functional requirement's check is existence — a subtree that claims nothing is a capability nobody built." + } + ], + "summary": { + "total": 4, + "functional": 4, + "architectural": 0, + "byStatus": { + "live": 3, + "partial": 1 + }, + "undecided": 1, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/require-implementers/input/meta.shop.yaml b/fixtures/requirement-check-conformance/require-implementers/input/meta.shop.yaml new file mode 100644 index 000000000..504b85c85 --- /dev/null +++ b/fixtures/requirement-check-conformance/require-implementers/input/meta.shop.yaml @@ -0,0 +1,39 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + # Nothing in this subtree names an implementing node. + - requirement.functional: + name: Returns + level: 1 + status: live + statement: A customer can send an order back. + counterexample: An order that cannot be returned. + children: + - requirement.functional: + name: Refunds + level: 3 + status: partial + statement: Every refund is kept. + counterexample: A refund that is lost. + children: + - requirement.functional: + name: Refunded + level: 4 + status: live + statement: A refund is recorded against its order. + counterexample: A refund with no order. diff --git a/fixtures/requirement-check-conformance/require-implementers/options.json b/fixtures/requirement-check-conformance/require-implementers/options.json new file mode 100644 index 000000000..92afc2d38 --- /dev/null +++ b/fixtures/requirement-check-conformance/require-implementers/options.json @@ -0,0 +1,3 @@ +{ + "requireImplementers": true +} diff --git a/fixtures/requirement-check-conformance/retired-clean/expected.json b/fixtures/requirement-check-conformance/retired-clean/expected.json new file mode 100644 index 000000000..2538bc46e --- /dev/null +++ b/fixtures/requirement-check-conformance/retired-clean/expected.json @@ -0,0 +1,16 @@ +{ + "diagnostics": [], + "summary": { + "total": 3, + "functional": 3, + "architectural": 0, + "byStatus": { + "live": 1, + "retired": 2 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/retired-clean/input/meta.shop.yaml b/fixtures/requirement-check-conformance/retired-clean/input/meta.shop.yaml new file mode 100644 index 000000000..10e661648 --- /dev/null +++ b/fixtures/requirement-check-conformance/retired-clean/input/meta.shop.yaml @@ -0,0 +1,31 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + - requirement.functional: + name: Fax + level: 1 + status: retired + statement: A customer can order by fax. + counterexample: A faxed order that is refused. + children: + - requirement.functional: + name: Faxed + level: 4 + status: retired + statement: An order confirmation is sent by fax. + counterexample: A confirmed order with no fax. diff --git a/fixtures/requirement-check-conformance/same-name-in-two-branches/expected.json b/fixtures/requirement-check-conformance/same-name-in-two-branches/expected.json new file mode 100644 index 000000000..e5ef0d3cf --- /dev/null +++ b/fixtures/requirement-check-conformance/same-name-in-two-branches/expected.json @@ -0,0 +1,28 @@ +{ + "diagnostics": [ + { + "severity": "error", + "code": "ERR_REQUIREMENT_DANGLING_REF", + "path": "Shop.Orders.Recorded", + "message": "'Ordr' does not resolve in the loaded model (status 'live' — the model moved and the requirement is stale)." + }, + { + "severity": "error", + "code": "ERR_REQUIREMENT_DANGLING_REF", + "path": "Shop.Returns.Recorded", + "message": "'Ordr' does not resolve in the loaded model (status 'live' — the model moved and the requirement is stale)." + } + ], + "summary": { + "total": 5, + "functional": 5, + "architectural": 0, + "byStatus": { + "live": 5 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/same-name-in-two-branches/input/meta.shop.yaml b/fixtures/requirement-check-conformance/same-name-in-two-branches/input/meta.shop.yaml new file mode 100644 index 000000000..d25e8f8d6 --- /dev/null +++ b/fixtures/requirement-check-conformance/same-name-in-two-branches/input/meta.shop.yaml @@ -0,0 +1,46 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Shop + level: 1 + status: live + statement: A customer can buy from the shop. + counterexample: A customer who cannot place an order. + children: + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + # No object is named Ordr. + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order, Ordr] + - requirement.functional: + name: Returns + level: 3 + status: live + statement: A customer can send an order back. + counterexample: An order that cannot be returned. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: A return is recorded when it is received. + counterexample: A received return has no row. + implementedBy: [Order, Ordr] diff --git a/fixtures/requirement-check-conformance/summary-undecided-rollup/expected.json b/fixtures/requirement-check-conformance/summary-undecided-rollup/expected.json new file mode 100644 index 000000000..65605f9b2 --- /dev/null +++ b/fixtures/requirement-check-conformance/summary-undecided-rollup/expected.json @@ -0,0 +1,16 @@ +{ + "diagnostics": [], + "summary": { + "total": 9, + "functional": 9, + "architectural": 0, + "byStatus": { + "live": 1, + "partial": 8 + }, + "undecided": 3, + "deferredUntracked": 0, + "entitiesClaimed": 2, + "entitiesTotal": 2 + } +} diff --git a/fixtures/requirement-check-conformance/summary-undecided-rollup/input/meta.shop.yaml b/fixtures/requirement-check-conformance/summary-undecided-rollup/input/meta.shop.yaml new file mode 100644 index 000000000..e7c39ce0e --- /dev/null +++ b/fixtures/requirement-check-conformance/summary-undecided-rollup/input/meta.shop.yaml @@ -0,0 +1,87 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - object.entity: + name: Customer + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + # Counted once, at the child: a parent is partial because its child is. + - requirement.functional: + name: Shop + level: 1 + status: partial + statement: A customer can buy from the shop. + counterexample: A customer who cannot place an order. + children: + - requirement.functional: + name: Recorded + level: 4 + status: partial + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + # Counted once, at the grandchild: both ancestors are roll-ups. + - requirement.functional: + name: Returns + level: 1 + status: partial + statement: A customer can send an order back. + counterexample: An order that cannot be returned. + children: + - requirement.functional: + name: Refunds + level: 3 + status: partial + statement: Every refund is kept. + counterexample: A refund that is lost. + children: + - requirement.functional: + name: Refunded + level: 4 + status: partial + statement: A refund is recorded against its order. + counterexample: A refund with no order. + implementedBy: [Order] + + # Not counted: the child is ruled on, and it still makes its parent a roll-up. + - requirement.functional: + name: Loyalty + level: 1 + status: partial + statement: A returning customer is recognised. + counterexample: A returning customer treated as new. + children: + - requirement.functional: + name: Enrolled + level: 4 + status: partial + disposition: accepted + statement: A customer is recorded once. + counterexample: Two rows for one customer. + implementedBy: [Customer] + + # Counted at the parent: nothing beneath it has outstanding work. + - requirement.functional: + name: Delivery + level: 1 + status: partial + statement: An order reaches the customer. + counterexample: An order that never arrives. + children: + - requirement.functional: + name: Dispatched + level: 4 + status: live + statement: An order is recorded when it leaves. + counterexample: A dispatched order has no row. + implementedBy: [Order] diff --git a/fixtures/requirement-check-conformance/superseded-by-dangling/expected.json b/fixtures/requirement-check-conformance/superseded-by-dangling/expected.json new file mode 100644 index 000000000..f10bdb6a5 --- /dev/null +++ b/fixtures/requirement-check-conformance/superseded-by-dangling/expected.json @@ -0,0 +1,35 @@ +{ + "diagnostics": [ + { + "severity": "error", + "code": "ERR_REQUIREMENT_DANGLING_REF", + "path": "Shop.Orders.Faxed", + "message": "@supersededBy 'Shop.Orders.Recordd' does not name a requirement in the loaded ledger. It must name the requirement that REPLACED this one — if nothing did, drop the attribute and let `notes` carry why the capability went." + }, + { + "severity": "error", + "code": "ERR_REQUIREMENT_DANGLING_REF", + "path": "Shop.Orders.Telexed", + "message": "@supersededBy 'Recorded' does not name a requirement in the loaded ledger. It must name the requirement that REPLACED this one — if nothing did, drop the attribute and let `notes` carry why the capability went." + }, + { + "severity": "error", + "code": "ERR_REQUIREMENT_DANGLING_REF", + "path": "Shop.Orders.Posted", + "message": "@supersededBy 'Order' does not name a requirement in the loaded ledger. It must name the requirement that REPLACED this one — if nothing did, drop the attribute and let `notes` carry why the capability went." + } + ], + "summary": { + "total": 6, + "functional": 6, + "architectural": 0, + "byStatus": { + "live": 3, + "retired": 3 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/superseded-by-dangling/input/meta.shop.yaml b/fixtures/requirement-check-conformance/superseded-by-dangling/input/meta.shop.yaml new file mode 100644 index 000000000..303eab76d --- /dev/null +++ b/fixtures/requirement-check-conformance/superseded-by-dangling/input/meta.shop.yaml @@ -0,0 +1,55 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Shop + level: 1 + status: live + statement: A customer can buy from the shop. + counterexample: A customer who cannot place an order. + children: + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + # A misspelt path. + - requirement.functional: + name: Faxed + level: 4 + status: retired + supersededBy: Shop.Orders.Recordd + statement: An order confirmation is sent by fax. + counterexample: A confirmed order with no fax. + # A name is not an address: the requirement is Shop.Orders.Recorded. + - requirement.functional: + name: Telexed + level: 4 + status: retired + supersededBy: Recorded + statement: An order confirmation is sent by telex. + counterexample: A confirmed order with no telex. + # An object, not a requirement. + - requirement.functional: + name: Posted + level: 4 + status: retired + supersededBy: Order + statement: An order confirmation is sent by post. + counterexample: A confirmed order with no letter. diff --git a/fixtures/requirement-check-conformance/superseded-by-package-local/expected.json b/fixtures/requirement-check-conformance/superseded-by-package-local/expected.json new file mode 100644 index 000000000..add0f066b --- /dev/null +++ b/fixtures/requirement-check-conformance/superseded-by-package-local/expected.json @@ -0,0 +1,16 @@ +{ + "diagnostics": [], + "summary": { + "total": 8, + "functional": 8, + "architectural": 0, + "byStatus": { + "live": 4, + "retired": 4 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 2, + "entitiesTotal": 2 + } +} diff --git a/fixtures/requirement-check-conformance/superseded-by-package-local/input/meta.acme.yaml b/fixtures/requirement-check-conformance/superseded-by-package-local/input/meta.acme.yaml new file mode 100644 index 000000000..8ccf2d72f --- /dev/null +++ b/fixtures/requirement-check-conformance/superseded-by-package-local/input/meta.acme.yaml @@ -0,0 +1,11 @@ +metadata: + package: acme + children: + # Completed with this file's package: acme::shop::Orders.Recorded. + - requirement.functional: + name: Wired + level: 4 + status: retired + supersededBy: shop::Orders.Recorded + statement: An order confirmation is sent by wire. + counterexample: A confirmed order with no wire. diff --git a/fixtures/requirement-check-conformance/superseded-by-package-local/input/meta.archive.yaml b/fixtures/requirement-check-conformance/superseded-by-package-local/input/meta.archive.yaml new file mode 100644 index 000000000..9ec7326e9 --- /dev/null +++ b/fixtures/requirement-check-conformance/superseded-by-package-local/input/meta.archive.yaml @@ -0,0 +1,11 @@ +metadata: + package: acme::archive + children: + # No requirement in acme::archive has this path; a bare path is looked up across the whole ledger. + - requirement.functional: + name: Telexed + level: 4 + status: retired + supersededBy: Orders.Recorded + statement: An order confirmation is sent by telex. + counterexample: A confirmed order with no telex. diff --git a/fixtures/requirement-check-conformance/superseded-by-package-local/input/meta.billing.yaml b/fixtures/requirement-check-conformance/superseded-by-package-local/input/meta.billing.yaml new file mode 100644 index 000000000..eb1a13614 --- /dev/null +++ b/fixtures/requirement-check-conformance/superseded-by-package-local/input/meta.billing.yaml @@ -0,0 +1,30 @@ +metadata: + package: acme::billing + children: + - object.entity: + name: Invoice + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: A billing order is recorded when it is raised. + counterexample: A raised billing order has no row. + implementedBy: [Invoice] + - requirement.functional: + name: Posted + level: 4 + status: retired + supersededBy: Orders.Recorded + statement: An order confirmation is sent by post. + counterexample: A confirmed order with no letter. diff --git a/fixtures/requirement-check-conformance/superseded-by-package-local/input/meta.shop.yaml b/fixtures/requirement-check-conformance/superseded-by-package-local/input/meta.shop.yaml new file mode 100644 index 000000000..206152544 --- /dev/null +++ b/fixtures/requirement-check-conformance/superseded-by-package-local/input/meta.shop.yaml @@ -0,0 +1,32 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + # Orders.Recorded is a path in acme::shop and in acme::billing. It still binds. + - requirement.functional: + name: Faxed + level: 4 + status: retired + supersededBy: Orders.Recorded + statement: An order confirmation is sent by fax. + counterexample: A confirmed order with no fax. diff --git a/fixtures/requirement-check-conformance/superseded-by-resolves-clean/expected.json b/fixtures/requirement-check-conformance/superseded-by-resolves-clean/expected.json new file mode 100644 index 000000000..70b72a9f1 --- /dev/null +++ b/fixtures/requirement-check-conformance/superseded-by-resolves-clean/expected.json @@ -0,0 +1,16 @@ +{ + "diagnostics": [], + "summary": { + "total": 5, + "functional": 5, + "architectural": 0, + "byStatus": { + "live": 3, + "retired": 2 + }, + "undecided": 0, + "deferredUntracked": 0, + "entitiesClaimed": 1, + "entitiesTotal": 1 + } +} diff --git a/fixtures/requirement-check-conformance/superseded-by-resolves-clean/input/meta.shop.yaml b/fixtures/requirement-check-conformance/superseded-by-resolves-clean/input/meta.shop.yaml new file mode 100644 index 000000000..ff79a6c0c --- /dev/null +++ b/fixtures/requirement-check-conformance/superseded-by-resolves-clean/input/meta.shop.yaml @@ -0,0 +1,47 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Shop + level: 1 + status: live + statement: A customer can buy from the shop. + counterexample: A customer who cannot place an order. + children: + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + # The path alone. + - requirement.functional: + name: Faxed + level: 4 + status: retired + supersededBy: Shop.Orders.Recorded + statement: An order confirmation is sent by fax. + counterexample: A confirmed order with no fax. + # The path qualified by its package. + - requirement.functional: + name: Telexed + level: 4 + status: retired + supersededBy: acme::shop::Shop.Orders.Recorded + statement: An order confirmation is sent by telex. + counterexample: A confirmed order with no telex. diff --git a/scripts/write-requirement-corpus-expected.ts b/scripts/write-requirement-corpus-expected.ts new file mode 100644 index 000000000..29e827a11 --- /dev/null +++ b/scripts/write-requirement-corpus-expected.ts @@ -0,0 +1,180 @@ +#!/usr/bin/env bun +/** + * Write the `expected.json` of a requirement conformance corpus from the TypeScript + * reference. + * + * bun scripts/write-requirement-corpus-expected.ts check + * + * ── What this is, and what it is not ─────────────────────────────────────────── + * + * ADR-0057 puts the requirement gate in every port, and the corpus is what holds the + * other four to this one. The expectations come from the reference rather than being + * typed by hand, so the message text is the text the reference really prints. + * + * That makes the output a SNAPSHOT, and a snapshot is not a contract. It becomes one + * only when every written file is read against the tables the ports copy + * (the corpus README says which). So: run this, then review the diff case by case. A + * case that reports more than it is meant to pin gets its INPUT fixed. A disagreement + * between the tables and the reference is a finding about the reference — it is never + * settled by editing an expectation, and never by re-running this until it looks right. + * + * A case that does not load strict is refused: nothing is written for it and the run + * exits non-zero. Each port's runner asserts a clean load first, so an expectation + * computed over a model that did not load would pin nothing. + */ +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +// RELATIVE, as in `generate-requirement-harness.ts`: `scripts/` sits outside the bun +// workspace, so a bare `@metaobjectsdev/*` specifier does not resolve from here. +import { MetaDataLoader, type MetaData } from "../server/typescript/packages/metadata/src/index.js"; +import { REQUIREMENT_STATUSES } from "../server/typescript/packages/metadata/src/core/requirement/requirement-constants.js"; +import { + checkRequirements, + scanRequirements, + summariseRequirements, + type Diagnostic, + type RequirementSummary, +} from "../server/typescript/packages/cli/src/lib/requirement-check.js"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +/** One conformance corpus this script can write. Adding a corpus is one more entry in + * {@link CORPORA}: its directory, the option keys its cases may set, and how one loaded + * case becomes the object its `expected.json` holds. */ +interface Corpus { + /** Repository-relative directory holding one sub-directory per case. */ + readonly dir: string; + /** Every key a case's `options.json` may carry. Anything else is refused. */ + readonly optionKeys: readonly string[]; + expected(root: MetaData, options: Readonly>): unknown; +} + +// --------------------------------------------------------------------------- +// check — fixtures/requirement-check-conformance +// --------------------------------------------------------------------------- + +/** Key order is fixed so a rewrite produces no diff: `path` is omitted, not nulled, on a + * diagnostic that has none (object coverage names its entity in the message). */ +function diagnosticRecord(d: Diagnostic): Record { + return { + severity: d.severity, + code: d.code, + ...(d.path === undefined ? {} : { path: d.path }), + message: d.message, + }; +} + +/** Statuses in their registered order, zero counts left out; the coverage pair only when + * the run measured coverage, which is what its absence means to a port. */ +function summaryRecord(s: RequirementSummary): Record { + const byStatus: Record = {}; + for (const status of REQUIREMENT_STATUSES) { + const count = s.byStatus[status]; + if (count !== undefined) byStatus[status] = count; + } + return { + total: s.total, + functional: s.functional, + architectural: s.architectural, + byStatus, + undecided: s.undecided, + deferredUntracked: s.deferredUntracked, + ...(s.entitiesClaimed === undefined ? {} : { entitiesClaimed: s.entitiesClaimed }), + ...(s.entitiesTotal === undefined ? {} : { entitiesTotal: s.entitiesTotal }), + }; +} + +function checkExpected(root: MetaData, options: Readonly>): unknown { + const scan = scanRequirements(root, { requireImplementers: options["requireImplementers"] === true }); + const summary = summariseRequirements(root, scan); + return { + // Emission order — the order the reference evaluates its checks in — so the file + // reads top to bottom against its input. Runners compare it as a multiset. + diagnostics: checkRequirements(root, scan).map(diagnosticRecord), + summary: summary === undefined ? null : summaryRecord(summary), + }; +} + +const CORPORA: Readonly> = { + check: { + dir: "fixtures/requirement-check-conformance", + optionKeys: ["libraries", "requireImplementers"], + expected: checkExpected, + }, +}; + +// --------------------------------------------------------------------------- +// The part every corpus shares: find the cases, load each one, write the file. +// --------------------------------------------------------------------------- + +function readOptions(caseDir: string, corpus: Corpus): Record { + const file = join(caseDir, "options.json"); + if (!existsSync(file)) return {}; + const options = JSON.parse(readFileSync(file, "utf8")) as Record; + const unknown = Object.keys(options).filter((k) => !corpus.optionKeys.includes(k)); + if (unknown.length > 0) throw new Error(`unknown option(s) ${unknown.join(", ")} in options.json`); + return options; +} + +function librariesOf(options: Readonly>): string[] { + const libraries = options["libraries"]; + if (libraries === undefined) return []; + if (!Array.isArray(libraries) || !libraries.every((l): l is string => typeof l === "string")) { + throw new Error("'libraries' in options.json must be an array of strings"); + } + return libraries; +} + +async function main(): Promise { + const name = process.argv[2]; + const corpus = name === undefined ? undefined : CORPORA[name]; + if (corpus === undefined) { + console.error( + `usage: bun scripts/write-requirement-corpus-expected.ts \n` + + ` corpora: ${Object.keys(CORPORA).join(", ")}\n`, + ); + return 2; + } + + const corpusDir = join(REPO_ROOT, corpus.dir); + const cases = readdirSync(corpusDir).filter((n) => statSync(join(corpusDir, n)).isDirectory()).sort(); + let written = 0; + let unchanged = 0; + let refused = 0; + + for (const caseName of cases) { + const caseDir = join(corpusDir, caseName); + let content: string; + try { + const options = readOptions(caseDir, corpus); + const libraries = librariesOf(options); + const { root, errors } = await MetaDataLoader.fromDirectory(join(caseDir, "input"), { + strict: true, + ...(libraries.length > 0 ? { libraries } : {}), + }); + if (errors.length > 0) throw new Error(`does not load strict:\n${errors.map((e) => ` ${String(e)}`).join("\n")}`); + content = JSON.stringify(corpus.expected(root, options), null, 2) + "\n"; + } catch (err) { + console.error(` REFUSED ${caseName}: ${err instanceof Error ? err.message : String(err)}`); + refused++; + continue; + } + const target = join(caseDir, "expected.json"); + if (existsSync(target) && readFileSync(target, "utf8") === content) { + unchanged++; + continue; + } + writeFileSync(target, content); + console.log(` wrote ${caseName}/expected.json`); + written++; + } + + console.log( + `${corpus.dir}: ${cases.length} case(s) — ${written} written, ${unchanged} unchanged, ${refused} refused.` + + (written > 0 ? `\nReview every written file by hand before committing it.` : ``), + ); + return refused > 0 ? 1 : 0; +} + +process.exit(await main()); diff --git a/server/typescript/packages/cli/test/requirement-check-conformance.test.ts b/server/typescript/packages/cli/test/requirement-check-conformance.test.ts new file mode 100644 index 000000000..14f4a667e --- /dev/null +++ b/server/typescript/packages/cli/test/requirement-check-conformance.test.ts @@ -0,0 +1,98 @@ +// Cross-port requirement-check conformance corpus — fixtures/requirement-check-conformance/. +// +// The requirement gate is core (`meta verify` is a drift gate), and ADR-0057 puts it in +// every port. This corpus is what holds the other four to this one: the same inputs, the +// same diagnostics down to the message text, the same summary counts. +// +// Every case is LOADED strict first, so "this model loads today" is proven by the fixture +// rather than asserted in prose, and only then checked. `expected.json` is written from +// this implementation by `scripts/write-requirement-corpus-expected.ts` and reviewed by +// hand; this runner is what stops it drifting afterwards. +import { describe, test, expect } from "bun:test"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { MetaDataLoader } from "@metaobjectsdev/metadata"; +import { + checkRequirements, + scanRequirements, + summariseRequirements, + type RequirementSummary, +} from "../src/lib/requirement-check.js"; + +const CORPUS_DIR = join(import.meta.dir, "../../../../../fixtures/requirement-check-conformance"); + +/** `path` is absent on a diagnostic whose subject is not a requirement (object coverage). */ +interface Finding { severity: string; code: string; path?: string; message: string } +interface Expected { diagnostics: Finding[]; summary: RequirementSummary | null } + +/** The whole of `options.json`. A key outside this list is refused rather than ignored: a + * misspelt `requireImplementers` would otherwise run the case without the strict switch + * and pin the wrong severity in every port. */ +const OPTION_KEYS = ["libraries", "requireImplementers"] as const; +interface Options { libraries?: string[]; requireImplementers?: boolean } + +type Row = readonly [severity: string, code: string, path: string, message: string]; + +const row = (d: Finding): Row => [d.severity, d.code, d.path ?? "", d.message]; +const order = (a: Row, b: Row): number => { + for (let i = 0; i < a.length; i++) { + if (a[i]! < b[i]!) return -1; + if (a[i]! > b[i]!) return 1; + } + return 0; +}; +/** The comparison the corpus README names: a sorted multiset of (severity, code, path, message). */ +const multiset = (ds: readonly Finding[]): Row[] => ds.map(row).sort(order); + +function readOptions(caseDir: string): Options { + const file = join(caseDir, "options.json"); + if (!existsSync(file)) return {}; + const options = JSON.parse(readFileSync(file, "utf8")) as Record; + const unknown = Object.keys(options).filter((k) => !(OPTION_KEYS as readonly string[]).includes(k)); + if (unknown.length > 0) throw new Error(`${file}: unknown option(s) ${unknown.join(", ")}`); + return options as Options; +} + +/** The case names the README's "Cases" table documents, in table order. */ +function documentedCases(): string[] { + const readme = readFileSync(join(CORPUS_DIR, "README.md"), "utf8"); + const section = readme.split(/^## /m).find((s) => s.startsWith("Cases\n")); + if (section === undefined) throw new Error("README.md has no '## Cases' section"); + return [...section.matchAll(/^\| `([^`]+)` \|/gm)].map((m) => m[1]!); +} + +const cases = readdirSync(CORPUS_DIR).filter((n) => statSync(join(CORPUS_DIR, n)).isDirectory()).sort(); + +describe("requirement-check conformance corpus", () => { + test("every case on disk is documented in the README, and nothing else is", () => { + expect(cases.length).toBeGreaterThan(0); + expect(documentedCases().sort()).toEqual(cases); + }); + + for (const name of cases) { + test(name, async () => { + const caseDir = join(CORPUS_DIR, name); + const expectedFile = join(caseDir, "expected.json"); + if (!existsSync(expectedFile)) { + throw new Error( + `${name}: no expected.json. Write it with ` + + `'bun scripts/write-requirement-corpus-expected.ts check', then review it by hand.`, + ); + } + const expected = JSON.parse(readFileSync(expectedFile, "utf8")) as Expected; + const options = readOptions(caseDir); + + const result = await MetaDataLoader.fromDirectory(join(caseDir, "input"), { + strict: true, + ...(options.libraries === undefined ? {} : { libraries: options.libraries }), + }); + expect(result.errors.map(String)).toEqual([]); + + const scan = scanRequirements(result.root, { + requireImplementers: options.requireImplementers ?? false, + }); + expect(multiset(checkRequirements(result.root, scan))).toEqual(multiset(expected.diagnostics)); + expect(summariseRequirements(result.root, scan) ?? null).toStrictEqual(expected.summary); + }); + } +}); From e8e66cea081b8999ffd3befed80bb4d25ba932e3 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 5 Oct 2026 23:54:17 -0400 Subject: [PATCH 07/43] =?UTF-8?q?docs(plan):=20requirements=20slice=201=20?= =?UTF-8?q?=E2=80=94=20what=20building=20the=20corpora=20found?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 43rd check case so the strict switch cannot pass as warnings-as-errors, file-name load order and the runner's coverage defaults as runner rules, an unknown grain refused in every port, and the single-document rule for the unpackaged identity case. Refs the plan and ADR-0057. --- ...ents-slice-1-checks-and-test-generators.md | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md b/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md index daa86c3cb..1a7808689 100644 --- a/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md +++ b/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md @@ -121,7 +121,7 @@ Source: `server/typescript/packages/cli/src/lib/requirement-check.ts`. Eleven co | 11 | `WARN_REQUIREMENT_DEFERRED_UNTRACKED` | warn | `disposition` is `deferred` and `trackedBy` is empty | `is deferred but names no @trackedBy issue. Deferring without a ticket is how a known gap becomes an unknown one — nothing will raise it again.` | | 12 | `WARN_REQUIREMENT_OBJECT_UNCLAIMED` | warn (the `OBJECT_COVERAGE_SEVERITY` constant; each port keeps a named constant) | coverage is measured (Table D) and a coverable entity's resolution key is not in the claim set. **No path.** | `no requirement claims ''. Add it to an L4 requirement's 'implementedBy'.` | -Reachability, from `spec/metamodel/requirement.json`: `level` is required on functional and is an integer with no range rule, so row 1 is reached with an out-of-range integer and never with an absent level on a functional node. `status` is required on both subtypes. The loader refuses `implementedBy` on `retired`. +Reachability, from `spec/metamodel/requirement.json`: `level` is required on functional and is an integer with no range rule, so row 1 is reached with an out-of-range integer and never with an absent level on a functional node. (Found while building the corpus: the TypeScript loader also lets a non-integer such as `4.5` through to row 1. The corpus does not pin that, because the other loaders are not known to agree.) `status` is required on both subtypes. The loader refuses `implementedBy` on `retired`. A printed line is ` []: `, or ` : ` when there is no path (`formatDiagnostic`, `cli/src/commands/verify.ts:1899`). @@ -303,7 +303,7 @@ def test_req_acme_shop_Orders_Refunded(): | Seam | TypeScript | Python | Java and Kotlin | C# | |---|---|---|---|---| -| Grain (`concern` default, or `member`) | `requirementTests({ grain })` | `requirement_tests(grain=…)`; config `requirementTests.grain` | generator arg `grain` | **UNVERIFIED** option surface; a property on the generator | +| Grain (`concern` default, or `member`; any other value is refused with a clear error, never run as a hybrid) | `requirementTests({ grain })` | `requirement_tests(grain=…)`; config `requirementTests.grain` | generator arg `grain` | **UNVERIFIED** option surface; a property on the generator | | Renderer hook. Receives the Table F record plus `statement`, `counterexample`, `targets` (`ref`, `concern`), `disposition`, `trackedBy`. Its result replaces the default text of that one test. | existing `renderers` and `resolveRenderer`; `RequirementTestArgs` gains `package`, `unit`, `id`, `witnessKey`, `skip`, `digest` | `requirement_tests(renderer=…)`; config `requirementTests.renderer` as `module:symbol`. Returns `RenderedTest(imports, source)` or `None` for the default | generator arg `renderer`: the class name of a `RequirementTestRenderer` | as Java, an `IRequirementTestRenderer` | | Strict switch: row 10 of Table C becomes severity `error`, code unchanged | `meta verify --require-implementers`, or `META_REQUIRE_IMPLEMENTERS=1` | `metaobjects verify --require-implementers`, same variable | `-Dmeta.verify.requireImplementers=true`, same variable | `dotnet meta verify --require-implementers`, same variable | | Witness location | not applicable | `requirement_tests(witness_module=…)`; config `requirementTests.witnessModule` | generator args `testPackage` and `witnessClass` | the same two, C# names | @@ -316,9 +316,9 @@ The flag is not called `--strict`: `cli/src/lib/args.ts:357` records why a `--st ### Table J — the two corpora -Both follow the field-lint corpus shape: each case is a directory with `input/` (one or more `.json` or `.yaml` metadata documents), an optional `options.json`, and `expected.json`. A runner (1) loads `input/` with the port's loader, **strict**, with the libraries `options.json` names, and asserts no load error; (2) computes; (3) compares. +Both follow the field-lint corpus shape: each case is a directory with `input/` (one or more `.json` or `.yaml` metadata documents), an optional `options.json`, and `expected.json`. A runner (1) loads the files of `input/` **in file-name order** with the port's loader, **strict**, with the libraries `options.json` names, and asserts no load error (the order is part of the contract: one did-you-mean hint and the file default package of a later document depend on it); (2) computes; (3) compares. -**`fixtures/requirement-check-conformance/`**. `options.json` keys: `libraries` (string array), `requireImplementers` (boolean). `expected.json`: +**`fixtures/requirement-check-conformance/`**. `options.json` keys: `libraries` (string array), `requireImplementers` (boolean). A runner passes no scope predicate and does not force the coverage answer: both are left to the port's defaults, or `coverage-library-plus-project` is reachable with the wrong total. `expected.json`: ```json { @@ -353,6 +353,7 @@ Both follow the field-lint corpus shape: each case is a directory with `input/` | `disposition-not-applicable`, `deferred-untracked` | Rows 9 and 11, with a tracked deferral beside the untracked one | | `nothing-implements-subtree`, `nothing-implements-delegating-parent-clean` | Row 10 on every live node of an empty subtree, and not on a parent whose child claims | | `require-implementers` | Same input as `nothing-implements-subtree` with the strict option: severity `error`, same code | +| `require-implementers-raises-only-that-code` | The strict option over a model that also has an untracked deferral and an unclaimed entity: row 10 is `error`, rows 11 and 12 stay `warn`. Added in review: without it a port that raised every warning would pass | | `superseded-by-resolves-clean`, `superseded-by-dangling`, `superseded-by-package-local` | Row 7 and Table B's ledger lookup | | `retired-clean` | A retired entry reports nothing and claims nothing | | `same-name-in-two-branches` | Two requirements with one name are told apart by path | @@ -400,7 +401,7 @@ Both follow the field-lint corpus shape: each case is a directory with `input/` | `planned-skip`, `retired-skip`, `partial-not-skipped` | `skip` per status; a planned requirement naming absent nodes has unit `*` | | `member-grain`, `member-grain-unresolved-dropped`, `member-grain-duplicate-ref` | One test per distinct resolving reference, bare and qualified forms kept as authored | | `package-in-address` | The same path in two packages gives two ids and two keys | -| `unpackaged` | An empty package gives a bare address | +| `unpackaged` | An empty package gives a bare address. One document only: a package-less document loaded after a packaged one takes that document's package as its file default | | `nested-path` | A five-deep path | | `digest-claim-fields` | Two requirements differing in statement differ in digest; two differing only in `title`, `notes`, `disposition` and `trackedBy` do not | | `digest-inherited` | A requirement inheriting its statement through `extends` hashes the effective text | @@ -446,7 +447,7 @@ The requirement checks are **not** ejectable in any port. They are the shared co ## File structure -**Shared, new:** `fixtures/requirement-check-conformance/` (a `README.md` and the 42 cases of Table J), `fixtures/requirement-test-identity-conformance/` (a `README.md` and 24 cases), `spec/decisions/ADR-0057-requirement-checks-and-tests-in-every-port.md`, `scripts/write-requirement-corpus-expected.ts`. +**Shared, new:** `fixtures/requirement-check-conformance/` (a `README.md` and the 43 cases of Table J), `fixtures/requirement-test-identity-conformance/` (a `README.md` and 24 cases), `spec/decisions/ADR-0057-requirement-checks-and-tests-in-every-port.md`, `scripts/write-requirement-corpus-expected.ts`. **Shared, modified:** `fixtures/generator-registry-conformance/registry.json` (the `requirement-tests` entry's `ports`, one port per generator task). @@ -520,7 +521,7 @@ Run: `cd server/typescript/packages/cli && bun test test/unit/requirement-check. ### Task 2: The requirement-check corpus and its TypeScript runner **Files:** -- Create: `fixtures/requirement-check-conformance/README.md` and the 42 case directories of Table J +- Create: `fixtures/requirement-check-conformance/README.md` and the 43 case directories of Table J - Create: `scripts/write-requirement-corpus-expected.ts` - Create: `server/typescript/packages/cli/test/requirement-check-conformance.test.ts` @@ -535,7 +536,7 @@ Run: `cd server/typescript/packages/cli && bun test test/unit/requirement-check. - [ ] **Step 5: Write the expectations script.** `scripts/write-requirement-corpus-expected.ts` takes a corpus name, loads each case the way the runner does and writes `expected.json`. It imports the reference by relative path, as `scripts/generate-requirement-harness.ts` does. Run `bun scripts/write-requirement-corpus-expected.ts check`. - [ ] **Step 6: Review every written file by hand against Table C, D and E.** This is the step that makes the corpus a contract instead of a snapshot. For each case confirm the codes are the ones the row "Pins" names and no others; fix the **input** when a case produces more than it should. A disagreement between Table C and the reference is a finding to report, not something to paper over in the expectation. - [ ] **Step 7: Write the README:** the two-line purpose, the format, the three runner steps, the case table, and a "Who asserts it" table (TypeScript now; the other rows are added by Tasks 5, 7 and 9). -- [ ] **Step 8: Run** the runner. Expected: PASS, 43 tests. +- [ ] **Step 8: Run** the runner. Expected: PASS, 44 tests. - [ ] **Step 9: Commit.** `git commit -m "test(conformance): requirement-check corpus, run by the TypeScript reference"` --- @@ -722,7 +723,7 @@ The code constants carry the TypeScript names (`ERR_REQUIREMENT_DANGLING_REF`, a Run: `cd server/python && uv run pytest tests/conformance/test_requirement_check_conformance.py -q` (use the repository's usual Python test invocation if it differs). Expected: FAIL, `requirement_check` not importable. - [ ] **Step 3: Implement the accessors and the resolver.** `superseded_by()` returns the stripped-non-blank string or `None`; `is_retired()` compares with `REQUIREMENT_STATUS_RETIRED`. Both read through `get_meta_attr()`. `library_packages()` parses `EMBEDDED_LIBRARY_MANIFESTS` and returns the frozen union of every manifest's `packages`. `resolve_claim_target` calls `naming_refs.resolve_object_ref` first, then applies Table B's second row. - [ ] **Step 4: Implement the checks,** row by row from Table C, in the order of Table C, with the message text copied from the TypeScript file. Row 3 ends the loop body for that requirement. `scan_requirements` computes the claim set once (Table D) and `measure_coverage` from `library_packages()` unless forced. -- [ ] **Step 5: Run the conformance runner.** Expected: PASS for all 42 cases. A failing case is a porting error; do not edit an `expected.json`. +- [ ] **Step 5: Run the conformance runner.** Expected: PASS for all 43 cases. A failing case is a porting error; do not edit an `expected.json`. - [ ] **Step 6: Failing CLI tests** in `test_cli_verify_requirements.py`: `a model with no requirements prints nothing and exits as before` (compare stderr and the exit code of `verify` on an existing no-requirement fixture before and after); `a dangling live reference exits 1 and prints the code, the path and the summary line`; `warnings alone exit 0`; `--require-implementers exits 1 on a nothing-implements warning` and so does `META_REQUIRE_IMPLEMENTERS=1`; `the gate runs with --templates alone` (no subverb selects it); `a metadata load failure prints no requirement line`. - [ ] **Step 7: Wire `verify`.** Add `--require-implementers` to the `verify` parser. Factor the load inside `_field_lint_findings` into one helper that returns the root, the project's own files and the collection, and use it for both the field lint and the new `_verify_requirements(args) -> int`, so `verify` loads once for the two. `_verify_requirements` returns 0 when the root did not load, prints the lines of Table E to stderr with the `metaobjects verify —` prefix, and returns 1 when any diagnostic has severity `error`. In `_cmd_verify`: `exit_code = max(exit_code, _verify_requirements(args))`, on every run. Pass `coverable=collection.in_scope` when a collection was resolved. - [ ] **Step 8: Run** `uv run pytest tests/conformance/test_requirement_check_conformance.py tests/codegen/test_cli_verify_requirements.py tests/codegen/test_cli_verify_field_lint.py -q`, then the port's lint and type check as `scripts/ci-local.sh` runs them (read the Python lane for the commands). Expected: PASS. @@ -870,7 +871,7 @@ public final class RequirementCheck { Run: `mvn -q -f server/java/pom.xml -pl metadata test -Dtest=RequirementCheckConformanceTest`. Expected: FAIL to compile. Use `MAVEN_ARGS` for a repository override, never `MAVEN_OPTS`. - [ ] **Step 3: Implement** the accessors, `libraryPackages()` (parse each `EmbeddedLibrary.MANIFESTS` value's `packages`, the way `parseLayers` reads `layers`), `RequirementClaims` (objects through one `SymbolTable` built per scan) and `RequirementCheck`, row by row from Table C with the message text copied from the TypeScript file. Expose one did-you-mean helper instead of writing a third copy. -- [ ] **Step 4: Run** the runner. Expected: PASS for all 42 cases. +- [ ] **Step 4: Run** the runner. Expected: PASS for all 43 cases. - [ ] **Step 5: Failing mojo tests** in `MetaDataVerifyRequirementsTest.java`, on the pattern of `MetaDataVerifyFieldLintTest.java`: no requirements logs nothing and does not fail; a dangling live reference throws `MojoFailureException` after logging the code, path and summary; warnings alone do not fail; `requireImplementers` (the parameter and the environment variable) fails on a nothing-implements warning; the gate runs once per `execute()` whichever of the template and codegen gates ran. - [ ] **Step 6: Wire the mojo.** A `@Parameter(property = "meta.verify.requireImplementers", defaultValue = "false")`, and `runRequirementGate(loader)` called once from `execute()`. It logs the summary with `getLog().info`, warnings with `getLog().warn`, errors with `getLog().error`, each prefixed `metaobjects:verify —`, and throws `MojoFailureException` when any error was found. Kotlin projects get the gate through this goal. - [ ] **Step 7: Run** `mvn -q -f server/java/pom.xml -pl metadata,maven-plugin -am test -Dtest='RequirementCheckConformanceTest,MetaDataVerifyRequirementsTest,MetaDataVerifyFieldLintTest,RequirementTest'`. Expected: PASS. @@ -944,7 +945,7 @@ Generator args: `testPackage` (required), `witnessClass` (required, a fully-qual - [ ] **Step 1: Read first.** The two TypeScript files; `NamingRefs.cs` (`ResolveObjectRef`, `EffectivePackage`, `DidYouMeanHint`); `MetaObjects.Cli/FieldLint.cs` and `Program.cs:460-650` (flag parsing, `--no-field-lint`, the `FieldLint.RunAdvisory` call and its output prefix); `MetaObjects.Cli.Tests/FieldLintConformanceTests.cs`. `MetaData` has `SuperData`, `Parent`, `ResolutionKey()` and `IsAbstract`. **UNVERIFIED:** whether `DidYouMeanHint`'s text matches TypeScript's; where `verify` computes its exit code so the gate can contribute to it. - [ ] **Step 2: Failing conformance runner** on the pattern of `FieldLintConformanceTests.cs`, in `MetaObjects.Conformance.Tests` (the checks are core, not CLI). Run: `dotnet test server/csharp --filter RequirementCheckConformance`. Expected: FAIL to compile. - [ ] **Step 3: Implement** the accessors, `LibraryPackages()` and the two classes, row by row from Table C. -- [ ] **Step 4: Run** the runner. Expected: PASS for all 42 cases. +- [ ] **Step 4: Run** the runner. Expected: PASS for all 43 cases. - [ ] **Step 5: Failing CLI tests** in `VerifyRequirementsTests.cs`, mirroring Task 5 Step 6: silence and an unchanged exit code with no requirements; exit 1 and the printed lines on a dangling live reference; exit 0 on warnings; `--require-implementers` and `META_REQUIRE_IMPLEMENTERS=1`. - [ ] **Step 6: Wire the CLI.** Parse `--require-implementers`, add it to the usage line, and run the gate on every `verify` beside the field lint, writing to `Console.Error` and folding a non-zero result into the command's exit code. - [ ] **Step 7: Run** `dotnet test server/csharp --filter "RequirementCheckConformance|VerifyRequirements|VerifyFieldLint|FieldLintConformance"`. Expected: PASS. From f332be0c40c3231cbc1b6b4ea6773fa725953ff9 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 5 Oct 2026 23:57:31 -0400 Subject: [PATCH 08/43] test(conformance): requirement-check corpus cases that a plausible wrong port would have passed Adds require-implementers-raises-only-that-code (the strict switch leaves the deferral and coverage warnings at warn), a levelled architectural L4 with no links, a string-prefix probe for the undecided roll-up and a cross-package subtype extending by qualified name. The runner now validates option types and asserts the require-implementers input equals its twin; the README says what the supersededBy and did-you-mean cases really pin and names two runner preconditions. Refs docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md and ADR-0057. --- .../requirement-check-conformance/README.md | 38 +++++++++++------ .../arch-live-no-implementers/expected.json | 12 ++++-- .../input/meta.shop.yaml | 8 ++++ .../input/meta.express.yaml | 10 +++++ .../input/meta.shop.yaml | 6 --- .../expected.json | 4 +- .../input/meta.express.yaml | 10 +++++ .../input/meta.shop.yaml | 6 --- .../expected.json | 34 +++++++++++++++ .../input/meta.shop.yaml | 41 +++++++++++++++++++ .../options.json | 3 ++ .../summary-undecided-rollup/expected.json | 8 ++-- .../input/meta.shop.yaml | 10 +++++ .../requirement-check-conformance.test.ts | 37 +++++++++++++++-- 14 files changed, 189 insertions(+), 38 deletions(-) create mode 100644 fixtures/requirement-check-conformance/coverage-arch-claim-propagates/input/meta.express.yaml create mode 100644 fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/input/meta.express.yaml create mode 100644 fixtures/requirement-check-conformance/require-implementers-raises-only-that-code/expected.json create mode 100644 fixtures/requirement-check-conformance/require-implementers-raises-only-that-code/input/meta.shop.yaml create mode 100644 fixtures/requirement-check-conformance/require-implementers-raises-only-that-code/options.json diff --git a/fixtures/requirement-check-conformance/README.md b/fixtures/requirement-check-conformance/README.md index 4f1b7804c..6ba4fb5f8 100644 --- a/fixtures/requirement-check-conformance/README.md +++ b/fixtures/requirement-check-conformance/README.md @@ -23,7 +23,11 @@ A runner does three things, in this order: 1. Load `input/` with the port's loader, **strict**, with the libraries `options.json` names, and assert **no load errors**. Load the files in ascending file-name order. 2. Run the gate over the loaded model, with the strict switch `options.json` sets - (default off), and compute the summary from the same run. + (default off), and compute the summary from the same run. Pass **no scope predicate**: + every non-abstract entity in the loaded model is coverable. Do **not force the coverage + answer**: whether coverage is measured is derived from the requirements' packages, which + is what the `coverage-library-*` cases test. (In the reference these are the + `coverable` and `measureCoverage` options of `scanRequirements`; a runner sets neither.) 3. Compare both with `expected.json`. ### What is compared @@ -66,12 +70,19 @@ it is wrong, unless the reference is shown to be wrong first. non-empty `implementedBy` is not reported by `WARN_REQUIREMENT_NOTHING_IMPLEMENTS`, even when the only one is on a `planned` child and names a node that does not exist yet (`nothing-implements-delegating-parent-clean`). -- **`supersededBy` resolves against the ledger, not the model, and a bare path is looked up - across every package.** The name of an object does not resolve - (`superseded-by-dangling`); a path that exists only in another package does - (`superseded-by-package-local`). -- **The did-you-mean hint lists objects in load order.** That is why step 1 fixes the file - order (`dangling-bare-name-in-two-packages`). +- **`supersededBy` resolves against the ledger, not the model, and a bare path that is + ambiguous across packages still binds.** The name of an object does not resolve + (`superseded-by-dangling`). A bare path is looked up across every package, and when two + packages both have it the reference takes the first in walk order instead of refusing + (`superseded-by-package-local`). This deliberately differs from `implementedBy`, where a + bare name that exists in two other packages binds nothing + (`dangling-bare-name-in-two-packages`). The case pins that the reference resolves; which + of the two requirements it binds is not observable in a diagnostic. +- **One message names two objects, in a fixed order.** The hint in + `dangling-bare-name-in-two-packages` reads `acme::billing::Order, acme::shop::Order`. + The fixture loads the billing file first and the two names also sort that way, so it + pins the order of this one message and not the rule behind it. The reference lists the + objects in load order, which is why step 1 fixes the file order. - **The library cases count the library's own ledger.** The three `coverage-library-*` summaries include the requirements the `iam` library ships, so a change to that ledger changes those three files. Every port embeds the same library, so the ports still agree. @@ -86,7 +97,7 @@ it is wrong, unless the reference is shown to be wrong first. | `dangling-object-partial` | The same on `partial`. The message carries the status. | | `dangling-object-planned-clean` | The same reference on `planned` reports nothing: a planned requirement may name nodes that do not exist yet. | | `dangling-object-did-you-mean` | The hint when the short name exists in one other package. | -| `dangling-bare-name-in-two-packages` | A bare name that exists in two other packages binds neither. The hint lists both, in load order. | +| `dangling-bare-name-in-two-packages` | A bare name that exists in two other packages binds neither. The hint lists both, `acme::billing::Order` first. | | `dangling-member-of-resolved-object` | The object resolves and its member does not: the hint names the object's resolution key and the missing member. | | `dangling-member-first-missing-segment` | Two-segment member paths. The hint names the first segment that did not resolve and the node it was looked for under: once when the first segment resolves, once when it does not. | | `claim-on-root-template` | A root-level `template.prompt` is a claim target, by bare name and by qualified name. Coverage is measured over zero entities, so both counts are `0`. | @@ -97,7 +108,7 @@ it is wrong, unless the reference is shown to be wrong first. | `l5-member-resolves-clean` | A functional L5 naming a field, an identity, and a view under a field: no diagnostics. | | `bad-level-out-of-range` | `ERR_REQUIREMENT_BAD_LEVEL` for levels 0 and 6 on functional requirements. The message has no architectural suffix. | | `level-nesting` | `ERR_REQUIREMENT_LEVEL_NESTING` for a child at its parent's level and for a child above it. | -| `arch-live-no-implementers` | `ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS` on a flat policy that is `live` and on one that is `partial`. | +| `arch-live-no-implementers` | `ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS` on a flat policy that is `live`, on one that is `partial`, and on a levelled L4 that is `live`: the rule is "may name the model and names nothing", not "has no level". | | `arch-planned-clean` | A `planned` flat policy with no implementer reports nothing. | | `arch-retired-clean` | A `retired` flat policy with no implementer reports nothing. | | `arch-levelled-tree` | Levelled architectural requirements: an L1 with links gets `ERR_REQUIREMENT_LINK_ABOVE_FLOOR`; an L1 and an L2 without links get no `ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS`; a child that re-ascends gets `ERR_REQUIREMENT_LEVEL_NESTING`; a level of 7 gets `ERR_REQUIREMENT_BAD_LEVEL` with the architectural suffix. | @@ -107,21 +118,22 @@ it is wrong, unless the reference is shown to be wrong first. | `deferred-untracked` | `WARN_REQUIREMENT_DEFERRED_UNTRACKED` on a deferral with no `trackedBy`, and nothing on the tracked deferral beside it. `deferredUntracked` is `1`. | | `nothing-implements-subtree` | `WARN_REQUIREMENT_NOTHING_IMPLEMENTS`, severity `warn`, on every node of a subtree in which nothing names a node: a `live` L1, a `partial` L3 and a `live` L4. | | `nothing-implements-delegating-parent-clean` | A parent whose child claims is not reported. Neither is one whose only claiming child is `planned`. | -| `require-implementers` | The input of `nothing-implements-subtree`, byte for byte, with `requireImplementers: true`: the same code, path and message, at severity `error`. | +| `require-implementers` | The input of `nothing-implements-subtree`, byte for byte (the reference runner asserts it), with `requireImplementers: true`: the same code, path and message, at severity `error`. | +| `require-implementers-raises-only-that-code` | `requireImplementers: true` over a model that also has an untracked deferral and an unclaimed entity: `WARN_REQUIREMENT_NOTHING_IMPLEMENTS` is an `error`, while `WARN_REQUIREMENT_DEFERRED_UNTRACKED` and `WARN_REQUIREMENT_OBJECT_UNCLAIMED` stay `warn`. | | `superseded-by-resolves-clean` | A `supersededBy` given as the requirement's path, and as the path qualified by its package: no diagnostics. | | `superseded-by-dangling` | `ERR_REQUIREMENT_DANGLING_REF` for a `supersededBy` that is a misspelt path, a requirement's name without its path, and the name of an object. | -| `superseded-by-package-local` | The ledger lookup over four packages, all resolving: a bare path that exists in two packages, from each of them; a bare path that exists only in another package; a reference completed with the referrer's package. | +| `superseded-by-package-local` | The ledger lookup over four packages, all resolving: a bare path that two packages both have, referenced from each of them and from a third package that has no such path (ambiguous across packages, and it still binds); and a reference completed with the referrer's package. | | `retired-clean` | A retired requirement, and a retired tree with nothing claimed beneath it, report nothing. | | `same-name-in-two-branches` | Two requirements named `Recorded` report the same error with the same message. Only `path` tells them apart. | | `coverage-unclaimed-entity` | `WARN_REQUIREMENT_OBJECT_UNCLAIMED` for the one entity no requirement claims. It has no `path`. | | `coverage-planned-claim-does-not-count` | An entity claimed only by a `planned` requirement is still unclaimed. | -| `coverage-arch-claim-propagates` | An architectural claim on an abstract base covers a subtype and a subtype of that subtype: no diagnostics, two of two entities claimed. | +| `coverage-arch-claim-propagates` | An architectural claim on an abstract base covers a subtype and a subtype of that subtype: no diagnostics, two of two entities claimed. The first `extends` is a bare name; the second is in another package and is a qualified name, so matching either string form alone does not reach both. | | `coverage-functional-claim-does-not-propagate` | The same model with a functional claim leaves both subtypes unclaimed. | | `coverage-exemptions` | An abstract entity, an `object.value` and an `object.projection` are not counted: one of one entities claimed. | | `coverage-library-only-not-measured` | `libraries: ["iam"]` and no project requirement: no unclaimed-entity warning, and no `entities*` keys in the summary. | | `coverage-library-plus-project` | One project requirement switches coverage on, over the library's entities too. The library's ledger claims its own, so only the project's unclaimed entity is reported. | | `coverage-library-overlay-does-not-activate` | An overlay on a library requirement changes that requirement's status and does not switch coverage on. | -| `summary-undecided-rollup` | `undecided` is `3` over four trees: a partial parent over a partial child counts once, at the child; a partial grandchild excludes both ancestors; a child with a `disposition` is not counted and still excludes its parent; a partial parent over a `live` child counts at the parent. | +| `summary-undecided-rollup` | `undecided` is `4` over four trees and one root node: a partial parent over a partial child counts once, at the child; a partial grandchild excludes both ancestors; a child with a `disposition` is not counted and still excludes its parent; a partial parent over a `live` child counts at the parent; and `DeliveryNote` beside `Delivery` counts without excluding it, because ancestry is by path segment and not by string prefix. | ## Who asserts it diff --git a/fixtures/requirement-check-conformance/arch-live-no-implementers/expected.json b/fixtures/requirement-check-conformance/arch-live-no-implementers/expected.json index d968fd8ab..97ea5f9c6 100644 --- a/fixtures/requirement-check-conformance/arch-live-no-implementers/expected.json +++ b/fixtures/requirement-check-conformance/arch-live-no-implementers/expected.json @@ -11,14 +11,20 @@ "code": "ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS", "path": "Attributed", "message": "architectural requirement is 'partial' but nothing implements it. Its check is universality — a claim set of zero means the policy is declared and unapplied." + }, + { + "severity": "error", + "code": "ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS", + "path": "Encrypted", + "message": "architectural requirement is 'live' but nothing implements it. Its check is universality — a claim set of zero means the policy is declared and unapplied." } ], "summary": { - "total": 3, + "total": 4, "functional": 0, - "architectural": 3, + "architectural": 4, "byStatus": { - "live": 2, + "live": 3, "partial": 1 }, "undecided": 1, diff --git a/fixtures/requirement-check-conformance/arch-live-no-implementers/input/meta.shop.yaml b/fixtures/requirement-check-conformance/arch-live-no-implementers/input/meta.shop.yaml index f9c085d08..b055bf8c6 100644 --- a/fixtures/requirement-check-conformance/arch-live-no-implementers/input/meta.shop.yaml +++ b/fixtures/requirement-check-conformance/arch-live-no-implementers/input/meta.shop.yaml @@ -26,3 +26,11 @@ metadata: status: partial statement: Every row records when it changed. counterexample: A row with no change time. + + # Levelled at the link floor: it may name the model, and it names nothing. + - requirement.architectural: + name: Encrypted + level: 4 + status: live + statement: Every stored row is encrypted at rest. + counterexample: A backup that opens in a text editor. diff --git a/fixtures/requirement-check-conformance/coverage-arch-claim-propagates/input/meta.express.yaml b/fixtures/requirement-check-conformance/coverage-arch-claim-propagates/input/meta.express.yaml new file mode 100644 index 000000000..760dd4a92 --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-arch-claim-propagates/input/meta.express.yaml @@ -0,0 +1,10 @@ +# A subtype of a subtype, in a second package: its extends is a qualified name, so the +# claim reaches it only through the resolved super chain. +metadata: + package: acme::express + children: + - object.entity: + name: ExpressOrder + extends: acme::shop::Order + children: + - field.string: { name: courier } diff --git a/fixtures/requirement-check-conformance/coverage-arch-claim-propagates/input/meta.shop.yaml b/fixtures/requirement-check-conformance/coverage-arch-claim-propagates/input/meta.shop.yaml index 1dae32394..a2d9dfa12 100644 --- a/fixtures/requirement-check-conformance/coverage-arch-claim-propagates/input/meta.shop.yaml +++ b/fixtures/requirement-check-conformance/coverage-arch-claim-propagates/input/meta.shop.yaml @@ -14,12 +14,6 @@ metadata: children: - field.string: { name: reference } - - object.entity: - name: ExpressOrder - extends: Order - children: - - field.string: { name: courier } - - requirement.architectural: name: Identified status: live diff --git a/fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/expected.json b/fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/expected.json index b0b669b97..d961c9475 100644 --- a/fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/expected.json +++ b/fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/expected.json @@ -3,12 +3,12 @@ { "severity": "warn", "code": "WARN_REQUIREMENT_OBJECT_UNCLAIMED", - "message": "no requirement claims 'acme::shop::Order'. Add it to an L4 requirement's 'implementedBy'." + "message": "no requirement claims 'acme::express::ExpressOrder'. Add it to an L4 requirement's 'implementedBy'." }, { "severity": "warn", "code": "WARN_REQUIREMENT_OBJECT_UNCLAIMED", - "message": "no requirement claims 'acme::shop::ExpressOrder'. Add it to an L4 requirement's 'implementedBy'." + "message": "no requirement claims 'acme::shop::Order'. Add it to an L4 requirement's 'implementedBy'." } ], "summary": { diff --git a/fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/input/meta.express.yaml b/fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/input/meta.express.yaml new file mode 100644 index 000000000..760dd4a92 --- /dev/null +++ b/fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/input/meta.express.yaml @@ -0,0 +1,10 @@ +# A subtype of a subtype, in a second package: its extends is a qualified name, so the +# claim reaches it only through the resolved super chain. +metadata: + package: acme::express + children: + - object.entity: + name: ExpressOrder + extends: acme::shop::Order + children: + - field.string: { name: courier } diff --git a/fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/input/meta.shop.yaml b/fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/input/meta.shop.yaml index 42495f16d..2f7ff4b0b 100644 --- a/fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/input/meta.shop.yaml +++ b/fixtures/requirement-check-conformance/coverage-functional-claim-does-not-propagate/input/meta.shop.yaml @@ -14,12 +14,6 @@ metadata: children: - field.string: { name: reference } - - object.entity: - name: ExpressOrder - extends: Order - children: - - field.string: { name: courier } - - requirement.functional: name: Kept level: 4 diff --git a/fixtures/requirement-check-conformance/require-implementers-raises-only-that-code/expected.json b/fixtures/requirement-check-conformance/require-implementers-raises-only-that-code/expected.json new file mode 100644 index 000000000..799b91b5c --- /dev/null +++ b/fixtures/requirement-check-conformance/require-implementers-raises-only-that-code/expected.json @@ -0,0 +1,34 @@ +{ + "diagnostics": [ + { + "severity": "error", + "code": "WARN_REQUIREMENT_NOTHING_IMPLEMENTS", + "path": "Refunded", + "message": "is 'live' but neither it nor anything nested under it names an implementing node. A functional requirement's check is existence — a subtree that claims nothing is a capability nobody built." + }, + { + "severity": "warn", + "code": "WARN_REQUIREMENT_DEFERRED_UNTRACKED", + "path": "Exchanged", + "message": "is deferred but names no @trackedBy issue. Deferring without a ticket is how a known gap becomes an unknown one — nothing will raise it again." + }, + { + "severity": "warn", + "code": "WARN_REQUIREMENT_OBJECT_UNCLAIMED", + "message": "no requirement claims 'acme::shop::Customer'. Add it to an L4 requirement's 'implementedBy'." + } + ], + "summary": { + "total": 3, + "functional": 3, + "architectural": 0, + "byStatus": { + "planned": 1, + "live": 2 + }, + "undecided": 0, + "deferredUntracked": 1, + "entitiesClaimed": 1, + "entitiesTotal": 2 + } +} diff --git a/fixtures/requirement-check-conformance/require-implementers-raises-only-that-code/input/meta.shop.yaml b/fixtures/requirement-check-conformance/require-implementers-raises-only-that-code/input/meta.shop.yaml new file mode 100644 index 000000000..6362e8750 --- /dev/null +++ b/fixtures/requirement-check-conformance/require-implementers-raises-only-that-code/input/meta.shop.yaml @@ -0,0 +1,41 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + # No requirement claims Customer: a coverage warning the strict switch leaves alone. + - object.entity: + name: Customer + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + # Live and names nothing: the one finding the strict switch raises to an error. + - requirement.functional: + name: Refunded + level: 4 + status: live + statement: A refund is recorded against its order. + counterexample: A refund with no order. + + # Deferred with no ticket: a warning the strict switch leaves alone. + - requirement.functional: + name: Exchanged + level: 4 + status: planned + disposition: deferred + statement: An exchange is recorded against its order. + counterexample: An exchange with no order. diff --git a/fixtures/requirement-check-conformance/require-implementers-raises-only-that-code/options.json b/fixtures/requirement-check-conformance/require-implementers-raises-only-that-code/options.json new file mode 100644 index 000000000..92afc2d38 --- /dev/null +++ b/fixtures/requirement-check-conformance/require-implementers-raises-only-that-code/options.json @@ -0,0 +1,3 @@ +{ + "requireImplementers": true +} diff --git a/fixtures/requirement-check-conformance/summary-undecided-rollup/expected.json b/fixtures/requirement-check-conformance/summary-undecided-rollup/expected.json index 65605f9b2..7398d074b 100644 --- a/fixtures/requirement-check-conformance/summary-undecided-rollup/expected.json +++ b/fixtures/requirement-check-conformance/summary-undecided-rollup/expected.json @@ -1,14 +1,14 @@ { "diagnostics": [], "summary": { - "total": 9, - "functional": 9, + "total": 10, + "functional": 10, "architectural": 0, "byStatus": { "live": 1, - "partial": 8 + "partial": 9 }, - "undecided": 3, + "undecided": 4, "deferredUntracked": 0, "entitiesClaimed": 2, "entitiesTotal": 2 diff --git a/fixtures/requirement-check-conformance/summary-undecided-rollup/input/meta.shop.yaml b/fixtures/requirement-check-conformance/summary-undecided-rollup/input/meta.shop.yaml index e7c39ce0e..11bdba74b 100644 --- a/fixtures/requirement-check-conformance/summary-undecided-rollup/input/meta.shop.yaml +++ b/fixtures/requirement-check-conformance/summary-undecided-rollup/input/meta.shop.yaml @@ -85,3 +85,13 @@ metadata: statement: An order is recorded when it leaves. counterexample: A dispatched order has no row. implementedBy: [Order] + + # Counted, and so is Delivery: DeliveryNote starts with the same letters and is not + # beneath it. Ancestry is by path segment, never by string prefix. + - requirement.functional: + name: DeliveryNote + level: 4 + status: partial + statement: A delivery note is recorded for every dispatched order. + counterexample: A dispatched order with no delivery note. + implementedBy: [Order] diff --git a/server/typescript/packages/cli/test/requirement-check-conformance.test.ts b/server/typescript/packages/cli/test/requirement-check-conformance.test.ts index 14f4a667e..d8997bff1 100644 --- a/server/typescript/packages/cli/test/requirement-check-conformance.test.ts +++ b/server/typescript/packages/cli/test/requirement-check-conformance.test.ts @@ -25,12 +25,20 @@ const CORPUS_DIR = join(import.meta.dir, "../../../../../fixtures/requirement-ch interface Finding { severity: string; code: string; path?: string; message: string } interface Expected { diagnostics: Finding[]; summary: RequirementSummary | null } -/** The whole of `options.json`. A key outside this list is refused rather than ignored: a - * misspelt `requireImplementers` would otherwise run the case without the strict switch - * and pin the wrong severity in every port. */ +/** The whole of `options.json`. A key outside this list, or a value of the wrong type, is + * refused rather than ignored: a misspelt `requireImplementers`, or one written as the + * string "true", would otherwise run the case without the strict switch and pin the + * wrong severity in every port. */ const OPTION_KEYS = ["libraries", "requireImplementers"] as const; interface Options { libraries?: string[]; requireImplementers?: boolean } +/** A case whose `input/` must be, file for file and byte for byte, another case's. The + * pair differs in `options.json` only, so the two expectations isolate what the option + * does; an input edited on one side would quietly end that. */ +const SAME_INPUT_AS: Readonly> = { + "require-implementers": "nothing-implements-subtree", +}; + type Row = readonly [severity: string, code: string, path: string, message: string]; const row = (d: Finding): Row => [d.severity, d.code, d.path ?? "", d.message]; @@ -50,7 +58,25 @@ function readOptions(caseDir: string): Options { const options = JSON.parse(readFileSync(file, "utf8")) as Record; const unknown = Object.keys(options).filter((k) => !(OPTION_KEYS as readonly string[]).includes(k)); if (unknown.length > 0) throw new Error(`${file}: unknown option(s) ${unknown.join(", ")}`); - return options as Options; + + const { libraries, requireImplementers } = options; + if (libraries !== undefined + && !(Array.isArray(libraries) && libraries.every((l): l is string => typeof l === "string"))) { + throw new Error(`${file}: 'libraries' must be an array of strings`); + } + if (requireImplementers !== undefined && typeof requireImplementers !== "boolean") { + throw new Error(`${file}: 'requireImplementers' must be a boolean`); + } + return { + ...(libraries === undefined ? {} : { libraries }), + ...(requireImplementers === undefined ? {} : { requireImplementers }), + }; +} + +/** Every file under a case's `input/`, by name, with its content. */ +function inputFiles(name: string): Record { + const dir = join(CORPUS_DIR, name, "input"); + return Object.fromEntries(readdirSync(dir).sort().map((f) => [f, readFileSync(join(dir, f), "utf8")])); } /** The case names the README's "Cases" table documents, in table order. */ @@ -82,6 +108,9 @@ describe("requirement-check conformance corpus", () => { const expected = JSON.parse(readFileSync(expectedFile, "utf8")) as Expected; const options = readOptions(caseDir); + const twin = SAME_INPUT_AS[name]; + if (twin !== undefined) expect(inputFiles(name)).toEqual(inputFiles(twin)); + const result = await MetaDataLoader.fromDirectory(join(caseDir, "input"), { strict: true, ...(options.libraries === undefined ? {} : { libraries: options.libraries }), From ca6f2a4af3b69fec9b45a21f46e50bf6f5264150 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 5 Oct 2026 23:57:34 -0400 Subject: [PATCH 09/43] fix(codegen-ts): compare every branch of the requirement-tests reference template, and refuse an unknown grain The byte-identity gate now reaches the custom-path warning, the capped uncovered list, the undecided gap line and the generator's name, target and orphan policy, and renders hand-built arguments through both renderers; the reference renderer reads the status as the built-in does. An unknown grain is refused by the identity functions and both generators instead of running half of each grain, and a non-ASCII digest is pinned so a character count cannot pass for a byte length. Refs docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md and ADR-0057. --- docs/features/own-your-codegen.md | 2 +- .../src/generators/requirement-tests.ts | 4 + .../packages/codegen-ts/src/index.ts | 1 + .../src/reference/requirement-tests.ts | 29 ++- .../codegen-ts/src/requirement-walk.ts | 29 ++- .../test/reference-byte-identical.test.ts | 226 +++++++++++++++++- .../test/requirement-tests-generator.test.ts | 15 ++ .../codegen-ts/test/requirement-walk.test.ts | 52 ++++ 8 files changed, 340 insertions(+), 18 deletions(-) diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 679a23f90..d49fbebf6 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -289,7 +289,7 @@ generator code. The 20-line programmatic shape for each port is in | Port | Invocation | Programmatic — write a `Generator` | Declarative — template + scope | |---|---|---|---| -| **TypeScript** | `meta init` → `meta gen --list --probe` → `meta eject ` → `meta gen` (Bun/Node CLI) | **Yes.** `meta generator new ` writes a working generator of your own into `codegen/generators/` and wires it — the first move for an output nothing ships. To start from a reference instead: `meta init` scaffolds the LAYOUT and an empty selection (ADR-0034 Amendment 2); `meta eject ...` copies each generator you choose into `codegen/generators/*.ts` and prints the import to add to `metaobjects.config.ts`. Edit them freely. The prompt tier (`prompt-render`, `output-parser`, `extractor`, `output-prompt`, `render-helper`) ejects like the rest: you own which templates get a module and where it lands, while the render and extract engines those modules call stay in the package. Not every registered generator is ejectable: `callable`, `trace-helper`, the `template` primitive, the docs tier (`docs`, `api-docs`, `mermaid-er`, whose door is `meta docs`) and `shared-model` ship no reference template, so they are **package-only** — `meta gen --list` marks them so and `meta eject` names them as such. (As of 2026-10-05 `requirement-tests` is no longer one of them: it ships a reference template, and `meta eject requirement-tests` copies the generator together with its default stub renderer.) `shared-model` (FR-023's publisher generator) stays package-only deliberately, since it emits a hash-pinned cross-port contract artifact. | **Yes** — `templateGenerator({ template, scope, outputPattern })` in the config's `generators: [...]`. No CLI flag: the config already takes generator values. | +| **TypeScript** | `meta init` → `meta gen --list --probe` → `meta eject ` → `meta gen` (Bun/Node CLI) | **Yes.** `meta generator new ` writes a working generator of your own into `codegen/generators/` and wires it — the first move for an output nothing ships. To start from a reference instead: `meta init` scaffolds the LAYOUT and an empty selection (ADR-0034 Amendment 2); `meta eject ...` copies each generator you choose into `codegen/generators/*.ts` and prints the import to add to `metaobjects.config.ts`. Edit them freely. The prompt tier (`prompt-render`, `output-parser`, `extractor`, `output-prompt`, `render-helper`) ejects like the rest: you own which templates get a module and where it lands, while the render and extract engines those modules call stay in the package. `requirement-tests` ejects as one file holding the generator and its default stub renderer, so the stub text is yours to change; the requirement walk, each test's identity and the claim digest stay in the package. Not every registered generator is ejectable: `callable`, `trace-helper`, the `template` primitive, the docs tier (`docs`, `api-docs`, `mermaid-er`, whose door is `meta docs`) and `shared-model` ship no reference template, so they are **package-only** — `meta gen --list` marks them so and `meta eject` names them as such. `shared-model` (FR-023's publisher generator) stays package-only deliberately, since it emits a hash-pinned cross-port contract artifact. | **Yes** — `templateGenerator({ template, scope, outputPattern })` in the config's `generators: [...]`. No CLI flag: the config already takes generator values. | | **Java / Kotlin** | `mvn metaobjects:generate` / `mvn metaobjects:verify` (`metaobjects-maven-plugin`) | **Yes.** Extend `FileEmittingGenerator` and read the model through `ModelWalk` (both in `metaobjects-codegen-base`) for one of your own. Every generator — built-in or your own — is named in `` and loaded from the project classpath: one seam, not two. There is no default suite, so `` is the complete list. Kotlin runs through the same goal. | **Yes** — `TemplateScopeGenerator` wired as an ordinary ``. No CLI flag: `` is already the seam. | | **C#** | `dotnet meta gen` / `dotnet meta verify` (.NET tool) | **Yes.** Implement `IGenerator` in the owned console project `codegen/` and list it in `codegen/Program.cs`; `dotnet meta gen` / `verify --codegen` hand off to that project whenever `codegen/Codegen.csproj` exists. `dotnet meta eject ` scaffolds the project, or write its two files by hand. An owned generator the `--generators` selection does not name still runs. | **Yes** — `dotnet meta gen --template-spec --template-root `. | | **Python** | `metaobjects gen` / `metaobjects verify` (console-script) | **Yes.** Name your generator as `module:symbol` in `--generators` or a target's `generators` in `metaobjects.config.yaml`; the symbol is an instance or a function returning one. Read the model through `metaobjects.codegen.model_walk`. (`--provider module:symbol` registers **metamodel vocabulary**, not a generator.) | **Yes** — `metaobjects gen --template-spec --templates `. | diff --git a/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts b/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts index 281d7a9f0..63e0ba132 100644 --- a/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts +++ b/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts @@ -22,6 +22,7 @@ import { import type { Generator, EmittedFile, GenContext } from "../generator.js"; import { walkRequirements, + assertRequirementTestGrain, defaultRequirementTestFilter, requirementTestUnits, requirementTestIdentity, @@ -130,6 +131,9 @@ function attrString(node: { attr: (n: string) => unknown }, name: string): strin export function requirementTests(opts: RequirementTestsOpts = {}): Generator { const filter = opts.filter ?? defaultRequirementTestFilter; const grain = opts.grain ?? "concern"; + // Refused here, when the generator is built, rather than on the first requirement: + // an unknown grain is a mistake in the config whatever the model holds. + assertRequirementTestGrain(grain); const toPath = opts.path ?? (grain === "member" ? defaultMemberPath : defaultPath); // A custom `path` with no custom `owns` leaves the default namespace pointing // somewhere the generator no longer writes, so reconciliation matches nothing. diff --git a/server/typescript/packages/codegen-ts/src/index.ts b/server/typescript/packages/codegen-ts/src/index.ts index b7a7f9113..92edc1ea4 100644 --- a/server/typescript/packages/codegen-ts/src/index.ts +++ b/server/typescript/packages/codegen-ts/src/index.ts @@ -359,6 +359,7 @@ export { // requirement-test generator agrees on, and what an owned (ejected) generator // keeps importing from here. defaultRequirementTestFilter, + assertRequirementTestGrain, requirementDigest, witnessKeyOf, requirementTestUnits, diff --git a/server/typescript/packages/codegen-ts/src/reference/requirement-tests.ts b/server/typescript/packages/codegen-ts/src/reference/requirement-tests.ts index 861e609d3..b4cd3ef21 100644 --- a/server/typescript/packages/codegen-ts/src/reference/requirement-tests.ts +++ b/server/typescript/packages/codegen-ts/src/reference/requirement-tests.ts @@ -36,6 +36,7 @@ import { GENERATED_HEADER, NO_CONCERN, + assertRequirementTestGrain, defaultRequirementTestFilter, requirementTestIdentity, requirementTestUnits, @@ -52,6 +53,8 @@ import { import { REQUIREMENT_ATTR_COUNTEREXAMPLE, REQUIREMENT_ATTR_STATEMENT, + REQUIREMENT_STATUSES, + REQUIREMENT_STATUSES_REQUIRING_LIVE_NODES, REQUIREMENT_STATUS_RETIRED, } from "@metaobjectsdev/metadata"; @@ -66,6 +69,19 @@ import { // capability works, so an empty green test asserts the opposite of the claim. // --------------------------------------------------------------------------- +/** + * Statuses whose stub is SKIPPED rather than failing. + * + * The rule is "does this entry claim the capability works right now?" — only `live` and + * `partial` do. A stub for anything else is skipped: a red build for something nobody + * intends to build is noise an application silences wholesale, taking the live stubs + * with it. Derived from the loader's status list, not restated from it, so a status + * added later is skipped by construction instead of being left failing. + */ +const SKIPPED_STATUSES: ReadonlySet = new Set( + REQUIREMENT_STATUSES.filter((s) => !REQUIREMENT_STATUSES_REQUIRING_LIVE_NODES.includes(s)), +); + /** * Escape an author-supplied value for a double-quoted TS string literal. * @@ -111,11 +127,9 @@ function gapLine(a: RequirementTestArgs): string { } export function renderRequirementTest(a: RequirementTestArgs): string { - // `skip` is null only for the statuses that claim the capability works right now - // (`live` and `partial`). A stub for anything else is skipped rather than failing: a - // red build for something nobody intends to build is noise an application silences - // wholesale, taking the live stubs with it. - const skipped = a.skip !== null; + // Decided from the requirement's STATUS. The identity fields on `a` (`skip`, `id`, + // `digest`, …) are there for a renderer of your own; this one reads none of them. + const skipped = a.view.status !== undefined && SKIPPED_STATUSES.has(a.view.status); const runner = skipped ? "test.skip" : "test"; // The test NAME is the link between the ledger entry and the assertion, and it is a // string literal: an unescaped quote in either value closes it. @@ -135,7 +149,7 @@ export function renderRequirementTest(a: RequirementTestArgs): string { ` "replace this with an assertion that fails when: ${forStringLiteral(a.counterexample)}",`, " );", ]; - } else if (a.skip === REQUIREMENT_STATUS_RETIRED) { + } else if (a.view.status === REQUIREMENT_STATUS_RETIRED) { body = [ " // Retired: this capability was deliberately removed and must not be rebuilt.", " // If you assert anything here, assert that it STAYS removed.", @@ -258,6 +272,9 @@ function attrString(node: { attr: (n: string) => unknown }, name: string): strin export function requirementTests(opts: RequirementTestsOpts = {}): Generator { const filter = opts.filter ?? defaultRequirementTestFilter; const grain = opts.grain ?? "concern"; + // Refused here, when the generator is built, rather than on the first requirement: + // an unknown grain is a mistake in the config whatever the model holds. + assertRequirementTestGrain(grain); const toPath = opts.path ?? (grain === "member" ? defaultMemberPath : defaultPath); // A custom `path` with no custom `owns` leaves the default namespace pointing // somewhere the generator no longer writes, so reconciliation matches nothing. That diff --git a/server/typescript/packages/codegen-ts/src/requirement-walk.ts b/server/typescript/packages/codegen-ts/src/requirement-walk.ts index 4a11539e5..2f3ff517d 100644 --- a/server/typescript/packages/codegen-ts/src/requirement-walk.ts +++ b/server/typescript/packages/codegen-ts/src/requirement-walk.ts @@ -147,13 +147,33 @@ export function groupByConcern(w: WalkedRequirement): Map.` a requirement claims. * - `member`: one test per distinct `@implementedBy` reference that resolves. */ -export type RequirementTestGrain = "concern" | "member"; +export type RequirementTestGrain = (typeof REQUIREMENT_TEST_GRAINS)[number]; + +/** + * Refuse anything that is not a grain. + * + * The type alone does not hold: `metaobjects.config.ts` is loaded without a typecheck, + * so a typo arrives here as a plain string. Letting it through picks a grain by + * accident — and not even one grain, since each place that branches on it would fall + * to its own default. Called wherever a grain enters: the identity functions below and + * the generator, built-in or owned. + */ +export function assertRequirementTestGrain(grain: unknown): asserts grain is RequirementTestGrain { + if (!(REQUIREMENT_TEST_GRAINS as readonly unknown[]).includes(grain)) { + throw new Error( + `unknown requirement-test grain ${JSON.stringify(grain)}: expected ` + + `${REQUIREMENT_TEST_GRAINS.map((g) => JSON.stringify(g)).join(" or ")}.`, + ); + } +} /** One generated test. The same record in every language port. */ export interface RequirementTestIdentity { @@ -255,6 +275,7 @@ export function requirementTestUnits( w: WalkedRequirement, grain: RequirementTestGrain = "concern", ): Map { + assertRequirementTestGrain(grain); if (grain === "concern") return groupByConcern(w); const units = new Map(); for (const t of w.targets) { @@ -300,10 +321,14 @@ export function requirementTestIdentities( opts: { grain?: RequirementTestGrain; filter?: (r: RequirementView) => boolean } = {}, ): RequirementTestIdentity[] { const filter = opts.filter ?? defaultRequirementTestFilter; + // Checked here as well as per requirement, so a bad grain is refused even over a + // ledger the filter empties — the answer must not depend on what the model holds. + const grain = opts.grain ?? "concern"; + assertRequirementTestGrain(grain); const out: RequirementTestIdentity[] = []; for (const walked of walkRequirements(root)) { if (!filter(walked.view)) continue; - for (const unit of requirementTestUnits(walked, opts.grain).keys()) { + for (const unit of requirementTestUnits(walked, grain).keys()) { out.push(requirementTestIdentity(walked, unit)); } } diff --git a/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts b/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts index 388a03075..94c394c68 100644 --- a/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts +++ b/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts @@ -30,8 +30,12 @@ import { extractor as refExtractor } from "../src/reference/extractor.js"; import { outputPrompt as refOutputPrompt } from "../src/reference/output-prompt.js"; import { renderHelper as refRenderHelper } from "../src/reference/render-helper.js"; import { requirementTests as builtinRequirementTests } from "../src/generators/requirement-tests.js"; -import { requirementTests as refRequirementTests } from "../src/reference/requirement-tests.js"; -import type { RequirementTestsOpts } from "../src/index.js"; +import { + requirementTests as refRequirementTests, + renderRequirementTest as refRenderRequirementTest, +} from "../src/reference/requirement-tests.js"; +import { renderRequirementTest as builtinRenderRequirementTest } from "../src/templates/requirement-test.js"; +import type { RequirementTestArgs, RequirementTestsOpts } from "../src/index.js"; import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; import { FileSource } from "@metaobjectsdev/metadata/core"; @@ -390,6 +394,18 @@ const REQUIREMENT_LEDGER = { "@counterexample": "a silent checkout", }, }, + { + // A gap somebody is tracking and nobody has ruled on: `@trackedBy` with no + // `@disposition`, which the stub records as "undecided". + "requirement.functional": { + name: "Chased", + "@level": 4, + "@status": "partial", + "@trackedBy": ["#77"], + "@statement": "An unpaid order is chased.", + "@counterexample": "an unpaid order nobody hears about", + }, + }, ], }, }, @@ -407,16 +423,31 @@ const REQUIREMENT_LEDGER = { }; describe("ADR-0034 — the requirement-tests reference over a ledger that declares requirements", () => { - const RUNS: ReadonlyArray<{ label: string; opts: RequirementTestsOpts; files: number }> = [ - // Recorded, Annotated, Refunded, Faxed, Acknowledged — one concern each. - { label: "the defaults", opts: {}, files: 5 }, + // Where a run moves the stubs to, and the namespace that goes with it. + const specPath: NonNullable = (view, key) => + `specs/${view.path}__${key.replace(/[^A-Za-z0-9]+/g, "_")}.spec.ts`; + const ownsSpecs = (relPath: string): boolean => relPath.startsWith("specs/"); + + // `warns` names a sentence the run MUST produce. The warnings are compared between the + // two generators either way; this is what stops that comparison passing over two + // empty lists when the branch under test was never reached. + const RUNS: ReadonlyArray<{ label: string; opts: RequirementTestsOpts; files: number; warns?: string }> = [ + // Recorded, Annotated, Refunded, Faxed, Acknowledged, Chased — one concern each. + { label: "the defaults", opts: {}, files: 6, warns: "2 requirement(s) matched no filter" }, // Every requirement (the L3 parent and the architectural policy included), one stub // per reference: Orders 1, Recorded 2, Annotated 2, Refunded 1, Faxed 1, - // Acknowledged 1, Audited 1. - { label: 'grain: "member" under a filter that keeps everything', opts: { grain: "member", filter: () => true }, files: 9 }, + // Acknowledged 1, Chased 1, Audited 1. + { label: 'grain: "member" under a filter that keeps everything', opts: { grain: "member", filter: () => true }, files: 10 }, // A filter that drops requirements while the warning is on, in the default grain. - { label: "a filter by package and status", opts: { filter: (r) => r.package === "acme::shop" && r.status !== "retired" }, files: 6 }, - { label: "the uncovered warning switched off", opts: { warnUncovered: false }, files: 5 }, + { label: "a filter by package and status", opts: { filter: (r) => r.package === "acme::shop" && r.status !== "retired" }, files: 7, warns: "Uncovered: Orders.Faxed." }, + { label: "the uncovered warning switched off", opts: { warnUncovered: false }, files: 6 }, + // Seven of the eight requirements uncovered: five are named and the rest counted. + { label: "more uncovered requirements than the warning names", opts: { filter: (r) => r.path === "Orders.Recorded" }, files: 1, warns: ", and 2 more." }, + // A custom `path` with no `owns`: the stubs move and the generator says it can no + // longer clean up after a deleted requirement. + { label: "a custom path without owns", opts: { path: specPath }, files: 6, warns: "a custom 'path' was supplied without a matching 'owns'" }, + { label: "a custom path with its owns, under member grain", opts: { path: specPath, owns: ownsSpecs, grain: "member", forceOrphanDelete: true }, files: 8, warns: "2 requirement(s) matched no filter" }, + { label: "a custom path with orphan reconciliation off", opts: { path: specPath, reconcileOrphans: false, warnUncovered: false }, files: 6 }, ]; for (const run of RUNS) { @@ -455,6 +486,183 @@ describe("ADR-0034 — the requirement-tests reference over a ledger that declar for (const k of aKeys) expect(`${k}:\n${b.files[k]}`).toBe(`${k}:\n${a.files[k]}`); // The warning text is duplicated in the copy too, so it is compared too. expect(b.warnings).toEqual(a.warnings); + if (run.warns === undefined) expect(a.warnings).toEqual([]); + else expect(a.warnings.join("\n")).toContain(run.warns); }); } + + test("the ledger reaches the undecided gap line", async () => { + // Vacuity guard for the one stub branch only this requirement takes. + const loaded = await new MetaDataLoader().load([ + new InMemoryStringSource(JSON.stringify(REQUIREMENT_LEDGER)), + ]); + const ctx = { loadedRoot: loaded.root, warn: () => {} } as unknown as Parameters[0]; + const files = await refRequirementTests().generate(ctx); + const chased = files.find((f) => f.path === "requirements/Orders.Chased.test.ts"); + expect(chased?.content).toContain(" * Known gap: undecided — #77"); + }); +}); + +// Everything above compares what a generator WRITES. A generator is also an object the +// runner reads: its name, its target, and above all its orphan policy, which decides +// which previously-generated files the runner may DELETE. A copy whose `owns` claimed +// more than the built-in's would remove another generator's output, and no emitted +// file would show it. +describe("ADR-0034 — the requirement-tests reference is the same GENERATOR, not just the same output", () => { + const specPath: NonNullable = (view, key) => `specs/${view.path}.${key}.spec.ts`; + const SAMPLE_PATHS = [ + "requirements/Orders.Recorded.object.entity.test.ts", + "requirements/deep/nested.test.ts", + "requirements", + "requirementsElsewhere/a.test.ts", + "specs/Orders.Recorded.object.entity.spec.ts", + "Order.ts", + "", + ]; + + const shape = (g: Generator) => ({ + name: g.name, + target: g.target, + reconciles: g.orphanPolicy !== undefined, + force: g.orphanPolicy?.force, + owns: g.orphanPolicy === undefined ? null : SAMPLE_PATHS.filter((p) => g.orphanPolicy?.owns(p)), + }); + + // `expected` is the built-in's shape written out, so the comparison below cannot + // pass by both sides being wrong together (or both claiming nothing at all). + const CASES: ReadonlyArray<{ label: string; opts: RequirementTestsOpts; expected: ReturnType }> = [ + { + label: "the defaults own the default stub directory, and nothing beside it", + opts: {}, + expected: { + name: "requirement-tests", + target: undefined, + reconciles: true, + force: undefined, + owns: ["requirements/Orders.Recorded.object.entity.test.ts", "requirements/deep/nested.test.ts"], + }, + }, + { + label: "member grain owns the same directory", + opts: { grain: "member" }, + expected: { + name: "requirement-tests", + target: undefined, + reconciles: true, + force: undefined, + owns: ["requirements/Orders.Recorded.object.entity.test.ts", "requirements/deep/nested.test.ts"], + }, + }, + { + label: "the name and the target are the application's", + opts: { name: "req-api", target: "api-tests" }, + expected: { + name: "req-api", + target: "api-tests", + reconciles: true, + force: undefined, + owns: ["requirements/Orders.Recorded.object.entity.test.ts", "requirements/deep/nested.test.ts"], + }, + }, + { + label: "a custom path without owns claims NOTHING", + opts: { path: specPath }, + expected: { name: "requirement-tests", target: undefined, reconciles: true, force: undefined, owns: [] }, + }, + { + label: "a custom path with owns claims exactly what owns says", + opts: { path: specPath, owns: (p) => p.startsWith("specs/") }, + expected: { + name: "requirement-tests", + target: undefined, + reconciles: true, + force: undefined, + owns: ["specs/Orders.Recorded.object.entity.spec.ts"], + }, + }, + { + label: "forceOrphanDelete sets force", + opts: { forceOrphanDelete: true }, + expected: { + name: "requirement-tests", + target: undefined, + reconciles: true, + force: true, + owns: ["requirements/Orders.Recorded.object.entity.test.ts", "requirements/deep/nested.test.ts"], + }, + }, + { + label: "reconcileOrphans: false declares no policy at all", + opts: { reconcileOrphans: false, forceOrphanDelete: true }, + expected: { name: "requirement-tests", target: undefined, reconciles: false, force: undefined, owns: null }, + }, + ]; + + for (const c of CASES) { + test(c.label, () => { + const builtin = shape(builtinRequirementTests(c.opts)); + expect(builtin).toEqual(c.expected); + expect(shape(refRequirementTests(c.opts))).toEqual(builtin); + }); + } + + test("an unknown grain is refused by both, in the same words", () => { + const typo = { grain: "members" } as unknown as RequirementTestsOpts; + const refusal = (make: () => Generator): string => { + try { + make(); + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + return "(did not throw)"; + }; + const builtin = refusal(() => builtinRequirementTests(typo)); + expect(builtin).toBe('unknown requirement-test grain "members": expected "concern" or "member".'); + expect(refusal(() => refRequirementTests(typo))).toBe(builtin); + }); +}); + +// The renderer is exported from the copy too, and an application may call it with +// arguments it built itself. Rendering through a generator only ever supplies arguments +// that agree with each other, so the two renderers are also compared directly — over +// every status, with the identity's `skip` field set to agree with it and to contradict +// it. The default renderer reads the STATUS; a copy that read `skip` instead passed +// every generator run above and diverged here. +describe("ADR-0034 — the requirement-tests reference renders hand-built arguments identically", () => { + const STATUSES = ["live", "partial", "planned", "retired", undefined] as const; + const SKIPS = [null, "planned", "retired"] as const; + const EXTRAS: ReadonlyArray> = [ + {}, + { disposition: "accepted" }, + { trackedBy: ["#1", "a */ b"] }, + { + disposition: "deferred", + trackedBy: ["#2"], + targets: [{ ref: "Order.note", concern: "field.string", node: {} as never }], + }, + ]; + + for (const status of STATUSES) { + for (const skip of SKIPS) { + test(`status ${status ?? "(absent)"}, skip ${skip ?? "null"}`, () => { + for (const extra of EXTRAS) { + const args: RequirementTestArgs = { + view: { subType: "functional", level: 4, status, path: 'Orders."Quoted"', package: "acme::shop", implementedByTypes: [] }, + concern: "object.entity", + statement: "A statement */ with a break.\nAnd a second line.", + counterexample: 'a "quoted" \\ counterexample\r\nover two lines', + targets: [], + package: "acme::shop", + unit: "object.entity", + id: 'acme::shop::Orders."Quoted" [object.entity]', + witnessKey: "req_acme_shop_Orders_Quoted__object_entity", + skip, + digest: "0".repeat(64), + ...extra, + }; + expect(refRenderRequirementTest(args)).toBe(builtinRenderRequirementTest(args)); + } + }); + } + } }); diff --git a/server/typescript/packages/codegen-ts/test/requirement-tests-generator.test.ts b/server/typescript/packages/codegen-ts/test/requirement-tests-generator.test.ts index c1bcc694a..78b595c47 100644 --- a/server/typescript/packages/codegen-ts/test/requirement-tests-generator.test.ts +++ b/server/typescript/packages/codegen-ts/test/requirement-tests-generator.test.ts @@ -237,3 +237,18 @@ describe("requirementTests — grain", () => { ]); }); }); + +describe("requirementTests — an unknown grain", () => { + test("is refused when the generator is built, before anything is generated", () => { + // `metaobjects.config.ts` is loaded without a typecheck. A typo used to take the + // member grouping and the concern paths at once. + const typo = { grain: "members" } as unknown as RequirementTestsOpts; + expect(() => requirementTests(typo)).toThrow( + 'unknown requirement-test grain "members": expected "concern" or "member".', + ); + }); + + test("an absent grain is the default, not an error", async () => { + expect((await emit({})).length).toBe(3); + }); +}); diff --git a/server/typescript/packages/codegen-ts/test/requirement-walk.test.ts b/server/typescript/packages/codegen-ts/test/requirement-walk.test.ts index b0b1d6f77..619c3c93b 100644 --- a/server/typescript/packages/codegen-ts/test/requirement-walk.test.ts +++ b/server/typescript/packages/codegen-ts/test/requirement-walk.test.ts @@ -13,9 +13,11 @@ import { NO_CONCERN, requirementDigest, witnessKeyOf, + requirementTestUnits, requirementTestIdentities, witnessKeyCollisions, } from "../src/requirement-walk.js"; +import type { RequirementTestGrain } from "../src/requirement-walk.js"; // The claimed nodes deliberately span THREE distinct types. A model whose targets // are all one type cannot tell the per-type fan-out rule from the per-node rule @@ -267,6 +269,28 @@ describe("requirementDigest — did the claim change", () => { expect(requirementDigest(await requirementAt(shop(reworded), "Gap"))).not.toBe(base); }); + test("lengths in the digest are UTF-8 BYTE lengths, not character counts", async () => { + // Every other digest in this file hashes ASCII, where the two agree. Here the + // statement is 36 characters and 44 bytes, the counterexample 23 and 26. + // + // The expected value was computed without this code, in a shell: + // printf 'requirement-digest/v1\nsubType 10\nfunctional\nlevel 1\n4\nstatus 4\nlive\n + // statement 44\n\ncounterexample 26\n\n + // implementedBy 1\nref 5\nOrder\n' | sha256sum + // with 44 and 26 taken from `printf %s "" | wc -c`. The same recipe over the + // worked example's text reproduces its pinned digest. + const accented = functional("Exact", { + "@level": 4, + "@status": "live", + "@statement": "Le total est exact — à l’euro près ✓", + "@counterexample": "Un total arrondi à 10 €", + "@implementedBy": ["Order"], + }); + expect(requirementDigest(await requirementAt(shop(accented), "Exact"))).toBe( + "ba64ffbaad8827f7c4692b49c083bfc8edc754d65690280f20f2257f3377a813", + ); + }); + test("the digest normalises CRLF", async () => { const withBreak = (br: string): Json => functional("Gap", { @@ -456,6 +480,34 @@ describe("requirementTestIdentities — one record per generated test", () => { }); }); +describe("an unknown grain is refused", () => { + // A config file is loaded without a typecheck, so a typo reaches this code as a + // plain string. Falling through to either grain would generate SOMETHING and say + // nothing; half of each (one grain's grouping, the other's paths) is worse. + const typo = "members" as unknown as RequirementTestGrain; + const refusal = 'unknown requirement-test grain "members": expected "concern" or "member".'; + + test("by requirementTestIdentities, even when the ledger is empty", async () => { + const empty = await loadDocs(shop()); + expect(walkRequirements(empty)).toEqual([]); + expect(() => requirementTestIdentities(empty, { grain: typo })).toThrow(refusal); + const root = await loadDocs(WORKED_EXAMPLE); + expect(() => requirementTestIdentities(root, { grain: typo })).toThrow(refusal); + }); + + test("by requirementTestUnits", async () => { + const [first] = walkRequirements(await loadDocs(WORKED_EXAMPLE)); + expect(() => requirementTestUnits(first!, typo)).toThrow(refusal); + }); + + test("and a value of the wrong type is named as it was given", async () => { + const root = await loadDocs(WORKED_EXAMPLE); + expect(() => + requirementTestIdentities(root, { grain: 5 as unknown as RequirementTestGrain }), + ).toThrow('unknown requirement-test grain 5: expected "concern" or "member".'); + }); +}); + describe("witnessKeyCollisions", () => { test("two addresses that mangle alike are reported as a collision", async () => { const root = await loadDocs( From c7dbb3451617cf7444e7880af63f6775d528dce8 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 6 Oct 2026 00:00:40 -0400 Subject: [PATCH 10/43] =?UTF-8?q?docs(plan):=20requirements=20slice=201=20?= =?UTF-8?q?=E2=80=94=20every=20port's=20generator=20test=20refuses=20an=20?= =?UTF-8?q?unknown=20grain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No corpus pins the refusal, so each port's own test is the gate. Refs the plan and ADR-0057. --- ...-05-requirements-slice-1-checks-and-test-generators.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md b/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md index 1a7808689..810844f57 100644 --- a/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md +++ b/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md @@ -821,7 +821,7 @@ requirementTests: - `the generated tests run`: write the file and a witness module into `tmp_path`, run `sys.executable -m pytest` in a subprocess, and assert one passed (witness present and returning), one failed with `unimplemented requirement:` and the counterexample (no witness), one failed with the witness's own assertion, one skipped (planned). - `a broken witness module raises its own error`: a witness module whose body does `import does_not_exist` makes the test error with `ModuleNotFoundError: No module named 'does_not_exist'`, not "unimplemented requirement". - `a witness-key collision refuses generation` with `ERR_REQUIREMENT_WITNESS_KEY_COLLISION` and both ids in the message. - - `grain="member" emits one test per reference`. + - `grain="member" emits one test per reference`; `an unknown grain is refused with a clear error` (in the identity function and in the generator; no corpus pins this, so the test is the gate). - `a renderer hook replaces one test and receives the digest`; `a hook returning None keeps the default`; `the hook's imports are merged, deduplicated and sorted`. - `a model with no requirement emits nothing and warns nothing`. - `requirements the filter excludes produce one capped warning`; `warn_uncovered=False` silences it; `a filter keeps an L3 requirement the default drops, and it renders with unit *`; `requirementTests.filter in the config is imported as module:symbol and applied`. @@ -924,7 +924,7 @@ Generator args: `testPackage` (required), `witnessClass` (required, a fully-qual - [ ] **Step 1: Read first.** `GeneratorBase.java` (`getArg`, the output-directory args), one existing `codegen-base` generator that writes Java source and its test, `CodegenCompileConformanceTest.java` in `codegen-spring` (how generated Java is compiled in a test), and how `MetaDataGeneratorMojo` instantiates a generator class by name. Also read `maven-plugin`'s `MetaDataEjectMojo.java`, `EjectSupport.java` and `EjectRoundTripTest.java`, and `codegen-spring/pom.xml`'s eject `` list. Settled since the plan was written: the generator lives in `codegen-spring` (Table L). **UNVERIFIED:** how a renderer or filter class named in an arg can be loaded so that a class on the **project's** classpath is found. The mojo loads the generator with a project class loader whose parent is the plugin's, so a packaged generator's own loader does not see project classes; read `AbstractMetaDataMojo.buildGenerators` and `createProjectClassLoader`, and pass or set the loader rather than guess. No generator arg names a loadable class today, so this is new ground: stop and report if it cannot be done without changing how every generator is constructed; how the JUnit Jupiter version is managed in `server/java/pom.xml` (it is used today only by the Kotlin and integration modules). - [ ] **Step 2: Failing identity runner,** then implement `RequirementTestIdentities` from Tables F and G (`value.getBytes(StandardCharsets.UTF_8).length`, `MessageDigest` SHA-256, sort with `String.compareTo`). The runner maps the `filter` name of `options.json` to a `RequirementTestFilter` with a table of exactly the seven rows of Table J. Run: `mvn -q -f server/java/pom.xml -pl metadata test -Dtest=RequirementTestIdentityConformanceTest`. Expected: FAIL, then PASS for all 24 cases. -- [ ] **Step 3: Failing generator tests:** the worked example (the identity corpus's `worked-example` input) emits `Requirements_acme_shop_Witnesses.java` and `Requirements_acme_shop_Test.java` in `testPackage`; the interface has one default member per non-skipped test and none for a skipped one; a skipped test carries `@Disabled` with the reason of Table H; the output imports only `org.junit.jupiter.api.*`; **the output compiles** with `javax.tools` together with a hand-written witness class, and invoking the test method on the compiled class throws `AssertionError` whose message holds `unimplemented requirement:` and the counterexample when the witness class does not override it, and returns normally when it does; prose with `"`, `\`, a newline and `*/` still compiles; a collision throws `GeneratorException` naming `ERR_REQUIREMENT_WITNESS_KEY_COLLISION` and both ids; `grain=member`; a renderer class replaces one test and receives the digest; a `filter` class keeps an L3 requirement the default drops and it renders with unit `*`; the excluded requirements produce one capped warning and `warnUncovered=false` silences it; a `filter` class that cannot be loaded is a clear `GeneratorException`; a missing `witnessClass` arg is a clear `GeneratorException`; a model with no requirement writes nothing; `stale file` as in Task 6. **Eject:** `requirement-tests` is in `EjectedGeneratorsCompileTest`'s list and compiles package-renamed against the published API; the round-trip test ejects it, compiles the unchanged copy and gets byte-identical output over the `worked-example` model with non-default `grain` and `testPackage` args; `EjectSupportTest` still passes with its hard-coded non-ejectable set unchanged. +- [ ] **Step 3: Failing generator tests:** the worked example (the identity corpus's `worked-example` input) emits `Requirements_acme_shop_Witnesses.java` and `Requirements_acme_shop_Test.java` in `testPackage`; the interface has one default member per non-skipped test and none for a skipped one; a skipped test carries `@Disabled` with the reason of Table H; the output imports only `org.junit.jupiter.api.*`; **the output compiles** with `javax.tools` together with a hand-written witness class, and invoking the test method on the compiled class throws `AssertionError` whose message holds `unimplemented requirement:` and the counterexample when the witness class does not override it, and returns normally when it does; prose with `"`, `\`, a newline and `*/` still compiles; a collision throws `GeneratorException` naming `ERR_REQUIREMENT_WITNESS_KEY_COLLISION` and both ids; `grain=member`; an unknown `grain` is refused with a clear error (no corpus pins this, so the test is the gate); a renderer class replaces one test and receives the digest; a `filter` class keeps an L3 requirement the default drops and it renders with unit `*`; the excluded requirements produce one capped warning and `warnUncovered=false` silences it; a `filter` class that cannot be loaded is a clear `GeneratorException`; a missing `witnessClass` arg is a clear `GeneratorException`; a model with no requirement writes nothing; `stale file` as in Task 6. **Eject:** `requirement-tests` is in `EjectedGeneratorsCompileTest`'s list and compiles package-renamed against the published API; the round-trip test ejects it, compiles the unchanged copy and gets byte-identical output over the `worked-example` model with non-default `grain` and `testPackage` args; `EjectSupportTest` still passes with its hard-coded non-ejectable set unchanged. - [ ] **Step 4: Implement** per Table H. The test class holds `private final Requirements__Witnesses witnesses = new ();`. Register `requirement-tests` with `Tier.NATIVE`, `Layer.CAPABILITY` (the enum's capability constant; read its name) and `ejectPath(JUnitRequirementTestsGenerator.class)`, add the `` to `codegen-spring/pom.xml`, and name the generator in `docs/ports/java.md`. Nothing in the generated header may carry the generator's own class or package name: the ejected copy has a different one, and its output must be byte-identical. - [ ] **Step 5: Run** `mvn -q -f server/java/pom.xml -pl metadata,codegen-base,codegen-spring,maven-plugin -am test -Dtest='RequirementTestIdentityConformanceTest,JUnitRequirementTestsGeneratorTest,GeneratorRegistryConformanceTest,CodegenCompileConformanceTest,EjectedGeneratorsCompileTest,EjectSupportTest,EjectRoundTripTest,MetaDataEjectMojoTest'` plus the new round-trip test by name (add `-Dsurefire.failIfNoSpecifiedTests=false` if a module holds none of them). Expected: PASS. - [ ] **Step 6: Add the Java row** to the identity corpus README. @@ -967,7 +967,7 @@ Generator args: `testPackage` (required), `witnessClass` (required, a fully-qual - [ ] **Step 1: Read first.** `Generator.cs` (`IGenerator`, `GenContext`), an existing generator and its registry entry, `CodegenCompileConformanceTests.cs` (the Roslyn compile helper), `MetaObjects.Cli/GenCommand.cs` and `OwnedCopy.cs`. **UNVERIFIED:** how a per-generator option (`testNamespace`, `witnessClass`, `grain`, a renderer, a filter, `warnUncovered`) reaches a C# generator from the CLI or a config file; decide from the code, keep the six names of Table I, and state the surface in the commit. Known since the plan was written: no per-generator option channel exists (`GenConfig` is global to the run, `.metaobjects/config.json` carries `sources` and `libraries` only), an owned `codegen/Program.cs` constructs generators itself (`new X { … }`), and the packaged tool cannot load a project's classes. The surface that follows from that: **public init properties on `RequirementTestsGenerator`**, with defaults that make the packaged `dotnet meta gen --generators requirement-tests` run work with no option at all (derive the test namespace and the witness class name from `GenConfig.Namespace`), and the project sets any of the six in its `codegen/Program.cs`. Do not add keys to `.metaobjects/config.json`: it is the neutral config every port reads. Also read `EjectableGenerators.cs` (`RewriteForEject` needs the exact line `namespace MetaObjects.Codegen.Generators;`, and the file may use nothing `internal`), `EjectCommand.cs`, `EjectedGeneratorCompileTests.cs` and `EjectableGeneratorsTests.cs`. **UNVERIFIED:** that `Xunit.Sdk.XunitException(string)` is usable from generated code under xUnit 2.9.2; if not, throw `System.InvalidOperationException` and say so in Table H when updating the docs. - [ ] **Step 2: Failing identity runner,** the runner mapping the `filter` name of `options.json` to an `IRequirementTestFilter` with a table of exactly the seven rows of Table J; then implement `RequirementTestIdentities` (`Encoding.UTF8.GetByteCount`, `SHA256.HashData`, lower-case hex, `StringComparer.Ordinal`). Expected: PASS for all 24 cases. -- [ ] **Step 3: Failing generator tests,** the C# equivalents of Task 8 Step 3: the two files per package; interface members only for non-skipped tests; `[Fact(Skip = "…")]`; only `Xunit` imported; **the output compiles** with Roslyn beside a hand-written witness class, and invoking the test method throws with the unimplemented message when the member is not implemented and returns when it is; the escaping case (`"`, `\`, a newline, `*/`); the collision refusal; member grain; the renderer hook; a filter keeps an L3 requirement the default drops; the capped uncovered warning and its switch; no requirements writes nothing; `stale file`. **Eject:** the embedded copy is byte-identical to `Generators/RequirementTestsGenerator.cs` and the embedded set still equals the registry's ejectable set (`EjectableGeneratorsTests`); the rewritten copy compiles with Roslyn against the public API only and, with non-default `Grain` and `TestNamespace`, emits the same bytes as the packaged generator over the `worked-example` model. +- [ ] **Step 3: Failing generator tests,** the C# equivalents of Task 8 Step 3: the two files per package; interface members only for non-skipped tests; `[Fact(Skip = "…")]`; only `Xunit` imported; **the output compiles** with Roslyn beside a hand-written witness class, and invoking the test method throws with the unimplemented message when the member is not implemented and returns when it is; the escaping case (`"`, `\`, a newline, `*/`); the collision refusal; member grain; an unknown grain refused with a clear error; the renderer hook; a filter keeps an L3 requirement the default drops; the capped uncovered warning and its switch; no requirements writes nothing; `stale file`. **Eject:** the embedded copy is byte-identical to `Generators/RequirementTestsGenerator.cs` and the embedded set still equals the registry's ejectable set (`EjectableGeneratorsTests`); the rewritten copy compiles with Roslyn against the public API only and, with non-default `Grain` and `TestNamespace`, emits the same bytes as the packaged generator over the `worked-example` model. - [ ] **Step 4: Implement** per Table H, in one file that holds the default rendering (Table L), and register `requirement-tests` (`Tier` native, `Layer` capability, `SourceFileName = "RequirementTestsGenerator.cs"`) with its `` item. - [ ] **Step 5: Run** `dotnet test server/csharp --filter "RequirementTestIdentityConformance|RequirementTestsGenerator|GeneratorRegistryConformance|CodegenCompileConformance|EjectableGenerators|EjectedGeneratorCompile|EjectEndToEnd"`. Expected: PASS. - [ ] **Step 6: Add the C# row** to the identity corpus README. @@ -986,7 +986,7 @@ Generator args: `testPackage` (required), `witnessClass` (required, a fully-qual - Consumes: `RequirementTestIdentities`, `RequirementTestRenderer`, `RequirementTestArgs`, `RenderedTest` (Task 8). Same six generator args as Java, the `filter` arg naming a `RequirementTestFilter` class. - [ ] **Step 1: Read first.** `KotlinNamesGenerator.kt` (a small generator and how it writes), `KotlinGenUtil.kt` (string escaping helpers, including `$`), `CodegenCompileConformanceTest.kt` and `KotlinSpringControllerGeneratorTest.kt` (kotlin-compile-testing). **UNVERIFIED:** that `codegen-kotlin` can see `codegen-base`'s `generator.requirement` types. -- [ ] **Step 2: Failing tests:** the worked example emits `Requirements_acme_shop_Witnesses.kt` (an interface whose members have default bodies) and `Requirements_acme_shop_Test.kt`; the emitted test function names equal the `witnessKey` values of the identity corpus's `worked-example` and `concern-fanout` cases (this is how Kotlin asserts the identity contract; the identity function itself is the JVM one Task 8 gates); skipped tests carry `@Disabled`; **the output compiles** with a hand-written witness class and behaves as in Task 8 when invoked; a counterexample containing `$name`, `"`, `\` and a newline compiles and survives into the message; the collision refusal; member grain; the renderer hook; a `filter` class keeps an L3 requirement the default drops; the capped uncovered warning and its switch; no requirements writes nothing. **Eject:** `KotlinRequirementTestsGenerator` is in `ejectableSimpleNames` and compiles package-renamed; and, new for this port, the package-renamed copy is instantiated from the compiled result, run over the `worked-example` model with non-default `grain` and `testPackage`, and its output is byte-identical to the packaged generator's. +- [ ] **Step 2: Failing tests:** the worked example emits `Requirements_acme_shop_Witnesses.kt` (an interface whose members have default bodies) and `Requirements_acme_shop_Test.kt`; the emitted test function names equal the `witnessKey` values of the identity corpus's `worked-example` and `concern-fanout` cases (this is how Kotlin asserts the identity contract; the identity function itself is the JVM one Task 8 gates); skipped tests carry `@Disabled`; **the output compiles** with a hand-written witness class and behaves as in Task 8 when invoked; a counterexample containing `$name`, `"`, `\` and a newline compiles and survives into the message; the collision refusal; member grain; an unknown grain refused with a clear error; the renderer hook; a `filter` class keeps an L3 requirement the default drops; the capped uncovered warning and its switch; no requirements writes nothing. **Eject:** `KotlinRequirementTestsGenerator` is in `ejectableSimpleNames` and compiles package-renamed; and, new for this port, the package-renamed copy is instantiated from the compiled result, run over the `worked-example` model with non-default `grain` and `testPackage`, and its output is byte-identical to the packaged generator's. - [ ] **Step 3: Implement** per Table H, in one file that holds the default rendering (Table L), and register `requirement-tests` with `ejectResourcePath = ejectPath("KotlinRequirementTestsGenerator")` and the `` in `codegen-kotlin/pom.xml`. - [ ] **Step 4: Run** the `codegen-kotlin` unit suite for the new test, `GeneratorRegistryConformanceTest`, `CodegenCompileConformanceTest`, and the Exposed 1.x check module (`codegen-kotlin-exposed1x-check`) since it re-runs the Kotlin generators. Expected: PASS. - [ ] **Step 5: Add the Kotlin row** to the identity corpus README ("identity function inherits via Java; emitted names asserted by `KotlinRequirementTestsGeneratorTest`"). From be764b85ca99edf9490631e2f1cafd37bd5e30e6 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 6 Oct 2026 00:02:53 -0400 Subject: [PATCH 11/43] test(conformance): requirement-test identity corpus, run by the TypeScript reference 24 cases under fixtures/requirement-test-identity-conformance pin what may not differ between the ports' requirement-test generators: which tests a ledger yields, each test's id, witness key, skip state and claim digest, and the requirement view a project's filter is shown, through seven named predicates each port's runner implements. Expectations are written by scripts/write-requirement-corpus-expected.ts identity and were recomputed independently from the contract; docs/CONFORMANCE.md gains the corpus's row and the check corpus's current count (43), and AGENTS.md and the site payload the true corpora count (28). Refs docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md and ADR-0057. --- AGENTS.md | 2 +- docs/CONFORMANCE.md | 5 +- examples/showcase/site-payload.json | 2 +- .../README.md | 194 ++++++++++++++++++ .../concern-fanout/expected.json | 25 +++ .../concern-fanout/input/meta.shop.yaml | 34 +++ .../default-filter/expected.json | 25 +++ .../default-filter/input/meta.shop.yaml | 64 ++++++ .../digest-claim-fields/expected.json | 35 ++++ .../digest-claim-fields/input/meta.shop.yaml | 40 ++++ .../digest-inherited/expected.json | 35 ++++ .../digest-inherited/input/meta.shop.yaml | 32 +++ .../digest-multibyte-and-crlf/expected.json | 15 ++ .../input/meta.shop.yaml | 19 ++ .../filter-all/expected.json | 65 ++++++ .../filter-all/input/meta.shop.yaml | 53 +++++ .../filter-all/options.json | 3 + .../filter-by-claimed-concern/expected.json | 45 ++++ .../input/meta.shop.yaml | 63 ++++++ .../filter-by-claimed-concern/options.json | 3 + .../filter-by-level/expected.json | 35 ++++ .../filter-by-level/input/meta.shop.yaml | 49 +++++ .../filter-by-level/options.json | 3 + .../filter-by-package/expected.json | 35 ++++ .../filter-by-package/input/meta.billing.yaml | 28 +++ .../filter-by-package/input/meta.shop.yaml | 34 +++ .../filter-by-package/options.json | 3 + .../filter-by-path/expected.json | 35 ++++ .../filter-by-path/input/meta.shop.yaml | 52 +++++ .../filter-by-path/options.json | 3 + .../filter-by-status/expected.json | 25 +++ .../filter-by-status/input/meta.shop.yaml | 37 ++++ .../filter-by-status/options.json | 3 + .../filter-by-subtype/expected.json | 25 +++ .../filter-by-subtype/input/meta.shop.yaml | 42 ++++ .../filter-by-subtype/options.json | 3 + .../member-grain-duplicate-ref/expected.json | 25 +++ .../input/meta.shop.yaml | 17 ++ .../member-grain-duplicate-ref/options.json | 3 + .../expected.json | 35 ++++ .../input/meta.shop.yaml | 37 ++++ .../options.json | 3 + .../member-grain/expected.json | 45 ++++ .../member-grain/input/meta.shop.yaml | 33 +++ .../member-grain/options.json | 3 + .../nested-path/expected.json | 45 ++++ .../nested-path/input/meta.shop.yaml | 83 ++++++++ .../no-targets/expected.json | 15 ++ .../no-targets/input/meta.shop.yaml | 16 ++ .../package-in-address/expected.json | 25 +++ .../input/meta.billing.yaml | 23 +++ .../package-in-address/input/meta.shop.yaml | 23 +++ .../partial-not-skipped/expected.json | 15 ++ .../partial-not-skipped/input/meta.shop.yaml | 16 ++ .../planned-skip/expected.json | 25 +++ .../planned-skip/input/meta.shop.yaml | 26 +++ .../retired-skip/expected.json | 15 ++ .../retired-skip/input/meta.shop.yaml | 16 ++ .../unpackaged/expected.json | 25 +++ .../unpackaged/input/meta.shop.yaml | 29 +++ .../witness-key-collision/expected.json | 30 +++ .../input/meta.shop.yaml | 33 +++ .../worked-example/expected.json | 25 +++ .../worked-example/input/meta.shop.yaml | 30 +++ scripts/write-requirement-corpus-expected.ts | 95 ++++++++- ...uirement-test-identity-conformance.test.ts | 135 ++++++++++++ 66 files changed, 2111 insertions(+), 6 deletions(-) create mode 100644 fixtures/requirement-test-identity-conformance/README.md create mode 100644 fixtures/requirement-test-identity-conformance/concern-fanout/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/concern-fanout/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/default-filter/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/default-filter/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/digest-claim-fields/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/digest-claim-fields/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/digest-inherited/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/digest-inherited/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/digest-multibyte-and-crlf/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/digest-multibyte-and-crlf/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/filter-all/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/filter-all/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/filter-all/options.json create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-claimed-concern/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-claimed-concern/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-claimed-concern/options.json create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-level/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-level/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-level/options.json create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-package/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-package/input/meta.billing.yaml create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-package/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-package/options.json create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-path/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-path/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-path/options.json create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-status/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-status/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-status/options.json create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-subtype/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-subtype/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-subtype/options.json create mode 100644 fixtures/requirement-test-identity-conformance/member-grain-duplicate-ref/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/member-grain-duplicate-ref/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/member-grain-duplicate-ref/options.json create mode 100644 fixtures/requirement-test-identity-conformance/member-grain-unresolved-dropped/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/member-grain-unresolved-dropped/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/member-grain-unresolved-dropped/options.json create mode 100644 fixtures/requirement-test-identity-conformance/member-grain/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/member-grain/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/member-grain/options.json create mode 100644 fixtures/requirement-test-identity-conformance/nested-path/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/nested-path/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/no-targets/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/no-targets/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/package-in-address/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/package-in-address/input/meta.billing.yaml create mode 100644 fixtures/requirement-test-identity-conformance/package-in-address/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/partial-not-skipped/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/partial-not-skipped/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/planned-skip/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/planned-skip/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/retired-skip/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/retired-skip/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/unpackaged/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/unpackaged/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/witness-key-collision/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/witness-key-collision/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/worked-example/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/worked-example/input/meta.shop.yaml create mode 100644 server/typescript/packages/codegen-ts/test/requirement-test-identity-conformance.test.ts diff --git a/AGENTS.md b/AGENTS.md index 842c21e8b..cf60721f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken. - **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres. **Cross-port conformance corpora** (every port runs the shared corpus): -- Metamodel: `fixtures/conformance/` (364 fixtures; 26 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. +- Metamodel: `fixtures/conformance/` (364 fixtures; 28 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. - Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical. - Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".) - API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed. Three sub-corpora run the **generated lane only, on all five ports** — `write-through/`, `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`) and `report/` (FR-044: a view-backed `object.report` serves GET list, answers `POST` with that same `405`, and mounts no `/{id}` route). That is deliberate, not a gap: what is under test is whether a port's GENERATOR emits those routes, and a hand-rolled reference server would answer every scenario by construction. The `m2m/` sub-corpus also gates **TPH x M:N together** (base-declared, subtype-declared, abstract-mid-declared, a non-subtype source onto a subtype TARGET, and the cross-subtype source id answering `200 []`) — the two corpora were originally built disjoint (`tph/` had no relationships, `m2m/` no discriminators), which is precisely how that defect class survived. diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 06b72372c..4b91e80d6 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -1,6 +1,6 @@ # Conformance coverage -The MetaObjects standard ships **27 shared conformance corpora** under +The MetaObjects standard ships **28 shared conformance corpora** under [`fixtures/`](../fixtures/). Every port runs every corpus that is *applicable to it* and asserts the same expected behaviour against the same fixtures. **This page is the inverse index**: fixture → feature doc + per-port pass status, and it is the @@ -50,7 +50,8 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/metamodel-docs/`](../fixtures/metamodel-docs/) | 1 | ✓ (docs emit is TS-owned) | — | — | — | — | | [`fixtures/fmt-conformance/`](../fixtures/fmt-conformance/) (#304 — `meta fmt`) | 12 | ✓ (reference) | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/field-lint-conformance/`](../fixtures/field-lint-conformance/) (the `verify` field authoring lint) | 15 | ✓ (reference) | ✓ | inherits via Java | ✓ | ✓ | -| [`fixtures/requirement-check-conformance/`](../fixtures/requirement-check-conformance/) (the `verify` requirement gate, ADR-0057) | 42 cases | ✓ (reference) | — (not yet) | — (not yet) | — (not yet) | — (not yet) | +| [`fixtures/requirement-check-conformance/`](../fixtures/requirement-check-conformance/) (the `verify` requirement gate, ADR-0057) | 43 cases | ✓ (reference) | — (not yet) | — (not yet) | — (not yet) | — (not yet) | +| [`fixtures/requirement-test-identity-conformance/`](../fixtures/requirement-test-identity-conformance/) (the identity, skip state and digest of each generated requirement test, and the filter seam, ADR-0057) | 24 cases | ✓ (reference) | — (not yet) | — (not yet) | — (not yet) | — (not yet) | | [`fixtures/naming-conformance/`](../fixtures/naming-conformance/) | 8 cases | ✓ | ✓ | inherits via Java (`RouteNaming.pluralize`) | ✓ | ✓ | | [`fixtures/codegen-noop/`](../fixtures/codegen-noop/) (FR-044 — reporting vocabulary: what is lowered, what stays inert) | 1 model pair (`reporting/with` vs `reporting/without`) | ✓ (codegen + migrate) | ✓ | ✓ | ✓ | ✓ | diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index fa3dfae22..e84e9bead 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -8,7 +8,7 @@ }, "counts": { "fixtures": 364, - "corpora": 27, + "corpora": 28, "baseTypes": 17 }, "snippets": { diff --git a/fixtures/requirement-test-identity-conformance/README.md b/fixtures/requirement-test-identity-conformance/README.md new file mode 100644 index 000000000..a054195e4 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/README.md @@ -0,0 +1,194 @@ +# Requirement-test identity conformance corpus + +Every port generates one test per requirement, in its own language and its own test +framework (ADR-0057). The generated files differ between ports and are meant to. This +corpus pins what must not differ: **which** tests a ledger yields, what each one is +**called**, whether it is **skipped**, the **digest** of the claim it tests, and what a +project's **filter** is shown when it chooses which requirements get a test. + +TypeScript is the reference. The identity function is `requirementTestIdentities` in +`server/typescript/packages/codegen-ts/src/requirement-walk.ts`; a port reads that file +before it writes its own, and its test suite runs this corpus so the ports cannot drift. + +## Fixture format + +Each case is a directory: + +- `input/` holds one or more metadata documents (`.yaml`). +- `options.json` is optional. Its keys are `grain` (`"concern"` or `"member"`) and + `filter` (the **name** of one predicate from the list below). No other key is legal. +- `expected.json` holds `{ "tests": [...], "collisions": [...] }`. + +A runner does three things, in this order: + +1. Load `input/` with the port's loader, **strict**, and assert **no load errors**. Load + the files in ascending file-name order, as the requirement-check corpus does. No + expectation here is known to depend on that order; the rule is fixed so that none can + come to. +2. Compute the test identities through the port's **public** generator seams: the grain + option when `options.json` sets `grain`, and the filter option when it sets `filter`. + An option the case does not set is not passed, so the port's own default applies. Then + compute the witness-key collisions among those identities. +3. Compare both with `expected.json`. + +### The named filters + +A predicate cannot be written in a file five languages read. So a case names one, and +each port's runner implements this closed list in its own language and hands the predicate +to the port's filter seam. A name outside the list fails the case. + +The predicate receives the **requirement view**: `subType`, `level`, `status`, `path`, +`package` and `implementedByTypes`. Between them the seven filters read every field. + +| `filter` | The predicate over the requirement view | +|---|---| +| `all` | always true | +| `architectural` | `subType` is `architectural` | +| `live` | `status` is `live` | +| `level-5` | `level` is `5` | +| `package-acme-shop` | `package` is `acme::shop` | +| `path-under-Shop` | `path` is `Shop` or starts with `Shop.` | +| `claims-entity` | `implementedByTypes` contains `object.entity` | + +- `level` is **absent** on an architectural requirement that declares none. Absent is not + `5` and not `0`: the `level-5` predicate must answer false for it without failing. +- `path` is the dotted chain of requirement names from the root, with no package. +- `package` is the requirement's **effective** package: the one the node declares, else + its file's, else the empty string. +- `implementedByTypes` is the distinct `.` of the references that + **resolve**, in first-seen order. A reference that does not resolve contributes nothing. + +With no `filter`, the port's default applies: a requirement gets a test when it is +`functional` and its `level` is 4 or 5. + +### What is compared + +`tests` is compared after sorting both sides by `id`. Compare code units, not a locale +collation; every `id` in this corpus is ASCII. Each record is one generated test: + +| Field | Definition | +|---|---| +| `package` | The requirement's effective package. `""` when it has none. | +| `path` | The dotted chain of requirement names from the root. | +| `unit` | What this one test stands for. Under `grain: concern` (the default), one test per **distinct** `.` among the requirement's resolved references. Under `grain: member`, one test per **distinct** reference that resolves, spelt exactly as authored. In both grains a requirement with no resolved reference yields exactly one test, with unit `*`. | +| `id` | `
[]`, where the address is `::`, or the bare path when the package is `""`. | +| `witnessKey` | `req_` + mangle(address), then `__` + mangle(unit) unless the unit is `*`. mangle replaces each maximal run of characters outside `[A-Za-z0-9]` with one `_`. | +| `status` | The requirement's status. | +| `skip` | `null` when the status is `live` or `partial`. Otherwise the status: `planned` or `retired`. | +| `digest` | The requirement digest, below. The same for every test of one requirement. | + +`collisions` is a list of `[id, id]` pairs: two tests with one `witnessKey`. Sort each +pair, then the list, before comparing. + +### The requirement digest + +`requirement-digest/v1`: the lowercase hex SHA-256 of the UTF-8 bytes of this text, where +`\n` is one line feed and every line ends with one. + +```text +requirement-digest/v1\n +subType \n\n +level \n\n +status \n\n +statement \n\n +counterexample \n\n +implementedBy \n +ref \n\n (one per implementedBy entry, in authored order) +``` + +- `` is the length in **bytes** of the value's UTF-8 encoding. `` is the number + of `implementedBy` entries. +- `level` is the decimal integer, or the empty value (`level 0\n\n`) when absent. +- Every value is the **effective** one: a value reached through `extends` is hashed as if + it had been written on the node. +- In `statement` and `counterexample`, `\r\n` and a lone `\r` each become `\n` before the + value is measured. +- `implementedBy` is hashed as authored: every entry, resolved or not, duplicates included. +- Nothing else is in the digest. Not the name, the path, the package, `title`, + `description`, `notes`, `disposition`, `trackedBy`, `supersededBy`, or any nested + requirement. + +The worked example hashes + +```text +requirement-digest/v1\nsubType 10\nfunctional\nlevel 1\n4\nstatus 4\nlive\nstatement 39\nAn order is recorded when it is placed.\ncounterexample 26\nA placed order has no row.\nimplementedBy 1\nref 5\nOrder\n +``` + +to `2714aa3925a47959aa5e48ae39d80ed203fd4e2caa046a90aab9e04691d9881a`. Passing that text +to `printf` and piping it into `sha256sum` reproduces it. + +### Where the expectations come from + +`bun scripts/write-requirement-corpus-expected.ts identity` writes every `expected.json` +from the TypeScript reference. Each written file is then reviewed by hand against the +definitions above: the set of units, each `witnessKey` by applying the mangle rule, each +`skip` against the status, and the digests against each other. A case that yields more +than its row below says gets its **input** fixed. + +A committed `expected.json` is never edited to make a port pass. A port that disagrees with +it is wrong, unless the reference is shown to be wrong first. + +## Things the cases pin that are easy to miss + +- **A filter replaces the default. It does not narrow it.** Every `filter-*` case keeps at + least one requirement the default would drop, so a port that applies the project's + predicate on top of its own default fails all seven. A port that ignores the option + fails all seven too. +- **A filter chooses requirements, not tests.** In `filter-by-claimed-concern` the + requirement that claims a template and an entity is kept, and its `template.prompt` + test is kept with it. +- **Under `grain: concern`, distinct means distinct, not adjacent.** `concern-fanout` + lists an entity, a template and a second entity, and yields two tests. +- **A reference to a missing member does not fall back to its object.** + `member-grain-unresolved-dropped` names a member that an existing object does not + have. That reference yields no test. +- **The digest is over the claim, so equal claims have equal digests wherever they sit.** + The worked example's `Recorded` claim is reused under other names and paths, in another + package and in no package, and its digest is `2714aa39…` every time. +- **The digest hashes what was authored, the unit is what resolved.** In `planned-skip` + the reference `Refund` resolves to nothing, so the unit is `*`, and it is still in the + digest. In `member-grain-duplicate-ref` three entries are hashed and two tests come out. +- **`unpackaged` is one document on purpose.** What a document without a package takes as + its default when it is loaded beside a packaged one is a loader question, and this + corpus does not ask it. +- **The carriage returns in `digest-multibyte-and-crlf` are written as YAML escapes** + (`"\r\n"`, `"\r"`), so the value does not depend on the line endings of the file. + +What the corpus does **not** pin: the text of a generated test file; the warning that +names the requirements a filter excluded; a requirement name outside ASCII; and whether an +`abstract` requirement gets a test (no case declares one). + +## Cases + +| Case | What it pins | +|---|---| +| `default-filter` | With no `filter`, an L1 to L5 tree and two architectural policies yield two tests: the functional L4 and the functional L5. The L1 to L3 nodes get none, and neither does a policy, including one that declares level 4. | +| `worked-example` | The reference model, which every port's generator test also renders: `Orders.Recorded` (functional, level 4, live, claiming the entity `Order`) and `Orders.Refunded` (functional, level 4, planned, no links), in `acme::shop`, under an L3 parent that gets no test. Both digests are pinned values: `2714aa39…` and `4ddcd781…`. | +| `concern-fanout` | One requirement claiming two entities and a template yields two tests, `object.entity` and `template.prompt`, with one digest. | +| `no-targets` | A live L4 with no `implementedBy` yields one test with unit `*`. Its key has no unit suffix. | +| `planned-skip` | `skip` is `planned`. A planned requirement naming a node that exists keeps that node's concern as its unit; one naming only a node that does not exist has unit `*`. | +| `retired-skip` | `skip` is `retired`, and the unit is `*`: the loader refuses `implementedBy` on a retired requirement. | +| `partial-not-skipped` | A `partial` requirement is not skipped: `skip` is `null` and `status` is `partial`. | +| `member-grain` | `grain: member`: one test per reference. Two entities, which are one concern, are two tests. A bare reference and a package-qualified one each keep the spelling they were authored with, in the unit and in the key, for an object and for a member. | +| `member-grain-unresolved-dropped` | `grain: member`: a reference that does not resolve yields no test, whether its object is missing or only its member is. A requirement none of whose references resolve yields one test with unit `*`. | +| `member-grain-duplicate-ref` | `grain: member`: an entity named three times in two spellings yields two tests, one per distinct spelling. | +| `package-in-address` | The same path, with the same claim, in two packages: two ids and two keys, one digest. A bare reference binds in each requirement's own package. | +| `unpackaged` | A document with no package: `package` is `""`, the address is the bare path, and the key starts `req_Orders_`. The digests are the worked example's. | +| `nested-path` | A five-deep path. Two branches end in the same three names (`Orders.Recorded.Quotable`), and their tests are told apart by the rest of the path. | +| `digest-claim-fields` | Two requirements that differ only in name, `title`, `description`, `notes`, `disposition` and `trackedBy` have one digest. A third that differs from the first by one word of the statement has another. | +| `digest-inherited` | A requirement that declares only a counterexample and reaches its level, status, statement and `implementedBy` through `extends` has the digest of the same claim written out in full, and the unit its inherited reference resolves to. | +| `digest-multibyte-and-crlf` | A statement holding two-byte and three-byte characters (55 characters, 78 bytes), and a counterexample holding a CR LF pair and a lone CR (64 bytes as authored, 63 as hashed). | +| `witness-key-collision` | `Orders.Recorded` beside `Orders_Recorded`: two ids, one key, and one entry in `collisions`. | +| `filter-all` | `filter: all` over an L1 to L5 tree and one flat policy: all six requirements get a test, the L1 to L3 nodes with unit `*`. | +| `filter-by-subtype` | `filter: architectural`: a flat policy and a levelled one get tests, and the functional L4 and L5 get none. Pins `subType`, and that a view with no `level` reaches a predicate. | +| `filter-by-status` | `filter: live`: a `partial` and a `planned` L4 are dropped, and their live L3 parent is kept beside the live L4. Pins `status`. | +| `filter-by-level` | `filter: level-5`: two functional L5 nodes and a levelled L5 policy get tests. Their L4 parent gets none, and neither does a flat policy, whose `level` is absent. Pins `level`. | +| `filter-by-package` | `filter: package-acme-shop` over two files: a root requirement and a nested one take their file's package, and one in each file declares the other file's package. The kept set follows the effective package, not the file. Pins `package`. | +| `filter-by-path` | `filter: path-under-Shop`: `Shop` and both its descendants are kept. `Shopfront` and its child are not. Pins `path` as the whole dotted chain, without the package. | +| `filter-by-claimed-concern` | `filter: claims-entity`: requirements claiming an entity are kept, a flat policy among them. One claiming only a template and one naming only a node that does not exist are dropped. Pins `implementedByTypes` as the concerns that resolved. | + +## Who asserts it + +| Port | Runner | +|---|---| +| TypeScript (reference) | `server/typescript/packages/codegen-ts/test/requirement-test-identity-conformance.test.ts` | diff --git a/fixtures/requirement-test-identity-conformance/concern-fanout/expected.json b/fixtures/requirement-test-identity-conformance/concern-fanout/expected.json new file mode 100644 index 000000000..32b77bb16 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/concern-fanout/expected.json @@ -0,0 +1,25 @@ +{ + "tests": [ + { + "id": "acme::shop::Confirmed [object.entity]", + "package": "acme::shop", + "path": "Confirmed", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Confirmed__object_entity", + "status": "live", + "skip": null, + "digest": "7432d838dbd4601ec8735162f497a2cc9c100440af803568c974a88b7a85bf3c" + }, + { + "id": "acme::shop::Confirmed [template.prompt]", + "package": "acme::shop", + "path": "Confirmed", + "unit": "template.prompt", + "witnessKey": "req_acme_shop_Confirmed__template_prompt", + "status": "live", + "skip": null, + "digest": "7432d838dbd4601ec8735162f497a2cc9c100440af803568c974a88b7a85bf3c" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/concern-fanout/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/concern-fanout/input/meta.shop.yaml new file mode 100644 index 000000000..b0a3d10c8 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/concern-fanout/input/meta.shop.yaml @@ -0,0 +1,34 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - object.entity: + name: Customer + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - object.value: + name: OrderConfirmationPayload + children: + - field.string: { name: reference } + + - template.prompt: + name: orderConfirmation + payloadRef: OrderConfirmationPayload + textRef: orders/confirmation + format: xml + + # Three references, two concerns. The entities are not adjacent in the list. + - requirement.functional: + name: Confirmed + level: 4 + status: live + statement: A customer is told their order was placed. + counterexample: A placed order nobody confirmed. + implementedBy: [Order, orderConfirmation, Customer] diff --git a/fixtures/requirement-test-identity-conformance/default-filter/expected.json b/fixtures/requirement-test-identity-conformance/default-filter/expected.json new file mode 100644 index 000000000..cece5683b --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/default-filter/expected.json @@ -0,0 +1,25 @@ +{ + "tests": [ + { + "id": "acme::shop::Shop.Sales.Orders.Recorded [object.entity]", + "package": "acme::shop", + "path": "Shop.Sales.Orders.Recorded", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Shop_Sales_Orders_Recorded__object_entity", + "status": "live", + "skip": null, + "digest": "2714aa3925a47959aa5e48ae39d80ed203fd4e2caa046a90aab9e04691d9881a" + }, + { + "id": "acme::shop::Shop.Sales.Orders.Recorded.Quotable [field.string]", + "package": "acme::shop", + "path": "Shop.Sales.Orders.Recorded.Quotable", + "unit": "field.string", + "witnessKey": "req_acme_shop_Shop_Sales_Orders_Recorded_Quotable__field_string", + "status": "live", + "skip": null, + "digest": "b5d7fb7a9657bfda7b30e2629617bf5c56e4337ef7a06abfe55715613ecdda63" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/default-filter/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/default-filter/input/meta.shop.yaml new file mode 100644 index 000000000..da1efb432 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/default-filter/input/meta.shop.yaml @@ -0,0 +1,64 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + # L1 to L3 are organisational: no test. L4 and L5 each get one. + - requirement.functional: + name: Shop + level: 1 + status: live + statement: A customer can buy from the shop. + counterexample: A customer who cannot place an order. + children: + - requirement.functional: + name: Sales + level: 2 + status: live + statement: Sales are taken through the shop. + counterexample: A sale made outside the shop. + children: + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + children: + - requirement.functional: + name: Quotable + level: 5 + status: live + statement: An order carries a reference the customer can quote. + counterexample: An order nobody can quote back. + implementedBy: [Order.reference] + + # A flat policy: architectural, so no test. + - requirement.architectural: + name: Identified + status: live + statement: Every row is addressed by a uuid. + counterexample: A row keyed by a name. + implementedBy: [Order] + + # A levelled policy at L4: the level alone does not earn a test. + - requirement.architectural: + name: Addressable + level: 4 + status: live + statement: An order is addressed by one stable key. + counterexample: An order that cannot be pointed at. + implementedBy: [Order] diff --git a/fixtures/requirement-test-identity-conformance/digest-claim-fields/expected.json b/fixtures/requirement-test-identity-conformance/digest-claim-fields/expected.json new file mode 100644 index 000000000..e1dec2d4f --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/digest-claim-fields/expected.json @@ -0,0 +1,35 @@ +{ + "tests": [ + { + "id": "acme::shop::Annotated [object.entity]", + "package": "acme::shop", + "path": "Annotated", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Annotated__object_entity", + "status": "partial", + "skip": null, + "digest": "9c7f098dc9a7c74fd9c13cc705bf174c306a3259208abb2a7acca0d577c4e262" + }, + { + "id": "acme::shop::Recorded [object.entity]", + "package": "acme::shop", + "path": "Recorded", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Recorded__object_entity", + "status": "partial", + "skip": null, + "digest": "9c7f098dc9a7c74fd9c13cc705bf174c306a3259208abb2a7acca0d577c4e262" + }, + { + "id": "acme::shop::Reworded [object.entity]", + "package": "acme::shop", + "path": "Reworded", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Reworded__object_entity", + "status": "partial", + "skip": null, + "digest": "f3f4a5ffca04e3257f91c228e77603f35bb7ad638f19fda6d41aee15e6aa6b7a" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/digest-claim-fields/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/digest-claim-fields/input/meta.shop.yaml new file mode 100644 index 000000000..ec3d50e18 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/digest-claim-fields/input/meta.shop.yaml @@ -0,0 +1,40 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Recorded + level: 4 + status: partial + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + # The same claim under another name, with everything a ledger entry can carry + # that is not the claim. + - requirement.functional: + name: Annotated + level: 4 + status: partial + title: Orders are recorded + description: Covers orders placed through the shop. + notes: Orders placed by telephone are keyed in the next morning. + disposition: deferred + trackedBy: ["acme/shop#12"] + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + # The same entry as Recorded with one word of the statement changed. + - requirement.functional: + name: Reworded + level: 4 + status: partial + statement: An order is recorded when it is paid. + counterexample: A placed order has no row. + implementedBy: [Order] diff --git a/fixtures/requirement-test-identity-conformance/digest-inherited/expected.json b/fixtures/requirement-test-identity-conformance/digest-inherited/expected.json new file mode 100644 index 000000000..7b257dd76 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/digest-inherited/expected.json @@ -0,0 +1,35 @@ +{ + "tests": [ + { + "id": "acme::shop::Kept [object.entity]", + "package": "acme::shop", + "path": "Kept", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Kept__object_entity", + "status": "live", + "skip": null, + "digest": "f747d4c611a478cf73bc9b58ae7f477b9625483d42f1bc9b1282f385c3620e75" + }, + { + "id": "acme::shop::KeptInFull [object.entity]", + "package": "acme::shop", + "path": "KeptInFull", + "unit": "object.entity", + "witnessKey": "req_acme_shop_KeptInFull__object_entity", + "status": "live", + "skip": null, + "digest": "f747d4c611a478cf73bc9b58ae7f477b9625483d42f1bc9b1282f385c3620e75" + }, + { + "id": "acme::shop::Recorded [object.entity]", + "package": "acme::shop", + "path": "Recorded", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Recorded__object_entity", + "status": "live", + "skip": null, + "digest": "2714aa3925a47959aa5e48ae39d80ed203fd4e2caa046a90aab9e04691d9881a" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/digest-inherited/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/digest-inherited/input/meta.shop.yaml new file mode 100644 index 000000000..ba55e1f45 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/digest-inherited/input/meta.shop.yaml @@ -0,0 +1,32 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + # Declares one attribute. Its level, status, statement and implementedBy are + # Recorded's, reached through extends. + - requirement.functional: + name: Kept + extends: Recorded + counterexample: A placed order is gone the next morning. + + # What Kept effectively says, written out in full. + - requirement.functional: + name: KeptInFull + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order is gone the next morning. + implementedBy: [Order] diff --git a/fixtures/requirement-test-identity-conformance/digest-multibyte-and-crlf/expected.json b/fixtures/requirement-test-identity-conformance/digest-multibyte-and-crlf/expected.json new file mode 100644 index 000000000..39860f16c --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/digest-multibyte-and-crlf/expected.json @@ -0,0 +1,15 @@ +{ + "tests": [ + { + "id": "acme::shop::Recorded [object.entity]", + "package": "acme::shop", + "path": "Recorded", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Recorded__object_entity", + "status": "live", + "skip": null, + "digest": "da577d1fc2d81eb2d559787bb6db70c84faea754fa0c8089d5461bc5eacc0567" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/digest-multibyte-and-crlf/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/digest-multibyte-and-crlf/input/meta.shop.yaml new file mode 100644 index 000000000..2f0e60b9e --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/digest-multibyte-and-crlf/input/meta.shop.yaml @@ -0,0 +1,19 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + # The statement holds two-byte and three-byte characters. The counterexample holds + # a CR LF pair and a lone CR, written as escapes so that the value does not depend + # on this file's own line endings. + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: "Une commande passée au café est enregistrée — 注文は記録される。" + counterexample: "A placed order has no row.\r\nIts total reads ¥0.\rNobody is told." + implementedBy: [Order] diff --git a/fixtures/requirement-test-identity-conformance/filter-all/expected.json b/fixtures/requirement-test-identity-conformance/filter-all/expected.json new file mode 100644 index 000000000..f3d5c3d52 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-all/expected.json @@ -0,0 +1,65 @@ +{ + "tests": [ + { + "id": "acme::shop::Identified [object.entity]", + "package": "acme::shop", + "path": "Identified", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Identified__object_entity", + "status": "live", + "skip": null, + "digest": "1665da033b2ecf14f717530ac11fd4927dad2b573c1a48e9ed261135d951e410" + }, + { + "id": "acme::shop::Shop [*]", + "package": "acme::shop", + "path": "Shop", + "unit": "*", + "witnessKey": "req_acme_shop_Shop", + "status": "live", + "skip": null, + "digest": "edc8a9b8159eb03a08d020427683253d6d1471032e0cf48115956362fc121653" + }, + { + "id": "acme::shop::Shop.Sales [*]", + "package": "acme::shop", + "path": "Shop.Sales", + "unit": "*", + "witnessKey": "req_acme_shop_Shop_Sales", + "status": "live", + "skip": null, + "digest": "5af1c1f490460147c7f5680c62348910f3c90065703b0ec696b4250e102f68e0" + }, + { + "id": "acme::shop::Shop.Sales.Orders [*]", + "package": "acme::shop", + "path": "Shop.Sales.Orders", + "unit": "*", + "witnessKey": "req_acme_shop_Shop_Sales_Orders", + "status": "live", + "skip": null, + "digest": "e84c69fda54cb9f04f3949af069c2c5d56027e008be3198a08c4ee4fbcad8be6" + }, + { + "id": "acme::shop::Shop.Sales.Orders.Recorded [object.entity]", + "package": "acme::shop", + "path": "Shop.Sales.Orders.Recorded", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Shop_Sales_Orders_Recorded__object_entity", + "status": "live", + "skip": null, + "digest": "2714aa3925a47959aa5e48ae39d80ed203fd4e2caa046a90aab9e04691d9881a" + }, + { + "id": "acme::shop::Shop.Sales.Orders.Recorded.Quotable [field.string]", + "package": "acme::shop", + "path": "Shop.Sales.Orders.Recorded.Quotable", + "unit": "field.string", + "witnessKey": "req_acme_shop_Shop_Sales_Orders_Recorded_Quotable__field_string", + "status": "live", + "skip": null, + "digest": "b5d7fb7a9657bfda7b30e2629617bf5c56e4337ef7a06abfe55715613ecdda63" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/filter-all/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/filter-all/input/meta.shop.yaml new file mode 100644 index 000000000..cea5af793 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-all/input/meta.shop.yaml @@ -0,0 +1,53 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Shop + level: 1 + status: live + statement: A customer can buy from the shop. + counterexample: A customer who cannot place an order. + children: + - requirement.functional: + name: Sales + level: 2 + status: live + statement: Sales are taken through the shop. + counterexample: A sale made outside the shop. + children: + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + children: + - requirement.functional: + name: Quotable + level: 5 + status: live + statement: An order carries a reference the customer can quote. + counterexample: An order nobody can quote back. + implementedBy: [Order.reference] + + - requirement.architectural: + name: Identified + status: live + statement: Every row is addressed by a uuid. + counterexample: A row keyed by a name. + implementedBy: [Order] diff --git a/fixtures/requirement-test-identity-conformance/filter-all/options.json b/fixtures/requirement-test-identity-conformance/filter-all/options.json new file mode 100644 index 000000000..425a0186e --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-all/options.json @@ -0,0 +1,3 @@ +{ + "filter": "all" +} diff --git a/fixtures/requirement-test-identity-conformance/filter-by-claimed-concern/expected.json b/fixtures/requirement-test-identity-conformance/filter-by-claimed-concern/expected.json new file mode 100644 index 000000000..b5c5bdade --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-claimed-concern/expected.json @@ -0,0 +1,45 @@ +{ + "tests": [ + { + "id": "acme::shop::Announced [object.entity]", + "package": "acme::shop", + "path": "Announced", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Announced__object_entity", + "status": "live", + "skip": null, + "digest": "be7e196cc97f87f5e9742f57fb2254b82e2cfc1a713be57d2424dea9188325cf" + }, + { + "id": "acme::shop::Announced [template.prompt]", + "package": "acme::shop", + "path": "Announced", + "unit": "template.prompt", + "witnessKey": "req_acme_shop_Announced__template_prompt", + "status": "live", + "skip": null, + "digest": "be7e196cc97f87f5e9742f57fb2254b82e2cfc1a713be57d2424dea9188325cf" + }, + { + "id": "acme::shop::Identified [object.entity]", + "package": "acme::shop", + "path": "Identified", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Identified__object_entity", + "status": "live", + "skip": null, + "digest": "1665da033b2ecf14f717530ac11fd4927dad2b573c1a48e9ed261135d951e410" + }, + { + "id": "acme::shop::Recorded [object.entity]", + "package": "acme::shop", + "path": "Recorded", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Recorded__object_entity", + "status": "live", + "skip": null, + "digest": "2714aa3925a47959aa5e48ae39d80ed203fd4e2caa046a90aab9e04691d9881a" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/filter-by-claimed-concern/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/filter-by-claimed-concern/input/meta.shop.yaml new file mode 100644 index 000000000..e5496a974 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-claimed-concern/input/meta.shop.yaml @@ -0,0 +1,63 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - object.value: + name: OrderConfirmationPayload + children: + - field.string: { name: reference } + + - template.prompt: + name: orderConfirmation + payloadRef: OrderConfirmationPayload + textRef: orders/confirmation + format: xml + + # Claims an entity. + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + # Claims only a template. + - requirement.functional: + name: Confirmed + level: 4 + status: live + statement: A customer is told their order was placed. + counterexample: A placed order nobody confirmed. + implementedBy: [orderConfirmation] + + # Claims a template and then an entity. + - requirement.functional: + name: Announced + level: 4 + status: live + statement: The confirmation names the order it is about. + counterexample: A confirmation that names no order. + implementedBy: [orderConfirmation, Order] + + # Names only a node that does not exist yet. No Refund is declared. + - requirement.functional: + name: Refunded + level: 4 + status: planned + statement: A refund is recorded against its order. + counterexample: A refund with no order. + implementedBy: [Refund] + + # A flat policy that claims an entity. + - requirement.architectural: + name: Identified + status: live + statement: Every row is addressed by a uuid. + counterexample: A row keyed by a name. + implementedBy: [Order] diff --git a/fixtures/requirement-test-identity-conformance/filter-by-claimed-concern/options.json b/fixtures/requirement-test-identity-conformance/filter-by-claimed-concern/options.json new file mode 100644 index 000000000..2b30d914b --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-claimed-concern/options.json @@ -0,0 +1,3 @@ +{ + "filter": "claims-entity" +} diff --git a/fixtures/requirement-test-identity-conformance/filter-by-level/expected.json b/fixtures/requirement-test-identity-conformance/filter-by-level/expected.json new file mode 100644 index 000000000..6f09f1bee --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-level/expected.json @@ -0,0 +1,35 @@ +{ + "tests": [ + { + "id": "acme::shop::Keyed [identity.primary]", + "package": "acme::shop", + "path": "Keyed", + "unit": "identity.primary", + "witnessKey": "req_acme_shop_Keyed__identity_primary", + "status": "live", + "skip": null, + "digest": "41eb263a291544d2c37ba4fec8ead24a1ce824ff9bcf8cba977fcbae166f4586" + }, + { + "id": "acme::shop::Recorded.Addressable [identity.primary]", + "package": "acme::shop", + "path": "Recorded.Addressable", + "unit": "identity.primary", + "witnessKey": "req_acme_shop_Recorded_Addressable__identity_primary", + "status": "live", + "skip": null, + "digest": "3c0921fbc37854c7330ad43523028bd21660998d08833dde92903153c1b3573d" + }, + { + "id": "acme::shop::Recorded.Quotable [field.string]", + "package": "acme::shop", + "path": "Recorded.Quotable", + "unit": "field.string", + "witnessKey": "req_acme_shop_Recorded_Quotable__field_string", + "status": "live", + "skip": null, + "digest": "b5d7fb7a9657bfda7b30e2629617bf5c56e4337ef7a06abfe55715613ecdda63" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/filter-by-level/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/filter-by-level/input/meta.shop.yaml new file mode 100644 index 000000000..037de0bc4 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-level/input/meta.shop.yaml @@ -0,0 +1,49 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + children: + - requirement.functional: + name: Quotable + level: 5 + status: live + statement: An order carries a reference the customer can quote. + counterexample: An order nobody can quote back. + implementedBy: [Order.reference] + - requirement.functional: + name: Addressable + level: 5 + status: live + statement: An order is addressed by one stable key. + counterexample: An order that cannot be pointed at. + implementedBy: [Order.pk] + + # A levelled policy at L5. + - requirement.architectural: + name: Keyed + level: 5 + status: live + statement: Every primary key is a single uuid column. + counterexample: A composite primary key. + implementedBy: [Order.pk] + + # A flat policy: it has no level, which is not the same as level 5. + - requirement.architectural: + name: Identified + status: live + statement: Every row is addressed by a uuid. + counterexample: A row keyed by a name. + implementedBy: [Order] diff --git a/fixtures/requirement-test-identity-conformance/filter-by-level/options.json b/fixtures/requirement-test-identity-conformance/filter-by-level/options.json new file mode 100644 index 000000000..9bad819e9 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-level/options.json @@ -0,0 +1,3 @@ +{ + "filter": "level-5" +} diff --git a/fixtures/requirement-test-identity-conformance/filter-by-package/expected.json b/fixtures/requirement-test-identity-conformance/filter-by-package/expected.json new file mode 100644 index 000000000..ecc80e909 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-package/expected.json @@ -0,0 +1,35 @@ +{ + "tests": [ + { + "id": "acme::shop::Orders [*]", + "package": "acme::shop", + "path": "Orders", + "unit": "*", + "witnessKey": "req_acme_shop_Orders", + "status": "live", + "skip": null, + "digest": "e84c69fda54cb9f04f3949af069c2c5d56027e008be3198a08c4ee4fbcad8be6" + }, + { + "id": "acme::shop::Orders.Recorded [object.entity]", + "package": "acme::shop", + "path": "Orders.Recorded", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Orders_Recorded__object_entity", + "status": "live", + "skip": null, + "digest": "2714aa3925a47959aa5e48ae39d80ed203fd4e2caa046a90aab9e04691d9881a" + }, + { + "id": "acme::shop::Settled [object.entity]", + "package": "acme::shop", + "path": "Settled", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Settled__object_entity", + "status": "live", + "skip": null, + "digest": "b8a6a5bf7873f5774105fc0ad3ee9d45994144dda20288f20d5245a4043a5461" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/filter-by-package/input/meta.billing.yaml b/fixtures/requirement-test-identity-conformance/filter-by-package/input/meta.billing.yaml new file mode 100644 index 000000000..81203e541 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-package/input/meta.billing.yaml @@ -0,0 +1,28 @@ +metadata: + package: acme::billing + children: + - object.entity: + name: Invoice + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + # Takes this file's package. + - requirement.functional: + name: Billed + level: 4 + status: live + statement: An invoice is recorded when it is raised. + counterexample: A raised invoice has no row. + implementedBy: [Invoice] + + # Declares its own package, which is not this file's. Its bare reference binds + # in the package it declares. + - requirement.functional: + name: Settled + package: acme::shop + level: 4 + status: live + statement: An order records when it was paid. + counterexample: A paid order with no payment time. + implementedBy: [Order] diff --git a/fixtures/requirement-test-identity-conformance/filter-by-package/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/filter-by-package/input/meta.shop.yaml new file mode 100644 index 000000000..3b846f996 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-package/input/meta.shop.yaml @@ -0,0 +1,34 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + # Neither node declares a package: both take this file's. + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + # Declares its own package, which is not this file's. + - requirement.functional: + name: Invoiced + package: acme::billing + level: 4 + status: live + statement: An invoice is raised for every order. + counterexample: An order with no invoice. + implementedBy: [Invoice] diff --git a/fixtures/requirement-test-identity-conformance/filter-by-package/options.json b/fixtures/requirement-test-identity-conformance/filter-by-package/options.json new file mode 100644 index 000000000..3bd3be707 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-package/options.json @@ -0,0 +1,3 @@ +{ + "filter": "package-acme-shop" +} diff --git a/fixtures/requirement-test-identity-conformance/filter-by-path/expected.json b/fixtures/requirement-test-identity-conformance/filter-by-path/expected.json new file mode 100644 index 000000000..5e848f196 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-path/expected.json @@ -0,0 +1,35 @@ +{ + "tests": [ + { + "id": "acme::shop::Shop [*]", + "package": "acme::shop", + "path": "Shop", + "unit": "*", + "witnessKey": "req_acme_shop_Shop", + "status": "live", + "skip": null, + "digest": "edc8a9b8159eb03a08d020427683253d6d1471032e0cf48115956362fc121653" + }, + { + "id": "acme::shop::Shop.Orders [*]", + "package": "acme::shop", + "path": "Shop.Orders", + "unit": "*", + "witnessKey": "req_acme_shop_Shop_Orders", + "status": "live", + "skip": null, + "digest": "e84c69fda54cb9f04f3949af069c2c5d56027e008be3198a08c4ee4fbcad8be6" + }, + { + "id": "acme::shop::Shop.Orders.Recorded [object.entity]", + "package": "acme::shop", + "path": "Shop.Orders.Recorded", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Shop_Orders_Recorded__object_entity", + "status": "live", + "skip": null, + "digest": "2714aa3925a47959aa5e48ae39d80ed203fd4e2caa046a90aab9e04691d9881a" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/filter-by-path/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/filter-by-path/input/meta.shop.yaml new file mode 100644 index 000000000..56f6112dd --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-path/input/meta.shop.yaml @@ -0,0 +1,52 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - object.entity: + name: Listing + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Shop + level: 1 + status: live + statement: A customer can buy from the shop. + counterexample: A customer who cannot place an order. + children: + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + # Begins with the same four letters and is not under Shop. + - requirement.functional: + name: Shopfront + level: 1 + status: live + statement: A customer can see what is for sale. + counterexample: A product nobody can find. + children: + - requirement.functional: + name: Listed + level: 4 + status: live + statement: A product for sale has a listing. + counterexample: A product for sale with no listing. + implementedBy: [Listing] diff --git a/fixtures/requirement-test-identity-conformance/filter-by-path/options.json b/fixtures/requirement-test-identity-conformance/filter-by-path/options.json new file mode 100644 index 000000000..1f7e82682 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-path/options.json @@ -0,0 +1,3 @@ +{ + "filter": "path-under-Shop" +} diff --git a/fixtures/requirement-test-identity-conformance/filter-by-status/expected.json b/fixtures/requirement-test-identity-conformance/filter-by-status/expected.json new file mode 100644 index 000000000..0475877bc --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-status/expected.json @@ -0,0 +1,25 @@ +{ + "tests": [ + { + "id": "acme::shop::Orders [*]", + "package": "acme::shop", + "path": "Orders", + "unit": "*", + "witnessKey": "req_acme_shop_Orders", + "status": "live", + "skip": null, + "digest": "e84c69fda54cb9f04f3949af069c2c5d56027e008be3198a08c4ee4fbcad8be6" + }, + { + "id": "acme::shop::Orders.Recorded [object.entity]", + "package": "acme::shop", + "path": "Orders.Recorded", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Orders_Recorded__object_entity", + "status": "live", + "skip": null, + "digest": "2714aa3925a47959aa5e48ae39d80ed203fd4e2caa046a90aab9e04691d9881a" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/filter-by-status/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/filter-by-status/input/meta.shop.yaml new file mode 100644 index 000000000..279df6b20 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-status/input/meta.shop.yaml @@ -0,0 +1,37 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + # Live, and at L3. + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + - requirement.functional: + name: Shipped + level: 4 + status: partial + statement: An order records when it left the warehouse. + counterexample: A shipped order with no dispatch time. + implementedBy: [Order] + - requirement.functional: + name: Refunded + level: 4 + status: planned + statement: A refund is recorded against its order. + counterexample: A refund with no order. diff --git a/fixtures/requirement-test-identity-conformance/filter-by-status/options.json b/fixtures/requirement-test-identity-conformance/filter-by-status/options.json new file mode 100644 index 000000000..015d8e1e9 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-status/options.json @@ -0,0 +1,3 @@ +{ + "filter": "live" +} diff --git a/fixtures/requirement-test-identity-conformance/filter-by-subtype/expected.json b/fixtures/requirement-test-identity-conformance/filter-by-subtype/expected.json new file mode 100644 index 000000000..96652cf30 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-subtype/expected.json @@ -0,0 +1,25 @@ +{ + "tests": [ + { + "id": "acme::shop::Addressable [object.entity]", + "package": "acme::shop", + "path": "Addressable", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Addressable__object_entity", + "status": "live", + "skip": null, + "digest": "39aa1215dbd4f3fca3f898c5eb4f390fb2fed8f6da610da87b2820b5a56194b5" + }, + { + "id": "acme::shop::Identified [object.entity]", + "package": "acme::shop", + "path": "Identified", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Identified__object_entity", + "status": "live", + "skip": null, + "digest": "1665da033b2ecf14f717530ac11fd4927dad2b573c1a48e9ed261135d951e410" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/filter-by-subtype/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/filter-by-subtype/input/meta.shop.yaml new file mode 100644 index 000000000..d0b1a8f42 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-subtype/input/meta.shop.yaml @@ -0,0 +1,42 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + # A flat policy: it has no level at all. + - requirement.architectural: + name: Identified + status: live + statement: Every row is addressed by a uuid. + counterexample: A row keyed by a name. + implementedBy: [Order] + + # A levelled policy. + - requirement.architectural: + name: Addressable + level: 4 + status: live + statement: An order is addressed by one stable key. + counterexample: An order that cannot be pointed at. + implementedBy: [Order] + + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + children: + - requirement.functional: + name: Quotable + level: 5 + status: live + statement: An order carries a reference the customer can quote. + counterexample: An order nobody can quote back. + implementedBy: [Order.reference] diff --git a/fixtures/requirement-test-identity-conformance/filter-by-subtype/options.json b/fixtures/requirement-test-identity-conformance/filter-by-subtype/options.json new file mode 100644 index 000000000..9d195f1ec --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-subtype/options.json @@ -0,0 +1,3 @@ +{ + "filter": "architectural" +} diff --git a/fixtures/requirement-test-identity-conformance/member-grain-duplicate-ref/expected.json b/fixtures/requirement-test-identity-conformance/member-grain-duplicate-ref/expected.json new file mode 100644 index 000000000..dfa83fc7b --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/member-grain-duplicate-ref/expected.json @@ -0,0 +1,25 @@ +{ + "tests": [ + { + "id": "acme::shop::Recorded [Order]", + "package": "acme::shop", + "path": "Recorded", + "unit": "Order", + "witnessKey": "req_acme_shop_Recorded__Order", + "status": "live", + "skip": null, + "digest": "5c3e6c7754fd943dd5f00a6b55544c16cb54a47a1d64e4def334760c07c7a501" + }, + { + "id": "acme::shop::Recorded [acme::shop::Order]", + "package": "acme::shop", + "path": "Recorded", + "unit": "acme::shop::Order", + "witnessKey": "req_acme_shop_Recorded__acme_shop_Order", + "status": "live", + "skip": null, + "digest": "5c3e6c7754fd943dd5f00a6b55544c16cb54a47a1d64e4def334760c07c7a501" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/member-grain-duplicate-ref/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/member-grain-duplicate-ref/input/meta.shop.yaml new file mode 100644 index 000000000..92a86bfdb --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/member-grain-duplicate-ref/input/meta.shop.yaml @@ -0,0 +1,17 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + # One entity, named three times in two spellings. + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order, "acme::shop::Order", Order] diff --git a/fixtures/requirement-test-identity-conformance/member-grain-duplicate-ref/options.json b/fixtures/requirement-test-identity-conformance/member-grain-duplicate-ref/options.json new file mode 100644 index 000000000..c1d859aab --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/member-grain-duplicate-ref/options.json @@ -0,0 +1,3 @@ +{ + "grain": "member" +} diff --git a/fixtures/requirement-test-identity-conformance/member-grain-unresolved-dropped/expected.json b/fixtures/requirement-test-identity-conformance/member-grain-unresolved-dropped/expected.json new file mode 100644 index 000000000..3298d77c6 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/member-grain-unresolved-dropped/expected.json @@ -0,0 +1,35 @@ +{ + "tests": [ + { + "id": "acme::shop::Recorded [Order]", + "package": "acme::shop", + "path": "Recorded", + "unit": "Order", + "witnessKey": "req_acme_shop_Recorded__Order", + "status": "planned", + "skip": "planned", + "digest": "1898440ecdffa3941b6e945b5aa0c2ad6135cb3b9d2b2b8cb734be00e00617f6" + }, + { + "id": "acme::shop::Recorded.Quotable [Order.reference]", + "package": "acme::shop", + "path": "Recorded.Quotable", + "unit": "Order.reference", + "witnessKey": "req_acme_shop_Recorded_Quotable__Order_reference", + "status": "planned", + "skip": "planned", + "digest": "65dc05d07276982476601048513af90df59b0ffabc31e5e9f71b63b37fd0f0cf" + }, + { + "id": "acme::shop::Refunded [*]", + "package": "acme::shop", + "path": "Refunded", + "unit": "*", + "witnessKey": "req_acme_shop_Refunded", + "status": "planned", + "skip": "planned", + "digest": "0a86fc1665ffb7a53dd99506c0ac0fbb7051311f2e214f061051fc5c60c37c57" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/member-grain-unresolved-dropped/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/member-grain-unresolved-dropped/input/meta.shop.yaml new file mode 100644 index 000000000..6866ed910 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/member-grain-unresolved-dropped/input/meta.shop.yaml @@ -0,0 +1,37 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + # Planned, so naming nodes that do not exist yet is legal. No Refund is declared. + - requirement.functional: + name: Recorded + level: 4 + status: planned + statement: An order and its refund are recorded. + counterexample: A refund with no row. + implementedBy: [Order, Refund] + children: + # One member that exists, one missing from an object that exists, and one + # under an object that does not. + - requirement.functional: + name: Quotable + level: 5 + status: planned + statement: An order and its refund carry a reference the customer can quote. + counterexample: A refund nobody can quote back. + implementedBy: [Order.reference, Order.refundReference, Refund.reference] + + # Nothing it names resolves. + - requirement.functional: + name: Refunded + level: 4 + status: planned + statement: A refund is recorded against its order. + counterexample: A refund with no order. + implementedBy: [Refund] diff --git a/fixtures/requirement-test-identity-conformance/member-grain-unresolved-dropped/options.json b/fixtures/requirement-test-identity-conformance/member-grain-unresolved-dropped/options.json new file mode 100644 index 000000000..c1d859aab --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/member-grain-unresolved-dropped/options.json @@ -0,0 +1,3 @@ +{ + "grain": "member" +} diff --git a/fixtures/requirement-test-identity-conformance/member-grain/expected.json b/fixtures/requirement-test-identity-conformance/member-grain/expected.json new file mode 100644 index 000000000..4e9889869 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/member-grain/expected.json @@ -0,0 +1,45 @@ +{ + "tests": [ + { + "id": "acme::shop::Recorded [Order]", + "package": "acme::shop", + "path": "Recorded", + "unit": "Order", + "witnessKey": "req_acme_shop_Recorded__Order", + "status": "live", + "skip": null, + "digest": "92d8d3bdfeb2f3545d489965b4e602dd617c24283bdf987d44ce6e34b53f0361" + }, + { + "id": "acme::shop::Recorded [acme::shop::Customer]", + "package": "acme::shop", + "path": "Recorded", + "unit": "acme::shop::Customer", + "witnessKey": "req_acme_shop_Recorded__acme_shop_Customer", + "status": "live", + "skip": null, + "digest": "92d8d3bdfeb2f3545d489965b4e602dd617c24283bdf987d44ce6e34b53f0361" + }, + { + "id": "acme::shop::Recorded.Quotable [Order.reference]", + "package": "acme::shop", + "path": "Recorded.Quotable", + "unit": "Order.reference", + "witnessKey": "req_acme_shop_Recorded_Quotable__Order_reference", + "status": "live", + "skip": null, + "digest": "fcf9ae1e3315cf0eebb22b2e4d51b8d75b518b8959d1b576a388638e5e2bc92b" + }, + { + "id": "acme::shop::Recorded.Quotable [acme::shop::Order.pk]", + "package": "acme::shop", + "path": "Recorded.Quotable", + "unit": "acme::shop::Order.pk", + "witnessKey": "req_acme_shop_Recorded_Quotable__acme_shop_Order_pk", + "status": "live", + "skip": null, + "digest": "fcf9ae1e3315cf0eebb22b2e4d51b8d75b518b8959d1b576a388638e5e2bc92b" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/member-grain/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/member-grain/input/meta.shop.yaml new file mode 100644 index 000000000..a3a89fa2f --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/member-grain/input/meta.shop.yaml @@ -0,0 +1,33 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - object.entity: + name: Customer + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + # Two entities are one concern and two references. Each requirement names one + # node by its bare name and one by its qualified name. + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded against its customer. + counterexample: An order with no customer. + implementedBy: [Order, "acme::shop::Customer"] + children: + - requirement.functional: + name: Quotable + level: 5 + status: live + statement: An order carries a reference the customer can quote. + counterexample: An order nobody can quote back. + implementedBy: [Order.reference, "acme::shop::Order.pk"] diff --git a/fixtures/requirement-test-identity-conformance/member-grain/options.json b/fixtures/requirement-test-identity-conformance/member-grain/options.json new file mode 100644 index 000000000..c1d859aab --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/member-grain/options.json @@ -0,0 +1,3 @@ +{ + "grain": "member" +} diff --git a/fixtures/requirement-test-identity-conformance/nested-path/expected.json b/fixtures/requirement-test-identity-conformance/nested-path/expected.json new file mode 100644 index 000000000..96ad7fb97 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/nested-path/expected.json @@ -0,0 +1,45 @@ +{ + "tests": [ + { + "id": "acme::shop::Shop.Returns.Orders.Recorded [object.entity]", + "package": "acme::shop", + "path": "Shop.Returns.Orders.Recorded", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Shop_Returns_Orders_Recorded__object_entity", + "status": "live", + "skip": null, + "digest": "121f87a83cbd2575f5510c1798e08915fd48f3c9c06833693a7102b5458a1364" + }, + { + "id": "acme::shop::Shop.Returns.Orders.Recorded.Quotable [field.string]", + "package": "acme::shop", + "path": "Shop.Returns.Orders.Recorded.Quotable", + "unit": "field.string", + "witnessKey": "req_acme_shop_Shop_Returns_Orders_Recorded_Quotable__field_string", + "status": "live", + "skip": null, + "digest": "c2ee5f69bad2f3c4d8cb4b1f0c299c187ff82ebd419f3ddfa8897ff947330434" + }, + { + "id": "acme::shop::Shop.Sales.Orders.Recorded [object.entity]", + "package": "acme::shop", + "path": "Shop.Sales.Orders.Recorded", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Shop_Sales_Orders_Recorded__object_entity", + "status": "live", + "skip": null, + "digest": "2714aa3925a47959aa5e48ae39d80ed203fd4e2caa046a90aab9e04691d9881a" + }, + { + "id": "acme::shop::Shop.Sales.Orders.Recorded.Quotable [field.string]", + "package": "acme::shop", + "path": "Shop.Sales.Orders.Recorded.Quotable", + "unit": "field.string", + "witnessKey": "req_acme_shop_Shop_Sales_Orders_Recorded_Quotable__field_string", + "status": "live", + "skip": null, + "digest": "b5d7fb7a9657bfda7b30e2629617bf5c56e4337ef7a06abfe55715613ecdda63" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/nested-path/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/nested-path/input/meta.shop.yaml new file mode 100644 index 000000000..c1f30b3cd --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/nested-path/input/meta.shop.yaml @@ -0,0 +1,83 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + - object.entity: + name: Return + children: + - field.uuid: { name: id } + - field.string: { name: reference } + - identity.primary: { name: pk, fields: [id] } + + # Two branches end in the same three names. Only the path tells their tests apart. + - requirement.functional: + name: Shop + level: 1 + status: live + statement: A customer can buy from the shop. + counterexample: A customer who cannot place an order. + children: + - requirement.functional: + name: Sales + level: 2 + status: live + statement: Sales are taken through the shop. + counterexample: A sale made outside the shop. + children: + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + children: + - requirement.functional: + name: Quotable + level: 5 + status: live + statement: An order carries a reference the customer can quote. + counterexample: An order nobody can quote back. + implementedBy: [Order.reference] + - requirement.functional: + name: Returns + level: 2 + status: live + statement: Goods are taken back through the shop. + counterexample: A return made outside the shop. + children: + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every returned order is kept. + counterexample: A returned order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: A return is recorded when it is accepted. + counterexample: An accepted return has no row. + implementedBy: [Return] + children: + - requirement.functional: + name: Quotable + level: 5 + status: live + statement: A return carries a reference the customer can quote. + counterexample: A return nobody can quote back. + implementedBy: [Return.reference] diff --git a/fixtures/requirement-test-identity-conformance/no-targets/expected.json b/fixtures/requirement-test-identity-conformance/no-targets/expected.json new file mode 100644 index 000000000..b7bf11237 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/no-targets/expected.json @@ -0,0 +1,15 @@ +{ + "tests": [ + { + "id": "acme::shop::Recorded [*]", + "package": "acme::shop", + "path": "Recorded", + "unit": "*", + "witnessKey": "req_acme_shop_Recorded", + "status": "live", + "skip": null, + "digest": "09628ddb7bec2fd406e0fc69833994bb96bc283493b1484c3079b6995c3a694c" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/no-targets/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/no-targets/input/meta.shop.yaml new file mode 100644 index 000000000..2a4e7f9cb --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/no-targets/input/meta.shop.yaml @@ -0,0 +1,16 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + # Live, and it names nothing. + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. diff --git a/fixtures/requirement-test-identity-conformance/package-in-address/expected.json b/fixtures/requirement-test-identity-conformance/package-in-address/expected.json new file mode 100644 index 000000000..7f4deee29 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/package-in-address/expected.json @@ -0,0 +1,25 @@ +{ + "tests": [ + { + "id": "acme::billing::Orders.Recorded [object.entity]", + "package": "acme::billing", + "path": "Orders.Recorded", + "unit": "object.entity", + "witnessKey": "req_acme_billing_Orders_Recorded__object_entity", + "status": "live", + "skip": null, + "digest": "2714aa3925a47959aa5e48ae39d80ed203fd4e2caa046a90aab9e04691d9881a" + }, + { + "id": "acme::shop::Orders.Recorded [object.entity]", + "package": "acme::shop", + "path": "Orders.Recorded", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Orders_Recorded__object_entity", + "status": "live", + "skip": null, + "digest": "2714aa3925a47959aa5e48ae39d80ed203fd4e2caa046a90aab9e04691d9881a" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/package-in-address/input/meta.billing.yaml b/fixtures/requirement-test-identity-conformance/package-in-address/input/meta.billing.yaml new file mode 100644 index 000000000..1cacc91ee --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/package-in-address/input/meta.billing.yaml @@ -0,0 +1,23 @@ +metadata: + package: acme::billing + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] diff --git a/fixtures/requirement-test-identity-conformance/package-in-address/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/package-in-address/input/meta.shop.yaml new file mode 100644 index 000000000..46bdddb77 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/package-in-address/input/meta.shop.yaml @@ -0,0 +1,23 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] diff --git a/fixtures/requirement-test-identity-conformance/partial-not-skipped/expected.json b/fixtures/requirement-test-identity-conformance/partial-not-skipped/expected.json new file mode 100644 index 000000000..3ca6c3cdd --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/partial-not-skipped/expected.json @@ -0,0 +1,15 @@ +{ + "tests": [ + { + "id": "acme::shop::Recorded [object.entity]", + "package": "acme::shop", + "path": "Recorded", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Recorded__object_entity", + "status": "partial", + "skip": null, + "digest": "9c7f098dc9a7c74fd9c13cc705bf174c306a3259208abb2a7acca0d577c4e262" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/partial-not-skipped/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/partial-not-skipped/input/meta.shop.yaml new file mode 100644 index 000000000..c9ce26b9b --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/partial-not-skipped/input/meta.shop.yaml @@ -0,0 +1,16 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Recorded + level: 4 + status: partial + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] diff --git a/fixtures/requirement-test-identity-conformance/planned-skip/expected.json b/fixtures/requirement-test-identity-conformance/planned-skip/expected.json new file mode 100644 index 000000000..cde6dfcc6 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/planned-skip/expected.json @@ -0,0 +1,25 @@ +{ + "tests": [ + { + "id": "acme::shop::Recorded [object.entity]", + "package": "acme::shop", + "path": "Recorded", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Recorded__object_entity", + "status": "planned", + "skip": "planned", + "digest": "a3f7627d0bea3eb3af8a3a22b03a8c4bc217aac64407e62ec7fb0b1981f3cfd2" + }, + { + "id": "acme::shop::Refunded [*]", + "package": "acme::shop", + "path": "Refunded", + "unit": "*", + "witnessKey": "req_acme_shop_Refunded", + "status": "planned", + "skip": "planned", + "digest": "0a86fc1665ffb7a53dd99506c0ac0fbb7051311f2e214f061051fc5c60c37c57" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/planned-skip/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/planned-skip/input/meta.shop.yaml new file mode 100644 index 000000000..d3f2e8d5a --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/planned-skip/input/meta.shop.yaml @@ -0,0 +1,26 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + # Planned, naming a node that exists: the unit is its concern. + - requirement.functional: + name: Recorded + level: 4 + status: planned + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + # Planned, naming a node that does not exist yet: nothing resolves. + - requirement.functional: + name: Refunded + level: 4 + status: planned + statement: A refund is recorded against its order. + counterexample: A refund with no order. + implementedBy: [Refund] diff --git a/fixtures/requirement-test-identity-conformance/retired-skip/expected.json b/fixtures/requirement-test-identity-conformance/retired-skip/expected.json new file mode 100644 index 000000000..24392cd97 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/retired-skip/expected.json @@ -0,0 +1,15 @@ +{ + "tests": [ + { + "id": "acme::shop::Faxed [*]", + "package": "acme::shop", + "path": "Faxed", + "unit": "*", + "witnessKey": "req_acme_shop_Faxed", + "status": "retired", + "skip": "retired", + "digest": "ef38b10d440646c90a3f9a0a1fd0b331a0c6326ca23eab7462f3e10a961881fa" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/retired-skip/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/retired-skip/input/meta.shop.yaml new file mode 100644 index 000000000..bbbdfdcb2 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/retired-skip/input/meta.shop.yaml @@ -0,0 +1,16 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + # A retired requirement cannot carry implementedBy: the loader refuses it. + - requirement.functional: + name: Faxed + level: 4 + status: retired + statement: An order confirmation is sent by fax. + counterexample: A confirmed order with no fax. diff --git a/fixtures/requirement-test-identity-conformance/unpackaged/expected.json b/fixtures/requirement-test-identity-conformance/unpackaged/expected.json new file mode 100644 index 000000000..b029d2e28 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/unpackaged/expected.json @@ -0,0 +1,25 @@ +{ + "tests": [ + { + "id": "Orders.Recorded [object.entity]", + "package": "", + "path": "Orders.Recorded", + "unit": "object.entity", + "witnessKey": "req_Orders_Recorded__object_entity", + "status": "live", + "skip": null, + "digest": "2714aa3925a47959aa5e48ae39d80ed203fd4e2caa046a90aab9e04691d9881a" + }, + { + "id": "Orders.Refunded [*]", + "package": "", + "path": "Orders.Refunded", + "unit": "*", + "witnessKey": "req_Orders_Refunded", + "status": "planned", + "skip": "planned", + "digest": "4ddcd781ccc2fe462bc316711d5cedb88b803af1727a0df19fe3a69487aa6f86" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/unpackaged/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/unpackaged/input/meta.shop.yaml new file mode 100644 index 000000000..422ea2549 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/unpackaged/input/meta.shop.yaml @@ -0,0 +1,29 @@ +# No package anywhere in this document, and it is the only document of the case. +metadata: + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + - requirement.functional: + name: Refunded + level: 4 + status: planned + statement: A refund is recorded against its order. + counterexample: A refund with no order. diff --git a/fixtures/requirement-test-identity-conformance/witness-key-collision/expected.json b/fixtures/requirement-test-identity-conformance/witness-key-collision/expected.json new file mode 100644 index 000000000..02a3157a6 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/witness-key-collision/expected.json @@ -0,0 +1,30 @@ +{ + "tests": [ + { + "id": "acme::shop::Orders.Recorded [object.entity]", + "package": "acme::shop", + "path": "Orders.Recorded", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Orders_Recorded__object_entity", + "status": "live", + "skip": null, + "digest": "2714aa3925a47959aa5e48ae39d80ed203fd4e2caa046a90aab9e04691d9881a" + }, + { + "id": "acme::shop::Orders_Recorded [object.entity]", + "package": "acme::shop", + "path": "Orders_Recorded", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Orders_Recorded__object_entity", + "status": "live", + "skip": null, + "digest": "c7583c32576dd584f58627cdc42a03875f9e11af6606e833b2c3a4619c50ebd7" + } + ], + "collisions": [ + [ + "acme::shop::Orders.Recorded [object.entity]", + "acme::shop::Orders_Recorded [object.entity]" + ] + ] +} diff --git a/fixtures/requirement-test-identity-conformance/witness-key-collision/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/witness-key-collision/input/meta.shop.yaml new file mode 100644 index 000000000..95ae7a8ec --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/witness-key-collision/input/meta.shop.yaml @@ -0,0 +1,33 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + # Path Orders.Recorded. + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + # Path Orders_Recorded: a different address, and the same witness key. + - requirement.functional: + name: Orders_Recorded + level: 4 + status: live + statement: An order is recorded once. + counterexample: Two rows for one order. + implementedBy: [Order] diff --git a/fixtures/requirement-test-identity-conformance/worked-example/expected.json b/fixtures/requirement-test-identity-conformance/worked-example/expected.json new file mode 100644 index 000000000..2988cb018 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/worked-example/expected.json @@ -0,0 +1,25 @@ +{ + "tests": [ + { + "id": "acme::shop::Orders.Recorded [object.entity]", + "package": "acme::shop", + "path": "Orders.Recorded", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Orders_Recorded__object_entity", + "status": "live", + "skip": null, + "digest": "2714aa3925a47959aa5e48ae39d80ed203fd4e2caa046a90aab9e04691d9881a" + }, + { + "id": "acme::shop::Orders.Refunded [*]", + "package": "acme::shop", + "path": "Orders.Refunded", + "unit": "*", + "witnessKey": "req_acme_shop_Orders_Refunded", + "status": "planned", + "skip": "planned", + "digest": "4ddcd781ccc2fe462bc316711d5cedb88b803af1727a0df19fe3a69487aa6f86" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/worked-example/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/worked-example/input/meta.shop.yaml new file mode 100644 index 000000000..2932f962a --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/worked-example/input/meta.shop.yaml @@ -0,0 +1,30 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + # An L3 parent: the default filter gives it no test. + - requirement.functional: + name: Orders + level: 3 + status: live + statement: Every placed order is kept. + counterexample: A placed order that is lost. + children: + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + - requirement.functional: + name: Refunded + level: 4 + status: planned + statement: A refund is recorded against its order. + counterexample: A refund with no order. diff --git a/scripts/write-requirement-corpus-expected.ts b/scripts/write-requirement-corpus-expected.ts index 29e827a11..ca504de8e 100644 --- a/scripts/write-requirement-corpus-expected.ts +++ b/scripts/write-requirement-corpus-expected.ts @@ -4,6 +4,7 @@ * reference. * * bun scripts/write-requirement-corpus-expected.ts check + * bun scripts/write-requirement-corpus-expected.ts identity * * ── What this is, and what it is not ─────────────────────────────────────────── * @@ -27,8 +28,18 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; // RELATIVE, as in `generate-requirement-harness.ts`: `scripts/` sits outside the bun // workspace, so a bare `@metaobjectsdev/*` specifier does not resolve from here. -import { MetaDataLoader, type MetaData } from "../server/typescript/packages/metadata/src/index.js"; -import { REQUIREMENT_STATUSES } from "../server/typescript/packages/metadata/src/core/requirement/requirement-constants.js"; +import { + MetaDataLoader, + OBJECT_SUBTYPE_ENTITY, + TYPE_OBJECT, + type MetaData, +} from "../server/typescript/packages/metadata/src/index.js"; +import { + REQUIREMENT_LEVEL_MEMBER, + REQUIREMENT_STATUSES, + REQUIREMENT_STATUS_LIVE, + REQUIREMENT_SUBTYPE_ARCHITECTURAL, +} from "../server/typescript/packages/metadata/src/core/requirement/requirement-constants.js"; import { checkRequirements, scanRequirements, @@ -36,6 +47,13 @@ import { type Diagnostic, type RequirementSummary, } from "../server/typescript/packages/cli/src/lib/requirement-check.js"; +import { + requirementTestIdentities, + witnessKeyCollisions, + type RequirementTestGrain, + type RequirementTestIdentity, + type RequirementView, +} from "../server/typescript/packages/codegen-ts/src/index.js"; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -96,12 +114,85 @@ function checkExpected(root: MetaData, options: Readonly }; } +// --------------------------------------------------------------------------- +// identity — fixtures/requirement-test-identity-conformance +// --------------------------------------------------------------------------- + +/** + * The corpus's closed list of filters, by the name a case's `options.json` gives. + * + * A second copy of the table in the corpus's TypeScript runner + * (`codegen-ts/test/requirement-test-identity-conformance.test.ts`), on purpose: every + * port's runner holds its own, and the runner is what fails when this one disagrees + * with it. The corpus README is the definition of both. + */ +const IDENTITY_FILTERS: Readonly boolean>> = { + "all": () => true, + "architectural": (r) => r.subType === REQUIREMENT_SUBTYPE_ARCHITECTURAL, + "live": (r) => r.status === REQUIREMENT_STATUS_LIVE, + "level-5": (r) => r.level === REQUIREMENT_LEVEL_MEMBER, + "package-acme-shop": (r) => r.package === "acme::shop", + "path-under-Shop": (r) => r.path === "Shop" || r.path.startsWith("Shop."), + "claims-entity": (r) => r.implementedByTypes.includes(`${TYPE_OBJECT}.${OBJECT_SUBTYPE_ENTITY}`), +}; + +/** Key order is fixed so a rewrite produces no diff. `skip` is written as `null`, not + * left out, on a test that runs: that it is not skipped is a statement, not an absence. */ +function identityRecord(t: RequirementTestIdentity): Record { + return { + id: t.id, + package: t.package, + path: t.path, + unit: t.unit, + witnessKey: t.witnessKey, + // `status` is required by the loader, so a case that loaded strict has one. + status: t.status ?? null, + skip: t.skip, + digest: t.digest, + }; +} + +function identityExpected(root: MetaData, options: Readonly>): unknown { + const grain = options["grain"]; + if (grain !== undefined && typeof grain !== "string") { + throw new Error("'grain' in options.json must be a string"); + } + const filterName = options["filter"]; + if (filterName !== undefined && typeof filterName !== "string") { + throw new Error("'filter' in options.json must be a string"); + } + const filter = filterName === undefined ? undefined : IDENTITY_FILTERS[filterName]; + if (filterName !== undefined && filter === undefined) { + throw new Error( + `unknown filter '${filterName}' in options.json (the corpus names: ` + + `${Object.keys(IDENTITY_FILTERS).join(", ")})`, + ); + } + // Each option is passed only when the case sets it, so a case without one is written + // from the reference's own default. A grain the reference does not know is refused + // by the reference, which refuses the case. + const tests = requirementTestIdentities(root, { + ...(grain === undefined ? {} : { grain: grain as RequirementTestGrain }), + ...(filter === undefined ? {} : { filter }), + }); + return { + // Sorted by id, which is how the reference returns them and how runners compare. + tests: tests.map(identityRecord), + collisions: witnessKeyCollisions(tests), + }; +} + const CORPORA: Readonly> = { check: { dir: "fixtures/requirement-check-conformance", optionKeys: ["libraries", "requireImplementers"], expected: checkExpected, }, + identity: { + dir: "fixtures/requirement-test-identity-conformance", + optionKeys: ["grain", "filter"], + expected: identityExpected, + }, }; // --------------------------------------------------------------------------- diff --git a/server/typescript/packages/codegen-ts/test/requirement-test-identity-conformance.test.ts b/server/typescript/packages/codegen-ts/test/requirement-test-identity-conformance.test.ts new file mode 100644 index 000000000..9627e3c19 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/requirement-test-identity-conformance.test.ts @@ -0,0 +1,135 @@ +// Cross-port requirement-test identity corpus — fixtures/requirement-test-identity-conformance/. +// +// Every port generates a test per requirement, each in its own language and test +// framework, and the generated FILES are free to differ. What may not differ is which +// tests a ledger yields, what each is called, whether it is skipped, the fingerprint of +// the claim it tests, and what a project's filter is shown. This corpus pins those, and +// this runner holds the TypeScript reference to it. +// +// Every case is LOADED strict first, so "this model loads today" is proven by the fixture +// rather than asserted in prose. `expected.json` is written from this implementation by +// `scripts/write-requirement-corpus-expected.ts identity` and reviewed by hand; this +// runner is what stops it drifting afterwards. +import { describe, test, expect } from "bun:test"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { + MetaDataLoader, + OBJECT_SUBTYPE_ENTITY, + REQUIREMENT_LEVEL_MEMBER, + REQUIREMENT_STATUS_LIVE, + REQUIREMENT_SUBTYPE_ARCHITECTURAL, + TYPE_OBJECT, +} from "@metaobjectsdev/metadata"; +// The package's PUBLIC exports, not the module they are defined in: the filter seam under +// test is the one an application reaches. +import { requirementTestIdentities, witnessKeyCollisions } from "../src/index.js"; +import type { + RequirementTestGrain, + RequirementTestIdentity, + RequirementView, +} from "../src/index.js"; + +const CORPUS_DIR = join( + import.meta.dir, + "../../../../../fixtures/requirement-test-identity-conformance", +); + +interface Expected { + tests: RequirementTestIdentity[]; + collisions: [string, string][]; +} + +/** The whole of `options.json`. A key outside this list is refused rather than ignored: a + * misspelt `filter` would otherwise run the case under the default filter and pin the + * wrong set of tests in every port. */ +const OPTION_KEYS = ["grain", "filter"] as const; +interface Options { grain?: RequirementTestGrain; filter?: string } + +/** + * The corpus's closed list of filters. A predicate cannot be written in a file five + * languages read, so a case NAMES one and every port's runner holds this table in its own + * language, handing the predicate to its public filter seam. Between them the seven rows + * read every field of the requirement view. + */ +const FILTERS: Readonly boolean>> = { + "all": () => true, + "architectural": (r) => r.subType === REQUIREMENT_SUBTYPE_ARCHITECTURAL, + "live": (r) => r.status === REQUIREMENT_STATUS_LIVE, + "level-5": (r) => r.level === REQUIREMENT_LEVEL_MEMBER, + "package-acme-shop": (r) => r.package === "acme::shop", + "path-under-Shop": (r) => r.path === "Shop" || r.path.startsWith("Shop."), + "claims-entity": (r) => r.implementedByTypes.includes(`${TYPE_OBJECT}.${OBJECT_SUBTYPE_ENTITY}`), +}; + +function readOptions(caseDir: string): Options { + const file = join(caseDir, "options.json"); + if (!existsSync(file)) return {}; + const options = JSON.parse(readFileSync(file, "utf8")) as Record; + const unknown = Object.keys(options).filter((k) => !(OPTION_KEYS as readonly string[]).includes(k)); + if (unknown.length > 0) throw new Error(`${file}: unknown option(s) ${unknown.join(", ")}`); + return options as Options; +} + +function filterNamed(name: string): (r: RequirementView) => boolean { + const filter = FILTERS[name]; + if (filter === undefined) { + throw new Error(`unknown filter '${name}'. The corpus names: ${Object.keys(FILTERS).join(", ")}`); + } + return filter; +} + +// Code units, as the reference orders ids: a locale collation differs between machines. +const codeUnits = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); +/** The comparison the corpus README names: both sides sorted by `id`. */ +const byId = (tests: readonly RequirementTestIdentity[]): RequirementTestIdentity[] => + [...tests].sort((a, b) => codeUnits(a.id, b.id)); +/** …and each collision pair sorted, then the list. */ +const sortedPairs = (pairs: readonly (readonly [string, string])[]): string[][] => + pairs + .map((pair) => [...pair].sort(codeUnits)) + .sort((a, b) => codeUnits(a[0]!, b[0]!) || codeUnits(a[1]!, b[1]!)); + +/** The case names the README's "Cases" table documents, in table order. */ +function documentedCases(): string[] { + const readme = readFileSync(join(CORPUS_DIR, "README.md"), "utf8"); + const section = readme.split(/^## /m).find((s) => s.startsWith("Cases\n")); + if (section === undefined) throw new Error("README.md has no '## Cases' section"); + return [...section.matchAll(/^\| `([^`]+)` \|/gm)].map((m) => m[1]!); +} + +const cases = readdirSync(CORPUS_DIR).filter((n) => statSync(join(CORPUS_DIR, n)).isDirectory()).sort(); + +describe("requirement-test identity conformance corpus", () => { + test("every case on disk is documented in the README, and nothing else is", () => { + expect(cases.length).toBeGreaterThan(0); + expect(documentedCases().sort()).toEqual(cases); + }); + + for (const name of cases) { + test(name, async () => { + const caseDir = join(CORPUS_DIR, name); + const expectedFile = join(caseDir, "expected.json"); + if (!existsSync(expectedFile)) { + throw new Error( + `${name}: no expected.json. Write it with ` + + `'bun scripts/write-requirement-corpus-expected.ts identity', then review it by hand.`, + ); + } + const expected = JSON.parse(readFileSync(expectedFile, "utf8")) as Expected; + const options = readOptions(caseDir); + + const result = await MetaDataLoader.fromDirectory(join(caseDir, "input"), { strict: true }); + expect(result.errors.map(String)).toEqual([]); + + // Each option is passed only when the case sets it, so a case without one runs + // the port's own default — which is itself part of what the corpus pins. + const tests = requirementTestIdentities(result.root, { + ...(options.grain === undefined ? {} : { grain: options.grain }), + ...(options.filter === undefined ? {} : { filter: filterNamed(options.filter) }), + }); + expect(byId(tests)).toStrictEqual(byId(expected.tests)); + expect(sortedPairs(witnessKeyCollisions(tests))).toStrictEqual(sortedPairs(expected.collisions)); + }); + } +}); From de2ab9620f02282343462d2d5ad71dd8f25aae04 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 6 Oct 2026 00:11:31 -0400 Subject: [PATCH 12/43] feat(java): the requirement gate in metaobjects:verify Ports the TypeScript requirement gate (codes, order, message text, summary) to Java, held to it by the shared check corpus; Kotlin gets it through the same Maven goal. One did-you-mean helper is shared with the loader. Refs docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md and ADR-0057. --- .../requirement-check-conformance/README.md | 2 + .../metaobjects/mojo/MetaDataVerifyMojo.java | 71 +++ .../mojo/MetaDataVerifyRequirementsTest.java | 184 +++++++ .../metaobjects/library/LibrarySources.java | 22 + .../metaobjects/loader/ValidationPhase.java | 8 +- .../requirement/MetaRequirement.java | 17 + .../requirement/RequirementCheck.java | 521 ++++++++++++++++++ .../requirement/RequirementClaims.java | 133 +++++ .../RequirementCheckConformanceTest.java | 201 +++++++ 9 files changed, 1157 insertions(+), 2 deletions(-) create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyRequirementsTest.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/requirement/RequirementCheck.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/requirement/RequirementClaims.java create mode 100644 server/java/metadata/src/test/java/com/metaobjects/conformance/RequirementCheckConformanceTest.java diff --git a/fixtures/requirement-check-conformance/README.md b/fixtures/requirement-check-conformance/README.md index 6ba4fb5f8..254ed642c 100644 --- a/fixtures/requirement-check-conformance/README.md +++ b/fixtures/requirement-check-conformance/README.md @@ -140,3 +140,5 @@ it is wrong, unless the reference is shown to be wrong first. | Port | Runner | |---|---| | TypeScript (reference) | `server/typescript/packages/cli/test/requirement-check-conformance.test.ts` | +| Java | `server/java/metadata/src/test/java/com/metaobjects/conformance/RequirementCheckConformanceTest.java` | +| Kotlin | inherits via Java: Kotlin has no CLI of its own and runs `verify` through the same Maven `metaobjects:verify` goal | diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java index 795ea45ae..6e5fbfa7c 100644 --- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java @@ -4,6 +4,7 @@ import com.metaobjects.generator.GeneratorBase; import com.metaobjects.generator.verify.TemplateVerify; import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.requirement.RequirementCheck; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugins.annotations.LifecyclePhase; @@ -75,6 +76,9 @@ public class MetaDataVerifyMojo extends AbstractMetaDataMojo { /** Arg used by {@link GeneratorBase} to locate each generator's output root. */ static final String ARG_OUTPUT_DIR = GeneratorBase.ARG_OUTPUTDIR; + /** What every line of this goal's requirement report starts with, as the field lint's does. */ + private static final String PREFIX = "metaobjects:verify \u2014 "; + /** The gen goal to suggest in the failure message ({@code groupId:artifactId:goal}). */ private static final String GEN_GOAL = "metaobjects:generate"; @@ -117,6 +121,24 @@ public class MetaDataVerifyMojo extends AbstractMetaDataMojo { public void setNoFieldLint(boolean noFieldLint) { this.noFieldLint = noFieldLint; } public boolean isNoFieldLint() { return noFieldLint; } + /** Environment variable that turns {@link #requireImplementers} on for a CI job. */ + static final String ENV_REQUIRE_IMPLEMENTERS = "META_REQUIRE_IMPLEMENTERS"; + + /** + * The strict switch of the requirement gate (ADR-0057): raise + * {@code WARN_REQUIREMENT_NOTHING_IMPLEMENTS} to an error, for a project whose ledger has + * caught up with its links. No other diagnostic changes severity. + * {@code META_REQUIRE_IMPLEMENTERS=1} does the same. + */ + @Parameter(property = "meta.verify.requireImplementers", defaultValue = "false") + private boolean requireImplementers = false; + + public void setRequireImplementers(boolean requireImplementers) { this.requireImplementers = requireImplementers; } + public boolean isRequireImplementers() { return requireImplementers; } + + /** The process environment, overridable so a test can set a variable. */ + String getEnv(String name) { return System.getenv(name); } + @Override public void execute() throws MojoExecutionException, MojoFailureException { // #233: warm the global registry singletons before verify builds its loader, @@ -186,6 +208,51 @@ private static List sourceFiles(MetaDataLoader loader) { return files; } + // ------------------------------------------------------------------------ + // the requirement gate — every mode (ADR-0057) + // ------------------------------------------------------------------------ + + /** + * Runs once per {@code execute()}, whichever mode was selected, as soon as the metadata has + * loaded, so what it found is printed even when the drift gate then fails the build. A model + * that declares no {@code requirement.*} node sees no change at all: nothing is logged. + * + * @return the number of errors found; the caller fails the build once its own gate has reported + */ + private int runRequirementGate(MetaDataLoader loader) { + RequirementCheck.Scan scan = RequirementCheck.scan(loader.getRoot(), + new RequirementCheck.Options(null, + requireImplementers || "1".equals(getEnv(ENV_REQUIRE_IMPLEMENTERS)))); + RequirementCheck.Summary summary = RequirementCheck.summarise(loader.getRoot(), scan); + if (summary == null) return 0; + + // Printed on every run, clean or not: a gate that says nothing when it passes cannot be + // told apart from a gate that checked nothing. + getLog().info(PREFIX + RequirementCheck.summaryText(summary, sourceFiles(loader).size())); + String undecided = RequirementCheck.undecidedText(summary); + if (undecided != null) getLog().info(PREFIX + undecided); + + List diagnostics = RequirementCheck.check(loader.getRoot(), scan); + int errors = 0; + for (RequirementCheck.Diagnostic d : diagnostics) { + if (d.severity() == RequirementCheck.Severity.ERROR) { + errors++; + getLog().error(RequirementCheck.formatDiagnostic(d)); + } else { + getLog().warn(RequirementCheck.formatDiagnostic(d)); + } + } + if (errors > 0) getLog().error(PREFIX + "requirements: " + errors + " error(s)."); + return errors; + } + + private static void failOnRequirementErrors(int errors) throws MojoFailureException { + if (errors > 0) { + throw new MojoFailureException("metaobjects:verify \u2014 requirements: " + errors + + " error(s); see the lines above."); + } + } + // ------------------------------------------------------------------------ // mode=templates — template/prompt {{field}}<->payload drift (ADR-0021 D2) // ------------------------------------------------------------------------ @@ -200,6 +267,7 @@ private void verifyTemplates() throws MojoExecutionException, MojoFailureExcepti ClassLoader projectClassLoader = createProjectClassLoader(); MetaDataLoader loader = createLoader(projectClassLoader); runFieldLintAdvisory(loader); + int requirementErrors = runRequirementGate(loader); TemplateVerify.Outcome outcome = TemplateVerify.run(loader, Paths.get(templateRoot)); @@ -223,6 +291,7 @@ private void verifyTemplates() throws MojoExecutionException, MojoFailureExcepti } getLog().info("MetaData Verify Mojo > No template/prompt drift detected."); + failOnRequirementErrors(requirementErrors); } // ------------------------------------------------------------------------ @@ -233,6 +302,7 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException ClassLoader projectClassLoader = createProjectClassLoader(); MetaDataLoader loader = createLoader(projectClassLoader); runFieldLintAdvisory(loader); + int requirementErrors = runRequirementGate(loader); // Per-generator: resolve its committed (real) outputDir from the merged args, then // stage an arg-override so the regenerate writes to a temp dir instead. Keep the @@ -313,6 +383,7 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException } finally { deleteRecursively(tempRoot); } + failOnRequirementErrors(requirementErrors); } /** diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyRequirementsTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyRequirementsTest.java new file mode 100644 index 000000000..8ebcc505f --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyRequirementsTest.java @@ -0,0 +1,184 @@ +package com.metaobjects.mojo; + +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugin.logging.SystemStreamLog; +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * {@code metaobjects:verify} — the requirement gate, end to end: the diagnostics, the summary + * and the failure. The codes, paths and message text are gated cross-port by + * {@code RequirementCheckConformanceTest} in the metadata module; this proves the goal runs + * the gate, prints what it found, and fails the build on an error and on nothing else. + */ +public class MetaDataVerifyRequirementsTest { + + private static final String PREFIX = "metaobjects:verify — "; + + /** Every line the goal logs, at every level, in order. */ + private static final class CapturingLog extends SystemStreamLog { + final List infos = new ArrayList<>(); + final List warnings = new ArrayList<>(); + final List errors = new ArrayList<>(); + + @Override public void info(CharSequence content) { infos.add(content.toString()); } + @Override public void warn(CharSequence content) { warnings.add(content.toString()); } + @Override public void error(CharSequence content) { errors.add(content.toString()); } + + List all() { + List all = new ArrayList<>(infos); + all.addAll(warnings); + all.addAll(errors); + return all; + } + + long count(String fragment) { + return all().stream().filter(l -> l.contains(fragment)).count(); + } + } + + /** A mojo whose environment the test controls. */ + private static final class TestMojo extends MetaDataVerifyMojo { + private final Function env; + + TestMojo(Function env) { this.env = env; } + + @Override String getEnv(String name) { return env.apply(name); } + } + + private static final String ORDER_ENTITY = """ + { "object.entity": { "name": "Order", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } } ] } }"""; + + private static String requirement(String name, String status, String implementedBy) { + return """ + { "requirement.functional": { "name": "%s", "@level": 4, "@status": "%s", + "@statement": "An order is recorded.", "@counterexample": "A placed order has no row."%s } }""" + .formatted(name, status, implementedBy == null ? "" : ", \"@implementedBy\": [\"" + implementedBy + "\"]"); + } + + private static Path model(String... children) throws IOException { + Path dir = Files.createTempDirectory("verify-requirements"); + dir.toFile().deleteOnExit(); + Path file = dir.resolve("meta.app.json"); + file.toFile().deleteOnExit(); + Files.writeString(file, "{ \"metadata.root\": { \"package\": \"acme::shop\", \"children\": [\n" + + String.join(",\n", children) + "\n] } }", StandardCharsets.UTF_8); + return dir; + } + + /** One goal run: everything it logged, and the failure it ended in, if any. */ + private record Run(CapturingLog log, MojoFailureException failure) {} + + private static Run run(Path dir, String mode, boolean requireImplementers, + Function env) throws Exception { + TestMojo mojo = new TestMojo(env); + mojo.setLoader(LoaderParam.builder("verify-requirements-test") + .withClassname("com.metaobjects.loader.MetaDataLoader") + .withSourceDir(dir.toString()) + .withSource("meta.app.json") + .build()); + mojo.setGenerators(Collections.emptyList()); + mojo.setGlobals(Collections.emptyMap()); + mojo.setMode(mode); + mojo.setTemplateRoot(Files.createTempDirectory("verify-requirements-templates").toString()); + mojo.setRequireImplementers(requireImplementers); + CapturingLog log = new CapturingLog(); + mojo.setLog(log); + try { + mojo.execute(); + return new Run(log, null); + } catch (MojoFailureException e) { + return new Run(log, e); + } + } + + private static final Function NO_ENV = name -> null; + + @Test + public void aModelWithNoRequirementLogsNothingAndDoesNotFail() throws Exception { + for (String mode : new String[]{"templates", "codegen"}) { + Run run = run(model(ORDER_ENTITY), mode, false, NO_ENV); + assertNull(mode, run.failure()); + assertEquals(mode + ": " + run.log().all(), 0, + run.log().all().stream().filter(l -> l.contains("requirements:") || l.contains("_REQUIREMENT_")).count()); + assertTrue(mode + ": " + run.log().errors, run.log().errors.isEmpty()); + } + } + + @Test + public void aDanglingLiveReferenceFailsTheBuildAfterLoggingTheCodePathAndSummary() throws Exception { + Run run = run(model(ORDER_ENTITY, requirement("Recorded", "live", "Ordr")), "templates", false, NO_ENV); + assertNotNull("the build must fail", run.failure()); + assertTrue(run.log().errors.toString(), run.log().errors.contains( + " ERR_REQUIREMENT_DANGLING_REF [Recorded]: 'Ordr' does not resolve in the loaded model " + + "(status 'live' \u2014 the model moved and the requirement is stale).")); + assertTrue(run.log().infos.toString(), run.log().infos.contains(PREFIX + + "requirements: 1 entries (1 functional, 0 architectural) \u2014 1 live; " + + "0/1 entities claimed, counted over 1 metadata file(s).")); + assertTrue(run.log().errors.toString(), run.log().errors.contains(PREFIX + "requirements: 1 error(s).")); + } + + @Test + public void warningsAloneDoNotFailTheBuild() throws Exception { + // A live L4 that names nothing: WARN_REQUIREMENT_NOTHING_IMPLEMENTS, and Order is unclaimed. + Run run = run(model(ORDER_ENTITY, requirement("Recorded", "live", null)), "templates", false, NO_ENV); + assertNull(run.failure()); + assertTrue(run.log().warnings.toString(), run.log().warnings.stream().anyMatch( + w -> w.startsWith(" WARN_REQUIREMENT_NOTHING_IMPLEMENTS [Recorded]: "))); + assertTrue(run.log().warnings.toString(), run.log().warnings.stream().anyMatch( + w -> w.startsWith(" WARN_REQUIREMENT_OBJECT_UNCLAIMED: no requirement claims 'acme::shop::Order'."))); + assertTrue(run.log().errors.toString(), run.log().errors.isEmpty()); + } + + @Test + public void requireImplementersTurnsTheNothingImplementsWarningIntoAFailure() throws Exception { + Path dir = model(ORDER_ENTITY, requirement("Recorded", "live", null)); + + Run byParameter = run(dir, "templates", true, NO_ENV); + assertNotNull("the parameter must fail the build", byParameter.failure()); + assertTrue(byParameter.log().errors.toString(), byParameter.log().errors.stream().anyMatch( + e -> e.startsWith(" WARN_REQUIREMENT_NOTHING_IMPLEMENTS [Recorded]: "))); + assertTrue(byParameter.log().errors.toString(), + byParameter.log().errors.contains(PREFIX + "requirements: 1 error(s).")); + // Only that one code is raised: the other warning keeps its severity. + assertTrue(byParameter.log().warnings.toString(), byParameter.log().warnings.stream().anyMatch( + w -> w.startsWith(" WARN_REQUIREMENT_OBJECT_UNCLAIMED: "))); + + assertNotNull("META_REQUIRE_IMPLEMENTERS=1 must fail the build", + run(dir, "templates", false, name -> "META_REQUIRE_IMPLEMENTERS".equals(name) ? "1" : null).failure()); + assertNull("only the value 1 turns the switch on", + run(dir, "templates", false, name -> "META_REQUIRE_IMPLEMENTERS".equals(name) ? "0" : null).failure()); + } + + @Test + public void theGateRunsOnceWhicheverGateTheModeRan() throws Exception { + Path dir = model(ORDER_ENTITY, requirement("Recorded", "live", "Order")); + for (String mode : new String[]{"templates", "codegen"}) { + Run run = run(dir, mode, false, NO_ENV); + assertNull(mode, run.failure()); + assertEquals(mode + ": " + run.log().all(), 1, run.log().count(PREFIX + "requirements: 1 entries")); + } + } + + @Test + public void aRequirementErrorStillFailsTheBuildInCodegenMode() throws Exception { + Run run = run(model(ORDER_ENTITY, requirement("Recorded", "live", "Ordr")), "codegen", false, NO_ENV); + assertNotNull("the build must fail", run.failure()); + assertEquals(run.log().all().toString(), 1, run.log().count(PREFIX + "requirements: 1 error(s).")); + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/library/LibrarySources.java b/server/java/metadata/src/main/java/com/metaobjects/library/LibrarySources.java index f2077c05e..e898fd334 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/library/LibrarySources.java +++ b/server/java/metadata/src/main/java/com/metaobjects/library/LibrarySources.java @@ -113,6 +113,28 @@ public static List generatorAnchors(String generatorName) { return out; } + /** + * The package names the shipped libraries declare, across every embedded manifest + * ({@code "packages"}), e.g. {@code metaobjects::iam} and {@code metaobjects::ai}. + * + *

The provenance key for requirement-coverage activation (FR-043 §5.4): a + * requirement whose package is NOT in this set was authored by the adopter. Read from the + * manifests, hand-parsed for the reason {@link #parseLayers} is, and never regenerated.

+ * + * @return the declared package names, sorted + */ + public static java.util.Set libraryPackages() { + java.util.Set out = new TreeSet<>(); + for (String manifest : EmbeddedLibrary.MANIFESTS.values()) { + java.util.regex.Matcher block = java.util.regex.Pattern + .compile("\"packages\"\\s*:\\s*\\[([^\\]]*)\\]").matcher(manifest); + if (!block.find()) continue; + java.util.regex.Matcher pkg = java.util.regex.Pattern.compile("\"([^\"]+)\"").matcher(block.group(1)); + while (pkg.find()) out.add(pkg.group(1)); + } + return out; + } + /** One {@code "key": "value"} string field out of a flat JSON object body. */ private static String manifestField(String objectBody, String key) { java.util.regex.Matcher m = java.util.regex.Pattern diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java index a782a4e4a..9b85810f3 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java @@ -4214,8 +4214,12 @@ public static MetaObject resolveRootObject(MetaRoot root, String ref, String ref /** ADR-0042 §5 — a did-you-mean suffix for an UNRESOLVED object reference: the FQNs of * same-short-name objects that DO exist (typically in other packages). Returns "" when - * none exist. Mirrors the TS {@code didYouMeanHint}. */ - private static String didYouMeanHint(MetaRoot root, String ref) { + * none exist. Mirrors the TS {@code didYouMeanHint}. + * + *

Public because it is the ONE builder of this hint: the requirement gate + * ({@code RequirementCheck}) appends the same text to a dangling claim, and a second copy + * would be a second wording to keep in step with TypeScript.

*/ + public static String didYouMeanHint(MetaRoot root, String ref) { if (ref == null) return ""; int sep = ref.lastIndexOf(MetaData.PKG_SEPARATOR); String shortName = (sep >= 0) ? ref.substring(sep + MetaData.PKG_SEPARATOR.length()) : ref; diff --git a/server/java/metadata/src/main/java/com/metaobjects/requirement/MetaRequirement.java b/server/java/metadata/src/main/java/com/metaobjects/requirement/MetaRequirement.java index d1519af5e..d57850177 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/requirement/MetaRequirement.java +++ b/server/java/metadata/src/main/java/com/metaobjects/requirement/MetaRequirement.java @@ -278,6 +278,23 @@ public List getTrackedBy() { return stringList(ATTR_TRACKED_BY); } + /** + * The requirement that REPLACED this one (FR-039), or {@code null} when absent or blank. + * Legal on {@link #STATUS_RETIRED} only; {@code verify} resolves it against the ledger. + */ + public String getSupersededBy() { + if (!hasMetaAttr(ATTR_SUPERSEDED_BY)) { + return null; + } + String value = getMetaAttr(ATTR_SUPERSEDED_BY).getValueAsString(); + return value == null || value.trim().isEmpty() ? null : value; + } + + /** Built, then deliberately removed -- exempt from the checks that assume a built thing. */ + public boolean isRetired() { + return STATUS_RETIRED.equals(getStatus()); + } + /** Intended but not built -- exempt from the checks that assume a built thing. */ public boolean isPlanned() { return STATUS_PLANNED.equals(getStatus()); diff --git a/server/java/metadata/src/main/java/com/metaobjects/requirement/RequirementCheck.java b/server/java/metadata/src/main/java/com/metaobjects/requirement/RequirementCheck.java new file mode 100644 index 000000000..bef839d67 --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/requirement/RequirementCheck.java @@ -0,0 +1,521 @@ +package com.metaobjects.requirement; + +import com.metaobjects.MetaData; +import com.metaobjects.MetaRoot; +import com.metaobjects.io.util.IOUtil; +import com.metaobjects.library.LibrarySources; +import com.metaobjects.loader.ValidationPhase; +import com.metaobjects.validation.SymbolTable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * The requirement gate ({@code verify}): reads the {@code requirement.*} nodes of a loaded + * model and reports what the loader cannot (ADR-0057). + * + *

Java port of the TypeScript reference, {@code requirement-check.ts}. The codes, their + * conditions, their ORDER and their message text are copied from it, and the shared + * {@code fixtures/requirement-check-conformance/} corpus holds this port to it. The loader + * owns the {@code @status} enum, the required attributes and the child rules; this class + * owns {@code @implementedBy} resolution, whose severity depends on {@code @status}, and + * the rules that need the whole ledger.

+ * + *

What a clean run proves is referential integrity. It never proves that a status is + * true or that a claimed node implements the requirement claiming it.

+ */ +public final class RequirementCheck { + + private RequirementCheck() {} + + public enum Severity { ERROR, WARN } + + /** + * One finding. {@code path} is the subject's address, the dotted chain of requirement + * names with no package, or {@code null} when the subject is an entity (coverage). + */ + public record Diagnostic(Severity severity, String code, String path, String message) {} + + /** A requirement paired with its ADDRESS: the dotted chain of requirement names from the root. */ + public record Addressed(MetaRequirement node, String path) {} + + /** + * @param measureCoverage force coverage on or off; {@code null} derives it from who authored + * the requirements (see {@code projectAuthoredRequirements}) + * @param requireImplementers the strict switch: raise {@link #WARN_REQUIREMENT_NOTHING_IMPLEMENTS} + * to an error + */ + public record Options(Boolean measureCoverage, boolean requireImplementers) {} + + /** + * What one run computes once and both the gate and the summary read, so the printed + * summary cannot disagree with the diagnostics beneath it. + */ + public record Scan(List addressed, Set claimedObjects, boolean measureCoverage, + boolean requireImplementers, SymbolTable symbols) {} + + /** + * Counts behind the summary line printed on every run, clean or not. + * {@code entitiesTotal} and {@code entitiesClaimed} are {@code null} when coverage was + * not measured: absence is the honest reading of "this project authored no requirement". + */ + public record Summary(int total, int functional, int architectural, Map byStatus, + int undecided, int deferredUntracked, Integer entitiesClaimed, Integer entitiesTotal) {} + + public static final String ERR_REQUIREMENT_LINK_ABOVE_FLOOR = "ERR_REQUIREMENT_LINK_ABOVE_FLOOR"; + public static final String ERR_REQUIREMENT_DANGLING_REF = "ERR_REQUIREMENT_DANGLING_REF"; + public static final String ERR_REQUIREMENT_BAD_LEVEL = "ERR_REQUIREMENT_BAD_LEVEL"; + public static final String ERR_REQUIREMENT_LEVEL_NESTING = "ERR_REQUIREMENT_LEVEL_NESTING"; + public static final String ERR_REQUIREMENT_L4_NOT_OBJECT = "ERR_REQUIREMENT_L4_NOT_OBJECT"; + public static final String ERR_REQUIREMENT_L5_NOT_MEMBER = "ERR_REQUIREMENT_L5_NOT_MEMBER"; + public static final String ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS = "ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS"; + public static final String WARN_REQUIREMENT_OBJECT_UNCLAIMED = "WARN_REQUIREMENT_OBJECT_UNCLAIMED"; + public static final String WARN_REQUIREMENT_DISPOSITION_NOT_APPLICABLE = "WARN_REQUIREMENT_DISPOSITION_NOT_APPLICABLE"; + public static final String WARN_REQUIREMENT_DEFERRED_UNTRACKED = "WARN_REQUIREMENT_DEFERRED_UNTRACKED"; + public static final String WARN_REQUIREMENT_NOTHING_IMPLEMENTS = "WARN_REQUIREMENT_NOTHING_IMPLEMENTS"; + + /** + * Severity of the object-coverage gate. It stays a warning: on a real estate with a single + * requirement it reports every entity, and at error a project adopting requirements + * incrementally would fail its first verify after authoring one entry. + */ + public static final Severity OBJECT_COVERAGE_SEVERITY = Severity.WARN; + + private static final String TYPE_OBJECT = "object"; + private static final String OBJECT_SUBTYPE_ENTITY = "entity"; + + // ------------------------------------------------------------------ + // the walk + // ------------------------------------------------------------------ + + /** + * Every {@code requirement.*} node in the tree, at any nesting depth, each with its dotted + * path. Hierarchy IS nesting, so this is a walk. It descends through EVERY node, not only + * through requirements: a requirement somewhere the child rules did not anticipate is still + * gated, which is the fail-closed direction for a gate. Only a requirement contributes a + * path segment. + */ + public static List collectAddressed(MetaRoot root) { + List out = new ArrayList<>(); + walk(root, "", out); + return out; + } + + private static void walk(MetaData node, String prefix, List out) { + for (MetaData c : RequirementClaims.structuralChildren(node)) { + boolean isReq = MetaRequirement.TYPE_REQUIREMENT.equals(c.getType()); + // The bare name: a root-level node's getName() is package-qualified in this port. + String path = isReq ? (prefix.isEmpty() ? c.getShortName() : prefix + "." + c.getShortName()) : prefix; + if (isReq) out.add(new Addressed((MetaRequirement) c, path)); + walk(c, path, out); + } + } + + /** + * The package a requirement resolves references in: its own, else the package of the nearest + * enclosing node that carries one (nested nodes carry bare names, so the declaring root-level + * node holds the file's package), else {@code ""}. + */ + public static String effectivePackage(MetaData node) { + for (MetaData n = node; n != null && !(n instanceof MetaRoot); n = n.getParent()) { + String pkg = n.getPackage(); + if (pkg != null && !pkg.isEmpty()) return pkg; + } + return ""; + } + + // ------------------------------------------------------------------ + // the scan + // ------------------------------------------------------------------ + + public static Scan scan(MetaRoot root, Options options) { + List addressed = collectAddressed(root); + SymbolTable symbols = SymbolTable.build(root); + boolean measure = options.measureCoverage() != null + ? options.measureCoverage() + : projectAuthoredRequirements(addressed); + return new Scan(addressed, claimedObjectKeys(root, symbols, addressed), measure, + options.requireImplementers(), symbols); + } + + /** + * Did the ADOPTER author any of these requirements? A library ships its own ledger, and + * without this, opting into a library would switch the unclaimed-entity gate on across a + * project that has never written a requirement. Provenance is the library's declared + * PACKAGE. An overlay on a library requirement stays in the library's package and does not + * activate coverage. + */ + private static boolean projectAuthoredRequirements(List addressed) { + Set libraryPackages = LibrarySources.libraryPackages(); + for (Addressed a : addressed) { + if (!libraryPackages.contains(effectivePackage(a.node()))) return true; + } + return false; + } + + /** + * Resolution keys of every object claimed by a requirement. Shared by the gate and the + * summary. A PLANNED requirement never contributes. An ARCHITECTURAL claim also covers every + * root-level object whose resolved super chain reaches the owner; a functional claim does not. + */ + private static Set claimedObjectKeys(MetaRoot root, SymbolTable symbols, List addressed) { + Set claimed = new HashSet<>(); + for (Addressed a : addressed) { + MetaRequirement req = a.node(); + if (req.isPlanned()) continue; + String referrerPkg = effectivePackage(req); + for (String ref : req.getImplementedBy()) { + RequirementClaims.MemberRef split = RequirementClaims.splitMemberRef(ref); + MetaData node = RequirementClaims.resolveClaimTarget(root, symbols, split.owner(), referrerPkg); + if (node == null) continue; + if (!split.path().isEmpty() && RequirementClaims.resolveMember(node, split.path()) == null) continue; + claimed.add(node.getName()); + if (req.isArchitectural()) claimed.addAll(subtypesOf(root, node)); + } + } + return claimed; + } + + /** Resolution keys of every root-level object whose RESOLVED super chain reaches {@code ancestor}. */ + private static List subtypesOf(MetaRoot root, MetaData ancestor) { + List out = new ArrayList<>(); + for (MetaData cand : RequirementClaims.structuralChildren(root)) { + if (!TYPE_OBJECT.equals(cand.getType()) || cand == ancestor) continue; + Set seen = Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + MetaData cur = cand.getSuperData(); + while (cur != null && seen.add(cur)) { + if (cur == ancestor) { + out.add(cand.getName()); + break; + } + cur = cur.getSuperData(); + } + } + return out; + } + + /** + * The entities object coverage measures: root-level, non-abstract {@code object.entity}. + * {@code object.value} and {@code object.projection} are exempt. + */ + private static List coverableEntities(MetaRoot root) { + List out = new ArrayList<>(); + for (MetaData n : RequirementClaims.structuralChildren(root)) { + // ADR-0039 sanctioned own-only read: abstractness describes THIS declaration and is + // never inherited, so IOUtil.isAbstract reads @isAbstract own-only. + if (TYPE_OBJECT.equals(n.getType()) && OBJECT_SUBTYPE_ENTITY.equals(n.getSubType()) && !IOUtil.isAbstract(n)) { + out.add(n); + } + } + return out; + } + + // ------------------------------------------------------------------ + // the gate + // ------------------------------------------------------------------ + + /** + * Check the requirement tree against the loaded model. No requirements: no diagnostics. + * Rows are evaluated per requirement in the order of the reference's code table; object + * coverage runs once, after every requirement. + */ + public static List check(MetaRoot root, Scan scan) { + List out = new ArrayList<>(); + if (scan.addressed().isEmpty()) return out; // opt-in by declaration + + Map ledger = ledgerIndex(scan.addressed()); + + for (Addressed a : scan.addressed()) { + MetaRequirement req = a.node(); + String reqPath = a.path(); + boolean architectural = req.isArchitectural(); + Integer level = req.getLevel(); + List refs = req.getImplementedBy(); + + // -- the level rules ------------------------------------------------ + // A functional requirement MUST be levelled. An architectural one MAY be, and + // levelling is the opt-in: unlevelled it is a flat policy these rules do not touch. + boolean levelled = level != null; + if (!architectural || levelled) { + if (!levelled || level < MetaRequirement.MIN_LEVEL || level > MetaRequirement.MAX_LEVEL) { + out.add(error(ERR_REQUIREMENT_BAD_LEVEL, reqPath, + "level must be an integer " + MetaRequirement.MIN_LEVEL + "-" + MetaRequirement.MAX_LEVEL + + " (got " + show(level) + "). " + + "L1 solution, L2 segment (app/library), L3 service, L4 object, L5 member." + + (architectural + ? " On an architectural requirement the level is optional — omit it for a flat policy." + : ""))); + } + // Nesting IS the hierarchy, so a child must sit strictly below its parent. + MetaData parent = req.getParent(); + if (parent instanceof MetaRequirement parentReq) { + Integer pl = parentReq.getLevel(); + if (pl != null && level != null && level <= pl) { + out.add(error(ERR_REQUIREMENT_LEVEL_NESTING, reqPath, + "nested under \"" + parent.getShortName() + "\" (level " + pl + ") but declares level " + + level + ". Nesting is the hierarchy — a child sits strictly below its parent.")); + } + } + } + + // -- the link boundary ---------------------------------------------- + if (!refs.isEmpty() && !req.mayReferenceModel()) { + out.add(error(ERR_REQUIREMENT_LINK_ABOVE_FLOOR, reqPath, + "'implementedBy' is legal at L" + MetaRequirement.LINK_FLOOR_LEVEL + " (object) and L" + + MetaRequirement.MAX_LEVEL + " (member) only. L1-L3 are organisational and never reference " + + "the model — move the links to a nested L" + MetaRequirement.LINK_FLOOR_LEVEL + " child.")); + continue; + } + + String referrerPkg = effectivePackage(req); + for (String ref : refs) { + RequirementClaims.MemberRef split = RequirementClaims.splitMemberRef(ref); + MetaData node = RequirementClaims.resolveClaimTarget(root, scan.symbols(), split.owner(), referrerPkg); + boolean isObjectRef = split.path().isEmpty(); + + // GRAIN stays functional-only: on a levelled architectural requirement the upper + // tiers are a quality taxonomy, so a claim set may legitimately mix grains. + if (!architectural && level != null && level == MetaRequirement.LINK_FLOOR_LEVEL && !isObjectRef) { + out.add(error(ERR_REQUIREMENT_L4_NOT_OBJECT, reqPath, + "L" + MetaRequirement.LINK_FLOOR_LEVEL + " references an object; '" + ref + "' names a member. " + + "Move it to a nested L" + MetaRequirement.LEVEL_MEMBER + " child, or reference the object itself.")); + continue; + } + if (!architectural && level != null && level == MetaRequirement.LEVEL_MEMBER && isObjectRef) { + out.add(error(ERR_REQUIREMENT_L5_NOT_MEMBER, reqPath, + "L" + MetaRequirement.LEVEL_MEMBER + " references a member (field, view or identity); '" + ref + + "' names an object. Move it to its L" + MetaRequirement.LINK_FLOOR_LEVEL + " parent.")); + continue; + } + + boolean resolved = node != null && (isObjectRef || RequirementClaims.resolveMember(node, split.path()) != null); + // Severity is CONDITIONAL ON STATUS: on `planned` the nodes do not exist YET. + if (!resolved && req.requiresLiveNodes()) { + // The hint answers an OBJECT that failed to resolve; when the object resolved + // and only the member is gone, name the member instead. + String hint = node == null + ? ValidationPhase.didYouMeanHint(root, split.owner()) + : missingMemberHint(node, split.path()); + out.add(error(ERR_REQUIREMENT_DANGLING_REF, reqPath, + "'" + ref + "' does not resolve in the loaded model (status '" + show(req.getStatus()) + + "' — the model moved and the requirement is stale)." + hint)); + } + } + + // -- @supersededBy resolution (FR-039) -------------------------------- + // Resolved against the LEDGER, not the model: a capability is replaced by another one. + String superseded = req.getSupersededBy(); + if (superseded != null && resolveRequirementRef(ledger, superseded, referrerPkg) == null) { + out.add(error(ERR_REQUIREMENT_DANGLING_REF, reqPath, + "@supersededBy '" + superseded + "' does not name a requirement in the loaded " + + "ledger. It must name the requirement that REPLACED this one — if nothing did, " + + "drop the attribute and let `notes` carry why the capability went.")); + } + + // -- architectural universality: claim-set arithmetic ------------------- + String status = req.getStatus(); + boolean live = req.requiresLiveNodes(); + if (architectural && live && refs.isEmpty() && req.mayReferenceModel()) { + out.add(error(ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS, reqPath, + "architectural requirement is '" + show(status) + "' but nothing implements it. " + + "Its check is universality — a claim set of zero means the policy is declared and unapplied.")); + } + + // -- disposition: the decision, not the state ------------------------- + String disposition = req.getDisposition(); + if (disposition != null && !req.hasOutstandingWork()) { + out.add(warn(WARN_REQUIREMENT_DISPOSITION_NOT_APPLICABLE, reqPath, + "carries @disposition '" + disposition + "' but its status is '" + show(status) + "', which has no " + + "outstanding work to decide about. A disposition is meaningful on 'planned' and 'partial' only — " + + "on any other status the decision IS the status.")); + } + + // -- functional existence, SUBTREE-scoped ----------------------------- + // The strict switch raises the SEVERITY only; the code keeps its WARN_ name because it + // identifies the finding. + if (!architectural && live && !subtreeClaimsAnything(req)) { + out.add(new Diagnostic(scan.requireImplementers() ? Severity.ERROR : Severity.WARN, + WARN_REQUIREMENT_NOTHING_IMPLEMENTS, reqPath, + "is '" + show(status) + "' but neither it nor anything nested under it names an " + + "implementing node. A functional requirement's check is existence — a subtree that claims " + + "nothing is a capability nobody built.")); + } + + if (MetaRequirement.DISPOSITION_DEFERRED.equals(disposition) && req.getTrackedBy().isEmpty()) { + out.add(warn(WARN_REQUIREMENT_DEFERRED_UNTRACKED, reqPath, + "is deferred but names no @trackedBy issue. Deferring without a ticket is how a known gap " + + "becomes an unknown one — nothing will raise it again.")); + } + } + + // -- object coverage: adding an entity forces a requirement --------------- + // Binary per entity, object grain only, adopter-authored requirements only. + if (scan.measureCoverage()) { + for (MetaData ent : coverableEntities(root)) { + String key = ent.getName(); + if (!scan.claimedObjects().contains(key)) { + out.add(new Diagnostic(OBJECT_COVERAGE_SEVERITY, WARN_REQUIREMENT_OBJECT_UNCLAIMED, null, + "no requirement claims '" + key + "'. Add it to an L" + MetaRequirement.LINK_FLOOR_LEVEL + + " requirement's 'implementedBy'.")); + } + } + } + return out; + } + + private static Diagnostic error(String code, String path, String message) { + return new Diagnostic(Severity.ERROR, code, path, message); + } + + private static Diagnostic warn(String code, String path, String message) { + return new Diagnostic(Severity.WARN, code, path, message); + } + + /** The reference prints a missing value as {@code undefined}. */ + private static String show(Object value) { + return value == null ? "undefined" : String.valueOf(value); + } + + /** + * Names the FIRST segment of a non-empty, unresolvable {@code path} under {@code obj}, and + * the node it was looked for under: {@code acme::shop::Order.reference.display} with + * {@code reference} still present reads "'acme::shop::Order.reference' has no member + * 'display'", not the whole tail. + */ + private static String missingMemberHint(MetaData obj, List path) { + int found = 0; + while (found < path.size() - 1 && RequirementClaims.resolveMember(obj, path.subList(0, found + 1)) != null) found++; + List parent = new ArrayList<>(); + parent.add(obj.getName()); + parent.addAll(path.subList(0, found)); + return " '" + String.join(".", parent) + "' has no member '" + path.get(found) + "'."; + } + + /** + * True when this requirement, or anything nested beneath it, names an implementing node. + * Subtree-scoped deliberately: a parent that delegates everything to its children implements + * nothing directly, and flagging that would fire on the correct shape of every tree. + * Existence is about NAMING, not resolving. + */ + private static boolean subtreeClaimsAnything(MetaRequirement req) { + if (!req.getImplementedBy().isEmpty()) return true; + for (MetaRequirement child : req.getChildRequirements()) { + if (subtreeClaimsAnything(child)) return true; + } + return false; + } + + /** + * The ledger index {@code @supersededBy} resolves against: {@code ::} + * for every packaged requirement, and the bare {@code } (first one wins). + */ + private static Map ledgerIndex(List addressed) { + Map keyed = new HashMap<>(); + for (Addressed a : addressed) { + String pkg = effectivePackage(a.node()); + if (!pkg.isEmpty()) keyed.put(pkg + MetaData.PKG_SEPARATOR + a.path(), a.node()); + keyed.putIfAbsent(a.path(), a.node()); + } + return keyed; + } + + /** An FQN binds exactly; a bare ref prefers the referrer's own package. */ + private static MetaRequirement resolveRequirementRef(Map ledger, String ref, String referrerPkg) { + MetaRequirement exact = ledger.get(ref); + if (exact != null) return exact; + if (!referrerPkg.isEmpty()) return ledger.get(referrerPkg + MetaData.PKG_SEPARATOR + ref); + return null; + } + + // ------------------------------------------------------------------ + // the summary + // ------------------------------------------------------------------ + + /** + * Count what the ledger contains, for the line printed on EVERY run, including a clean one. + * + * @return the counts, or {@code null} when the model declares no requirement + */ + public static Summary summarise(MetaRoot root, Scan scan) { + if (scan.addressed().isEmpty()) return null; // opt-in by declaration + + // `undecided` counts only the requirements a @disposition could actually SETTLE. Every + // ANCESTOR of a node with outstanding work is a roll-up and is excluded, whether or not + // the descendant has a disposition. Ancestry is by dotted path SEGMENT, not by string + // prefix: `DeliveryNote` is not under `Delivery`. + Set rollUpAncestors = new HashSet<>(); + for (Addressed a : scan.addressed()) { + if (!a.node().hasOutstandingWork()) continue; + String[] segments = a.path().split("\\.", -1); + for (int i = 1; i < segments.length; i++) { + rollUpAncestors.add(String.join(".", java.util.Arrays.asList(segments).subList(0, i))); + } + } + + int functional = 0, architectural = 0, undecided = 0, deferredUntracked = 0; + Map byStatus = new LinkedHashMap<>(); + for (Addressed a : scan.addressed()) { + MetaRequirement req = a.node(); + if (req.isArchitectural()) architectural++; + else functional++; + if (req.getStatus() != null) byStatus.merge(req.getStatus(), 1, Integer::sum); + if (req.hasOutstandingWork() && req.getDisposition() == null && !rollUpAncestors.contains(a.path())) undecided++; + if (MetaRequirement.DISPOSITION_DEFERRED.equals(req.getDisposition()) && req.getTrackedBy().isEmpty()) deferredUntracked++; + } + + Integer entitiesClaimed = null; + Integer entitiesTotal = null; + if (scan.measureCoverage()) { + int total = 0, claimedCount = 0; + for (MetaData ent : coverableEntities(root)) { + total++; + if (scan.claimedObjects().contains(ent.getName())) claimedCount++; + } + entitiesClaimed = claimedCount; + entitiesTotal = total; + } + return new Summary(scan.addressed().size(), functional, architectural, byStatus, undecided, + deferredUntracked, entitiesClaimed, entitiesTotal); + } + + // ------------------------------------------------------------------ + // what verify prints (everything after the port's own command prefix) + // ------------------------------------------------------------------ + + /** + * {@code requirements: entries (...) — , ...; } then either the + * not-measured sentence or {@code / entities claimed, counted over + * metadata file(s).} Statuses appear in declaration order, zero counts omitted. + */ + public static String summaryText(Summary s, int metadataFiles) { + List parts = new ArrayList<>(); + for (String status : MetaRequirement.STATUSES) { + int n = s.byStatus().getOrDefault(status, 0); + if (n > 0) parts.add(n + " " + status); + } + return "requirements: " + s.total() + " entries (" + s.functional() + " functional, " + + s.architectural() + " architectural) — " + String.join(", ", parts) + "; " + + (s.entitiesTotal() == null + ? "coverage: not measured (no project-authored requirements)." + : s.entitiesClaimed() + "/" + s.entitiesTotal() + " entities claimed, counted over " + + metadataFiles + " metadata file(s)."); + } + + /** The recorded-gaps line, or {@code null} when nothing is undecided. */ + public static String undecidedText(Summary s) { + if (s.undecided() <= 0) return null; + return "requirements: " + s.undecided() + " recorded gap(s) with no @disposition. " + + "These are known problems nobody has ruled on — set 'accepted' or 'deferred' to close the question."; + } + + /** {@code []: }, or {@code : } when there is no path. */ + public static String formatDiagnostic(Diagnostic d) { + return " " + d.code() + (d.path() == null ? "" : " [" + d.path() + "]") + ": " + d.message(); + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/requirement/RequirementClaims.java b/server/java/metadata/src/main/java/com/metaobjects/requirement/RequirementClaims.java new file mode 100644 index 000000000..2a3e745b5 --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/requirement/RequirementClaims.java @@ -0,0 +1,133 @@ +package com.metaobjects.requirement; + +import com.metaobjects.MetaData; +import com.metaobjects.MetaRoot; +import com.metaobjects.attr.MetaAttribute; +import com.metaobjects.validation.SymbolTable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Resolves an {@code @implementedBy} reference to the model node it names. ONE resolver, + * shared by the requirement gate ({@link RequirementCheck}) and the requirement-test + * generator, so the ADR-0042 package-local binding contract has a single owner. + * + *

Java port of {@code resolve-claim.ts} (and {@code splitMemberRef} from the TypeScript + * gate). Objects resolve through {@link SymbolTable}, the loader's own resolver, never a + * parallel name scan (#228).

+ */ +public final class RequirementClaims { + + private RequirementClaims() {} + + /** A reference split into its owning root-level node and the dotted member path under it. */ + public record MemberRef(String owner, List path) {} + + /** + * Split a member reference into its owning ref and the dotted member path. {@code ::} + * qualifies the ROOT-level node only, so the owner ends at the first {@code .} after the + * last {@code ::}: {@code acme::sales::Order.total.display} gives owner + * {@code acme::sales::Order} and path {@code [total, display]}. + */ + public static MemberRef splitMemberRef(String ref) { + int pkgEnd = ref.lastIndexOf(MetaData.PKG_SEPARATOR); + int from = pkgEnd == -1 ? 0 : pkgEnd + MetaData.PKG_SEPARATOR.length(); + int dot = ref.indexOf('.', from); + if (dot == -1) return new MemberRef(ref, List.of()); + return new MemberRef(ref.substring(0, dot), Arrays.asList(ref.substring(dot + 1).split("\\.", -1))); + } + + /** + * Resolve the owner segment of an {@code @implementedBy} reference to the node it names. + * OBJECTS FIRST, through the loader's symbol table; then ROOT-LEVEL NON-OBJECT nodes (a + * {@code template.prompt} and its siblings). Requirements are excluded: hierarchy is + * nesting, and a requirement claiming a requirement would be a second parent mechanism. + * + * @param referrerPkg the effective package of the requirement making the claim + * @return the node, or {@code null} when nothing matches or the match is ambiguous + */ + public static MetaData resolveClaimTarget(MetaRoot root, String owner, String referrerPkg) { + return resolveClaimTarget(root, SymbolTable.build(root), owner, referrerPkg); + } + + /** {@link #resolveClaimTarget(MetaRoot, String, String)} over a symbol table the caller built once. */ + public static MetaData resolveClaimTarget(MetaRoot root, SymbolTable symbols, String owner, String referrerPkg) { + MetaData object = symbols.resolveObject(owner, referrerPkg); + if (object != null) return object; + + List candidates = new ArrayList<>(); + for (MetaData c : structuralChildren(root)) { + if (!"object".equals(c.getType()) && !MetaRequirement.TYPE_REQUIREMENT.equals(c.getType())) candidates.add(c); + } + + // A fully-qualified reference binds exactly, like every other FQN in the model. + if (owner.contains(MetaData.PKG_SEPARATOR)) { + for (MetaData c : candidates) if (owner.equals(c.getName())) return c; + return null; + } + // A bare reference prefers the referrer's own package, then a root-level (unpackaged) + // node of that bare name. An ambiguous bare name binds NOTHING. + if (referrerPkg != null && !referrerPkg.isEmpty()) { + String localKey = referrerPkg + MetaData.PKG_SEPARATOR + owner; + MetaData local = null; + int matches = 0; + for (MetaData c : candidates) { + if (localKey.equals(c.getName())) { local = c; matches++; } + } + if (matches == 1) return local; + } + MetaData bare = null; + int matches = 0; + for (MetaData c : candidates) { + if (owner.equals(c.getShortName()) && owner.equals(c.getName())) { bare = c; matches++; } + } + return matches == 1 ? bare : null; + } + + /** + * Walk dotted member segments by CHILD NAME from a node, to full depth. Attributes are not + * members: they are children in this port's tree and are not in TypeScript's. + * + * @return the node reached, or {@code null} when a segment does not resolve + */ + public static MetaData resolveMember(MetaData obj, List path) { + MetaData cur = obj; + for (String seg : path) { + if (cur == null) return null; + MetaData next = null; + for (MetaData c : structuralChildren(cur)) { + if (seg.equals(c.getShortName())) { next = c; break; } + } + cur = next; + } + return cur; + } + + /** + * Resolve a full {@code @implementedBy} reference, owner plus any dotted member segments, to + * the node it names, or {@code null}. Resolution walks to the FULL depth of the reference, + * so {@code Council.slug.display} yields the view node rather than stopping at the field. + */ + public static MetaData resolveClaim(MetaRoot root, String ref, String referrerPkg) { + // Segments split on every dot, as the reference does: a package qualifies the root + // node only and carries no dot, so the first segment is the whole owner. + String[] segs = ref.split("\\.", -1); + MetaData owner = resolveClaimTarget(root, segs[0], referrerPkg); + if (owner == null || segs.length == 1) return owner; + return resolveMember(owner, Arrays.asList(segs).subList(1, segs.length)); + } + + /** + * The node's EFFECTIVE children, attributes excluded: own plus inherited through + * {@code extends} (ADR-0039 default, the same set TypeScript's {@code children()} reads). + */ + static List structuralChildren(MetaData node) { + List out = new ArrayList<>(); + for (MetaData c : node.getChildren(MetaData.class, true)) { + if (!(c instanceof MetaAttribute)) out.add(c); + } + return out; + } +} diff --git a/server/java/metadata/src/test/java/com/metaobjects/conformance/RequirementCheckConformanceTest.java b/server/java/metadata/src/test/java/com/metaobjects/conformance/RequirementCheckConformanceTest.java new file mode 100644 index 000000000..23edd6945 --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/conformance/RequirementCheckConformanceTest.java @@ -0,0 +1,201 @@ +package com.metaobjects.conformance; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.metaobjects.MetaRoot; +import com.metaobjects.library.LibrarySources; +import com.metaobjects.loader.DirectorySource; +import com.metaobjects.loader.LoaderOptions; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.loader.MetaDataSource; +import com.metaobjects.requirement.RequirementCheck; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Cross-port requirement-check conformance corpus — {@code fixtures/requirement-check-conformance/}. + * See that directory's README.md for the fixture format and the three runner steps: load + * {@code input/} STRICT, in file-name order, beside the libraries {@code options.json} + * names; run the gate with no scope predicate and no forced coverage answer; compare the + * diagnostics (an unordered multiset) and the summary with {@code expected.json}. + * + *

Mirrors the TypeScript reference ({@code requirement-check-conformance.test.ts}). A + * mismatch here is a bug in THIS port's gate, never in the fixture.

+ */ +@RunWith(Parameterized.class) +public class RequirementCheckConformanceTest { + + private static final Path CORPUS = CorpusRoot.locate().resolveSibling("requirement-check-conformance"); + + /** The whole of {@code options.json}: a key outside this list is refused, not ignored. */ + private static final List OPTION_KEYS = List.of("libraries", "requireImplementers"); + + /** A case whose {@code input/} must be, file for file, another case's. */ + private static final Map SAME_INPUT_AS = Map.of( + "require-implementers", "nothing-implements-subtree"); + + private static final Pattern DOCUMENTED_CASE = Pattern.compile("^\\| `([^`]+)` \\|", Pattern.MULTILINE); + + @Parameterized.Parameters(name = "{0}") + public static Collection fixtures() throws IOException { + List params = new ArrayList<>(); + for (String name : caseNames()) params.add(new Object[]{name}); + return params; + } + + private static List caseNames() throws IOException { + try (Stream dirs = Files.list(CORPUS)) { + return dirs.filter(Files::isDirectory).map(d -> d.getFileName().toString()).sorted().toList(); + } + } + + private final String name; + + public RequirementCheckConformanceTest(String name) { + this.name = name; + } + + @Test + public void everyCaseOnDiskIsDocumentedInTheReadmeAndNothingElseIs() throws IOException { + String readme = Files.readString(CORPUS.resolve("README.md"), StandardCharsets.UTF_8); + String section = readme.substring(readme.indexOf("\n## Cases\n")); + int next = section.indexOf("\n## ", 1); + if (next > 0) section = section.substring(0, next); + List documented = new ArrayList<>(); + Matcher m = DOCUMENTED_CASE.matcher(section); + while (m.find()) documented.add(m.group(1)); + documented.sort(Comparator.naturalOrder()); + assertEquals(caseNames(), documented); + } + + @Test + public void fixture() throws IOException { + Path dir = CORPUS.resolve(name); + Path expectedFile = dir.resolve("expected.json"); + assertTrue(name + ": no expected.json", Files.isRegularFile(expectedFile)); + JsonObject expected = JsonParser.parseString(Files.readString(expectedFile, StandardCharsets.UTF_8)).getAsJsonObject(); + + List libraries = new ArrayList<>(); + boolean requireImplementers = false; + Path optionsFile = dir.resolve("options.json"); + if (Files.isRegularFile(optionsFile)) { + JsonObject options = JsonParser.parseString(Files.readString(optionsFile, StandardCharsets.UTF_8)).getAsJsonObject(); + for (String key : options.keySet()) { + assertTrue(optionsFile + ": unknown option " + key, OPTION_KEYS.contains(key)); + } + if (options.has("libraries")) { + for (JsonElement l : options.getAsJsonArray("libraries")) libraries.add(l.getAsString()); + } + if (options.has("requireImplementers")) { + assertTrue(optionsFile + ": 'requireImplementers' must be a boolean", + options.get("requireImplementers").getAsJsonPrimitive().isBoolean()); + requireImplementers = options.get("requireImplementers").getAsBoolean(); + } + } + + String twin = SAME_INPUT_AS.get(name); + if (twin != null) assertEquals(name, inputFiles(twin), inputFiles(name)); + + // Step 1: STRICT, libraries first and then input/ in file-name order, one batch. + MetaDataLoader loader = new MetaDataLoader(LoaderOptions.create(false, false, true), + MetaDataLoader.SUBTYPE_MANUAL, "requirement_check_" + name.replace('-', '_')); + loader.init(); + List sources = new ArrayList<>(LibrarySources.librarySources(libraries)); + sources.addAll(new DirectorySource(dir.resolve("input"), new DirectorySource.Options()).expandToList()); + loader.load(sources); + assertTrue(name + ": expected a clean strict load, got: " + loader.getErrors(), loader.getErrors().isEmpty()); + + // Step 2: no scope predicate, no forced coverage answer. + MetaRoot root = loader.getRoot(); + RequirementCheck.Scan scan = RequirementCheck.scan(root, new RequirementCheck.Options(null, requireImplementers)); + + // Step 3: compare. + assertEquals(name, multiset(expected.getAsJsonArray("diagnostics")), actualRows(RequirementCheck.check(root, scan))); + assertEquals(name, expectedSummary(expected.get("summary")), actualSummary(RequirementCheck.summarise(root, scan))); + } + + private static Map inputFiles(String caseName) throws IOException { + Map out = new TreeMap<>(); + try (Stream files = Files.list(CORPUS.resolve(caseName).resolve("input"))) { + for (Path f : files.toList()) out.put(f.getFileName().toString(), Files.readString(f, StandardCharsets.UTF_8)); + } + return out; + } + + /** A row is {@code severity | code | path | message}; an absent path compares as the empty string. */ + private static List multiset(JsonArray diagnostics) { + List rows = new ArrayList<>(); + for (JsonElement e : diagnostics) { + JsonObject d = e.getAsJsonObject(); + rows.add(row(d.get("severity").getAsString(), d.get("code").getAsString(), + d.has("path") ? d.get("path").getAsString() : "", d.get("message").getAsString())); + } + rows.sort(Comparator.naturalOrder()); + return rows; + } + + private static List actualRows(List diagnostics) { + List rows = new ArrayList<>(); + for (RequirementCheck.Diagnostic d : diagnostics) { + rows.add(row(d.severity().name().toLowerCase(java.util.Locale.ROOT), d.code(), + d.path() == null ? "" : d.path(), d.message())); + } + rows.sort(Comparator.naturalOrder()); + return rows; + } + + private static String row(String severity, String code, String path, String message) { + return severity + " | " + code + " | " + path + " | " + message; + } + + /** The summary as a key-sorted {@code key=value} list; {@code null} for no requirement. */ + private static List expectedSummary(JsonElement summary) { + if (summary == null || summary.isJsonNull()) return null; + JsonObject s = summary.getAsJsonObject(); + List out = new ArrayList<>(); + for (String key : s.keySet()) { + if (key.equals("byStatus")) { + for (Map.Entry e : s.getAsJsonObject("byStatus").entrySet()) { + out.add("byStatus." + e.getKey() + "=" + e.getValue().getAsInt()); + } + } else { + out.add(key + "=" + s.get(key).getAsInt()); + } + } + out.sort(Comparator.naturalOrder()); + return out; + } + + private static List actualSummary(RequirementCheck.Summary s) { + if (s == null) return null; + List out = new ArrayList<>(Arrays.asList( + "total=" + s.total(), "functional=" + s.functional(), "architectural=" + s.architectural(), + "undecided=" + s.undecided(), "deferredUntracked=" + s.deferredUntracked())); + for (Map.Entry e : s.byStatus().entrySet()) out.add("byStatus." + e.getKey() + "=" + e.getValue()); + if (s.entitiesTotal() != null) out.add("entitiesTotal=" + s.entitiesTotal()); + if (s.entitiesClaimed() != null) out.add("entitiesClaimed=" + s.entitiesClaimed()); + out.sort(Comparator.naturalOrder()); + return out; + } +} From d88ae3779292a86ee391f59dc2f2f9ac72b2bef3 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 6 Oct 2026 00:14:06 -0400 Subject: [PATCH 13/43] =?UTF-8?q?docs(plan):=20requirements=20slice=201=20?= =?UTF-8?q?=E2=80=94=20what=20reviewing=20the=20identity=20corpus=20found?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The effective package of a requirement nested under a package-declaring parent is the parent's, as the reference has it; the mangle class is ASCII and excludes the underscore; an eighth named predicate pins an absent level; an abstract requirement is pinned as collected; and the check corpus gains a member reference whose owner does not resolve. Refs the plan and ADR-0057. --- ...ents-slice-1-checks-and-test-generators.md | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md b/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md index 810844f57..9a6510358 100644 --- a/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md +++ b/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md @@ -20,7 +20,7 @@ The plan as merged asked nine questions. The owner answered them, and added one |---|---| | **Answer 8, "make it uniform".** The filter that selects which requirements get a test offers the same two options in all five ports: a predicate over one shared requirement view, and the uncovered-warning switch. | Table F, Table I (two new rows), Table J (the `filter` option, seven named predicates, seven `filter-*` cases), Tasks 3, 4, 6, 8, 10, 11 | | **Added requirement: the generator is application-owned.** "Whatever we do for this around requirements and testing needs to also eject and be owned by the application. It may change or significantly. This is just a recommended approach." In every port the `requirement-tests` generator, with its default renderer, is ejectable through that port's existing eject mechanism, and a test proves an unedited ejected copy produces byte-identical output. The checks in `verify` stay stock: they are the shared contract. | Table L (new), Table K, Tasks 3, 6, 8, 10, 11, 12 | -| A separate identity case `worked-example` holds the model of Tables F, G and H; `concern-fanout` keeps its fan-out meaning. One case could not pin both. The identity corpus is 24 cases. | Table J, Tasks 4, 6, 8, 11 | +| A separate identity case `worked-example` holds the model of Tables F, G and H; `concern-fanout` keeps its fan-out meaning. One case could not pin both. The identity corpus is 26 cases after review (an eighth predicate and a case for an absent level, and a case for an abstract requirement). | Table J, Tasks 4, 6, 8, 11 | | Answers 1 to 7 and 9 take the plan's stated defaults, with answers 3 and 4 confirmed as written. | [Answered questions](#answered-questions) | Every port was confirmed to have an eject mechanism before Table L was written (`meta eject`, `metaobjects eject`, `mvn metaobjects:eject` for Java and Kotlin, `dotnet meta eject`); none had to be invented. @@ -83,7 +83,7 @@ What the code does that the documents do not say. Each row changes a task below: |---|---| | Walk | Depth-first over the loaded root's children, in declaration order, descending through **every** node. A node of type `requirement` is collected. | | Path | The dotted chain of requirement names from the root to the node. Only a requirement contributes a segment; a non-requirement node between two requirements is walked through and adds nothing. No package. Example: `Shop.Orders.Recorded`. | -| Effective package | `node.package`, else the file's default package (`fileDefaultPackage`), else `""`. Read on the requirement node itself. | +| Effective package | The package the loader resolved for the requirement node: its own declared `package`, else the package of the nearest enclosing node that declares one, else the file's default package, else `""`. In TypeScript that is `node.package ?? node.fileDefaultPackage ?? ""` read on the node itself, because the loader has already carried a parent's declared package down to its children. A port whose loader does not do that walks up. (Corrected in review: the first wording, read literally, gave a child of a package-declaring requirement the file's package; the reference gives it the parent's. Identity case `filter-by-package` pins it.) | | Qualified address | `::`, or the bare path when the package is `""`. Example: `acme::shop::Shop.Orders.Recorded`. | Diagnostics use the **path**. Test identities use the **qualified address**. @@ -165,7 +165,7 @@ One record per generated test. This is what the identity corpus pins, and it is | `path` | The requirement's path (Table A). | | `unit` | Under `grain: concern` (default): one test per **distinct** `.` among the requirement's resolved targets, first-seen order. Under `grain: member`: one test per **distinct** `implementedBy` entry that resolves, the reference exactly as authored. In both grains a requirement with no resolved target yields exactly one test with unit `*`. | | `id` | ` []`. | -| `witnessKey` | `req_` + mangle(qualified address), then `__` + mangle(unit) unless the unit is `*`. mangle replaces each maximal run of characters outside `[A-Za-z0-9]` with one `_`. | +| `witnessKey` | `req_` + mangle(qualified address), then `__` + mangle(unit) unless the unit is `*`. mangle replaces each maximal run of characters outside `[A-Za-z0-9]` with one `_`. The class is ASCII and excludes `_` itself: `Orders__Recorded` mangles to `Orders_Recorded`, and `Café.Réglé` to `Caf_R_gl_`. A port's idiomatic word class (`\W`, `isalnum`, `isLetterOrDigit`) is wrong on both. The corpus pins the doubled underscore; the non-ASCII half is pinned by a direct test of the key function in every port, because the loaders are not known to agree on non-ASCII names. | | `status` | The requirement's status. | | `skip` | `null` when the status is `live` or `partial`; otherwise the status (`planned` or `retired`). Derived from the status lists, never a literal set. | | `digest` | Table G. | @@ -339,7 +339,7 @@ Both follow the field-lint corpus shape: each case is a directory with `input/` | `clean-fully-claimed` | An L1 to L5 tree claiming every entity: no diagnostics, full summary | | `dangling-object-live`, `dangling-object-partial` | Row 6 for each live status | | `dangling-object-planned-clean` | A planned requirement may name nodes that do not exist | -| `dangling-object-did-you-mean` | Row 6's hint when the short name exists in another package | +| `dangling-object-did-you-mean` | Row 6's hint when the short name exists in another package, for a bare object reference and for a member reference whose **owner** does not resolve (`Invoice.total`): the gate splits first and hints on the owner, so the message names `Invoice`, not the member. Added in review: every other dotted dangling reference in the corpus has a resolved owner, so this branch had no case | | `dangling-bare-name-in-two-packages` | A bare name present in two other packages binds nothing | | `dangling-member-of-resolved-object`, `dangling-member-first-missing-segment` | Row 6's member hint, at one and two levels | | `claim-on-root-template` | A reference to a root-level `template.prompt` resolves | @@ -378,6 +378,7 @@ Both follow the field-lint corpus shape: each case is a directory with `input/` | `package-acme-shop` | `package` is `acme::shop` | | `path-under-Shop` | `path` is `Shop` or starts with `Shop.` | | `claims-entity` | `implementedByTypes` contains `object.entity` | +| `unlevelled` | `level` is absent (not `0`, not a sentinel) | `expected.json`: @@ -406,11 +407,13 @@ Both follow the field-lint corpus shape: each case is a directory with `input/` | `digest-claim-fields` | Two requirements differing in statement differ in digest; two differing only in `title`, `notes`, `disposition` and `trackedBy` do not | | `digest-inherited` | A requirement inheriting its statement through `extends` hashes the effective text | | `digest-multibyte-and-crlf` | A non-ASCII statement and a `\r\n` in the counterexample | -| `witness-key-collision` | `Orders.Recorded` beside `Orders_Recorded`: one entry in `collisions` | +| `witness-key-collision` | `Orders.Recorded` beside `Orders_Recorded`: one entry in `collisions`. A third requirement with a doubled underscore in its name does not collide and pins that `_` is outside the kept class | +| `abstract-collected` | An `abstract` functional L4 gets a test like any other requirement (Table A: every requirement node is collected). Pinned as the reference behaves so the ports cannot drift apart; the owner has not ruled on whether an abstract requirement should be tested | | `filter-all` | `filter: all` over an L1 to L5 tree with one architectural policy: every requirement gets a test, the L1 to L3 nodes with unit `*` | | `filter-by-subtype` | `filter: architectural`: an unlevelled policy and a levelled one get tests, the functional nodes none. Pins `subType`, and that an absent `level` reaches the predicate as absent | | `filter-by-status` | `filter: live`: a partial and a planned requirement are dropped. Pins `status` | | `filter-by-level` | `filter: level-5`: only the L5 nodes. Pins `level` | +| `filter-by-absent-level` | `filter: unlevelled`: only the flat architectural policies. Pins that an absent `level` reaches the predicate as absent in every port | | `filter-by-package` | `filter: package-acme-shop` over two packages, one requirement taking its package from the file default. Pins `package` as the effective package | | `filter-by-path` | `filter: path-under-Shop`: `Shop` and its descendants, not a sibling `Shopfront`. Pins `path` | | `filter-by-claimed-concern` | `filter: claims-entity`: a requirement claiming an entity is kept, one claiming only a template or only an unresolvable name is dropped. Pins `implementedByTypes` as the resolved concerns | @@ -447,7 +450,7 @@ The requirement checks are **not** ejectable in any port. They are the shared co ## File structure -**Shared, new:** `fixtures/requirement-check-conformance/` (a `README.md` and the 43 cases of Table J), `fixtures/requirement-test-identity-conformance/` (a `README.md` and 24 cases), `spec/decisions/ADR-0057-requirement-checks-and-tests-in-every-port.md`, `scripts/write-requirement-corpus-expected.ts`. +**Shared, new:** `fixtures/requirement-check-conformance/` (a `README.md` and the 43 cases of Table J), `fixtures/requirement-test-identity-conformance/` (a `README.md` and 26 cases), `spec/decisions/ADR-0057-requirement-checks-and-tests-in-every-port.md`, `scripts/write-requirement-corpus-expected.ts`. **Shared, modified:** `fixtures/generator-registry-conformance/registry.json` (the `requirement-tests` entry's `ports`, one port per generator task). @@ -661,7 +664,7 @@ export function witnessKeyOf(qualifiedAddress: string, unit: string): string { ### Task 4: The requirement-test identity corpus and its TypeScript runner **Files:** -- Create: `fixtures/requirement-test-identity-conformance/README.md` and the 24 case directories of Table J +- Create: `fixtures/requirement-test-identity-conformance/README.md` and the 26 case directories of Table J - Create: `server/typescript/packages/codegen-ts/test/requirement-test-identity-conformance.test.ts` - Modify: `scripts/write-requirement-corpus-expected.ts` (the `identity` corpus) @@ -669,11 +672,11 @@ export function witnessKeyOf(qualifiedAddress: string, unit: string): string { - Consumes: `requirementTestIdentities`, `witnessKeyCollisions` (Task 3). - Produces: the corpus Tasks 6, 8, 10 and 11 run. -- [ ] **Step 1: Write the runner** on the pattern of Task 2's: load strict, assert no load error, compute `requirementTestIdentities(root, { grain, filter })` (the `filter` option is a name, mapped to a predicate by a table in the runner that holds exactly the seven rows of Table J; an unknown name fails the test) and `witnessKeyCollisions(...)`, compare with `expected.tests` (both sorted by `id`) and `expected.collisions`. Include the README-equals-disk test. +- [ ] **Step 1: Write the runner** on the pattern of Task 2's: load strict, assert no load error, compute `requirementTestIdentities(root, { grain, filter })` (the `filter` option is a name, mapped to a predicate by a table in the runner that holds exactly the eight rows of Table J; an unknown name fails the test) and `witnessKeyCollisions(...)`, compare with `expected.tests` (both sorted by `id`) and `expected.collisions`. Include the README-equals-disk test. - [ ] **Step 2: Write the inputs** for each row of Table J. `worked-example` holds the model of Tables F, G and H exactly, so its two expected digests are the pinned ones (`2714aa39…` and `4ddcd781…`). If the second does not come out as pinned, the input differs from the model the plan's author hashed: find the difference (level, statement or counterexample text) before touching Table H. Each `filter-*` case keeps a requirement the default filter would drop, or drops one it would keep, so a runner that ignores the option fails the case. - [ ] **Step 3: Write and review the expectations.** `bun scripts/write-requirement-corpus-expected.ts identity`, then check each file by hand: the unit set against Table F, each `witnessKey` by applying the mangle rule on paper, each `skip` against the status, and for `digest-claim-fields` that the digests differ and agree where the case says. Recompute one digest independently (`printf` the text of Table G into `sha256sum`) for `digest-multibyte-and-crlf`. - [ ] **Step 4: Write the README** (purpose, format, runner steps, case table, "Who asserts it"). -- [ ] **Step 5: Run.** `cd server/typescript/packages/codegen-ts && bun test test/requirement-test-identity-conformance.test.ts`. Expected: PASS, 25 tests. +- [ ] **Step 5: Run.** `cd server/typescript/packages/codegen-ts && bun test test/requirement-test-identity-conformance.test.ts`. Expected: PASS, 27 tests. - [ ] **Step 6: Commit.** `git commit -m "test(conformance): requirement-test identity corpus, run by the TypeScript reference"` --- @@ -810,8 +813,8 @@ requirementTests: ``` - [ ] **Step 1: Read first.** `requirement-walk.ts`, `generators/requirement-tests.ts` and `templates/requirement-test.ts` (the escaping comments are the specification of Review Focus 1), then an existing Python generator factory and its registry entry, `eject.build_owned` (how a `module:symbol` is imported relative to the config), and `test_schema_and_loader_accept_EXACTLY_the_same_keys`. **UNVERIFIED:** how `metaobjects gen` reports or removes a generated file that is no longer emitted (`_diff_report`, `_is_ours_for` in `cli.py`). Confirmed since the plan was written: a registry entry is ejectable when it carries `source=`, `tests/codegen/test_eject.py::test_every_ejectable_entry_names_its_source` fails on an entry without it, and `eject.build_owned` passes the build context to a factory with exactly one required positional parameter. Read `codegen/eject.py` and that test before writing the factory. -- [ ] **Step 2: Failing identity runner,** parametrized over `fixtures/requirement-test-identity-conformance/`, comparing records as dicts with the corpus's field names (`witnessKey`, not `witness_key`). The runner maps the `filter` name of `options.json` to a predicate over `RequirementView` with a table of exactly the seven rows of Table J, and passes it as `filter=`. Run it. Expected: FAIL. -- [ ] **Step 3: Implement `requirement_walk.py`** from Tables F and G. Digest lengths use `len(value.encode("utf-8"))`. Sort by `id` with plain string comparison. Run the identity runner. Expected: PASS for all 24 cases. +- [ ] **Step 2: Failing identity runner,** parametrized over `fixtures/requirement-test-identity-conformance/`, comparing records as dicts with the corpus's field names (`witnessKey`, not `witness_key`). The runner maps the `filter` name of `options.json` to a predicate over `RequirementView` with a table of exactly the eight rows of Table J, and passes it as `filter=`. Run it. Expected: FAIL. +- [ ] **Step 3: Implement `requirement_walk.py`** from Tables F and G. Digest lengths use `len(value.encode("utf-8"))`. Sort by `id` with plain string comparison. Add one direct test of the key function: `witness_key_of("acme::shop::Café.Réglé", "*") == "req_acme_shop_Caf_R_gl_"` (Table F: the class is ASCII). Run the identity runner. Expected: PASS for all 26 cases. - [ ] **Step 4: Failing generator tests** in `test_requirement_tests_generator.py`: - `the worked example renders the reference file`: generate from the identity corpus's `worked-example` input and compare with the Python block of Table H, byte for byte. - `one file per metamodel package`, `an unpackaged ledger writes test_root_requirements.py`. @@ -923,7 +926,7 @@ public interface RequirementTestRenderer { Generator args: `testPackage` (required), `witnessClass` (required, a fully-qualified class name), `grain` (`concern` or `member`), `renderer` (optional class name), `filter` (optional class name of a `RequirementTestFilter`), `warnUncovered` (`true` by default). - [ ] **Step 1: Read first.** `GeneratorBase.java` (`getArg`, the output-directory args), one existing `codegen-base` generator that writes Java source and its test, `CodegenCompileConformanceTest.java` in `codegen-spring` (how generated Java is compiled in a test), and how `MetaDataGeneratorMojo` instantiates a generator class by name. Also read `maven-plugin`'s `MetaDataEjectMojo.java`, `EjectSupport.java` and `EjectRoundTripTest.java`, and `codegen-spring/pom.xml`'s eject `` list. Settled since the plan was written: the generator lives in `codegen-spring` (Table L). **UNVERIFIED:** how a renderer or filter class named in an arg can be loaded so that a class on the **project's** classpath is found. The mojo loads the generator with a project class loader whose parent is the plugin's, so a packaged generator's own loader does not see project classes; read `AbstractMetaDataMojo.buildGenerators` and `createProjectClassLoader`, and pass or set the loader rather than guess. No generator arg names a loadable class today, so this is new ground: stop and report if it cannot be done without changing how every generator is constructed; how the JUnit Jupiter version is managed in `server/java/pom.xml` (it is used today only by the Kotlin and integration modules). -- [ ] **Step 2: Failing identity runner,** then implement `RequirementTestIdentities` from Tables F and G (`value.getBytes(StandardCharsets.UTF_8).length`, `MessageDigest` SHA-256, sort with `String.compareTo`). The runner maps the `filter` name of `options.json` to a `RequirementTestFilter` with a table of exactly the seven rows of Table J. Run: `mvn -q -f server/java/pom.xml -pl metadata test -Dtest=RequirementTestIdentityConformanceTest`. Expected: FAIL, then PASS for all 24 cases. +- [ ] **Step 2: Failing identity runner,** then implement `RequirementTestIdentities` from Tables F and G (`value.getBytes(StandardCharsets.UTF_8).length`, `MessageDigest` SHA-256, sort with `String.compareTo`; one direct test that `witnessKeyOf("acme::shop::Café.Réglé", "*")` is `req_acme_shop_Caf_R_gl_`, since `Character.isLetterOrDigit` is the wrong class). The runner maps the `filter` name of `options.json` to a `RequirementTestFilter` with a table of exactly the eight rows of Table J. Run: `mvn -q -f server/java/pom.xml -pl metadata test -Dtest=RequirementTestIdentityConformanceTest`. Expected: FAIL, then PASS for all 26 cases. - [ ] **Step 3: Failing generator tests:** the worked example (the identity corpus's `worked-example` input) emits `Requirements_acme_shop_Witnesses.java` and `Requirements_acme_shop_Test.java` in `testPackage`; the interface has one default member per non-skipped test and none for a skipped one; a skipped test carries `@Disabled` with the reason of Table H; the output imports only `org.junit.jupiter.api.*`; **the output compiles** with `javax.tools` together with a hand-written witness class, and invoking the test method on the compiled class throws `AssertionError` whose message holds `unimplemented requirement:` and the counterexample when the witness class does not override it, and returns normally when it does; prose with `"`, `\`, a newline and `*/` still compiles; a collision throws `GeneratorException` naming `ERR_REQUIREMENT_WITNESS_KEY_COLLISION` and both ids; `grain=member`; an unknown `grain` is refused with a clear error (no corpus pins this, so the test is the gate); a renderer class replaces one test and receives the digest; a `filter` class keeps an L3 requirement the default drops and it renders with unit `*`; the excluded requirements produce one capped warning and `warnUncovered=false` silences it; a `filter` class that cannot be loaded is a clear `GeneratorException`; a missing `witnessClass` arg is a clear `GeneratorException`; a model with no requirement writes nothing; `stale file` as in Task 6. **Eject:** `requirement-tests` is in `EjectedGeneratorsCompileTest`'s list and compiles package-renamed against the published API; the round-trip test ejects it, compiles the unchanged copy and gets byte-identical output over the `worked-example` model with non-default `grain` and `testPackage` args; `EjectSupportTest` still passes with its hard-coded non-ejectable set unchanged. - [ ] **Step 4: Implement** per Table H. The test class holds `private final Requirements__Witnesses witnesses = new ();`. Register `requirement-tests` with `Tier.NATIVE`, `Layer.CAPABILITY` (the enum's capability constant; read its name) and `ejectPath(JUnitRequirementTestsGenerator.class)`, add the `` to `codegen-spring/pom.xml`, and name the generator in `docs/ports/java.md`. Nothing in the generated header may carry the generator's own class or package name: the ejected copy has a different one, and its output must be byte-identical. - [ ] **Step 5: Run** `mvn -q -f server/java/pom.xml -pl metadata,codegen-base,codegen-spring,maven-plugin -am test -Dtest='RequirementTestIdentityConformanceTest,JUnitRequirementTestsGeneratorTest,GeneratorRegistryConformanceTest,CodegenCompileConformanceTest,EjectedGeneratorsCompileTest,EjectSupportTest,EjectRoundTripTest,MetaDataEjectMojoTest'` plus the new round-trip test by name (add `-Dsurefire.failIfNoSpecifiedTests=false` if a module holds none of them). Expected: PASS. @@ -966,7 +969,7 @@ Generator args: `testPackage` (required), `witnessClass` (required, a fully-qual - Produces: `RequirementTestIdentities.Digest`, `WitnessKeyOf`, `DefaultFilter`, `Identities(root, grain, filter: null)`, `WitnessKeyCollisions`; `record RequirementView(string SubType, int? Level, string? Status, string Path, string Package, IReadOnlyList ImplementedByTypes)`; `interface IRequirementTestFilter { bool Include(RequirementView view); }`; `record RequirementTestIdentity(string Package, string Path, string Unit, string Id, string WitnessKey, string? Status, string? Skip, string Digest)`; `interface IRequirementTestRenderer { RenderedTest? Render(RequirementTestArgs args); }`. - [ ] **Step 1: Read first.** `Generator.cs` (`IGenerator`, `GenContext`), an existing generator and its registry entry, `CodegenCompileConformanceTests.cs` (the Roslyn compile helper), `MetaObjects.Cli/GenCommand.cs` and `OwnedCopy.cs`. **UNVERIFIED:** how a per-generator option (`testNamespace`, `witnessClass`, `grain`, a renderer, a filter, `warnUncovered`) reaches a C# generator from the CLI or a config file; decide from the code, keep the six names of Table I, and state the surface in the commit. Known since the plan was written: no per-generator option channel exists (`GenConfig` is global to the run, `.metaobjects/config.json` carries `sources` and `libraries` only), an owned `codegen/Program.cs` constructs generators itself (`new X { … }`), and the packaged tool cannot load a project's classes. The surface that follows from that: **public init properties on `RequirementTestsGenerator`**, with defaults that make the packaged `dotnet meta gen --generators requirement-tests` run work with no option at all (derive the test namespace and the witness class name from `GenConfig.Namespace`), and the project sets any of the six in its `codegen/Program.cs`. Do not add keys to `.metaobjects/config.json`: it is the neutral config every port reads. Also read `EjectableGenerators.cs` (`RewriteForEject` needs the exact line `namespace MetaObjects.Codegen.Generators;`, and the file may use nothing `internal`), `EjectCommand.cs`, `EjectedGeneratorCompileTests.cs` and `EjectableGeneratorsTests.cs`. **UNVERIFIED:** that `Xunit.Sdk.XunitException(string)` is usable from generated code under xUnit 2.9.2; if not, throw `System.InvalidOperationException` and say so in Table H when updating the docs. -- [ ] **Step 2: Failing identity runner,** the runner mapping the `filter` name of `options.json` to an `IRequirementTestFilter` with a table of exactly the seven rows of Table J; then implement `RequirementTestIdentities` (`Encoding.UTF8.GetByteCount`, `SHA256.HashData`, lower-case hex, `StringComparer.Ordinal`). Expected: PASS for all 24 cases. +- [ ] **Step 2: Failing identity runner,** the runner mapping the `filter` name of `options.json` to an `IRequirementTestFilter` with a table of exactly the eight rows of Table J; then implement `RequirementTestIdentities` (`Encoding.UTF8.GetByteCount`, `SHA256.HashData`, lower-case hex, `StringComparer.Ordinal`; one direct test that `WitnessKeyOf("acme::shop::Café.Réglé", "*")` is `req_acme_shop_Caf_R_gl_`, since `char.IsLetterOrDigit` is the wrong class). Expected: PASS for all 26 cases. - [ ] **Step 3: Failing generator tests,** the C# equivalents of Task 8 Step 3: the two files per package; interface members only for non-skipped tests; `[Fact(Skip = "…")]`; only `Xunit` imported; **the output compiles** with Roslyn beside a hand-written witness class, and invoking the test method throws with the unimplemented message when the member is not implemented and returns when it is; the escaping case (`"`, `\`, a newline, `*/`); the collision refusal; member grain; an unknown grain refused with a clear error; the renderer hook; a filter keeps an L3 requirement the default drops; the capped uncovered warning and its switch; no requirements writes nothing; `stale file`. **Eject:** the embedded copy is byte-identical to `Generators/RequirementTestsGenerator.cs` and the embedded set still equals the registry's ejectable set (`EjectableGeneratorsTests`); the rewritten copy compiles with Roslyn against the public API only and, with non-default `Grain` and `TestNamespace`, emits the same bytes as the packaged generator over the `worked-example` model. - [ ] **Step 4: Implement** per Table H, in one file that holds the default rendering (Table L), and register `requirement-tests` (`Tier` native, `Layer` capability, `SourceFileName = "RequirementTestsGenerator.cs"`) with its `` item. - [ ] **Step 5: Run** `dotnet test server/csharp --filter "RequirementTestIdentityConformance|RequirementTestsGenerator|GeneratorRegistryConformance|CodegenCompileConformance|EjectableGenerators|EjectedGeneratorCompile|EjectEndToEnd"`. Expected: PASS. From e4f8398674f4a68f6b90ac3701b1eb1e36f376b7 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 6 Oct 2026 00:15:15 -0400 Subject: [PATCH 14/43] fix(python): import PACKAGE_SEP in yaml_desugar, so a node-level package under a packaged root loads A YAML node declaring its own package under a packaged root hit an undefined name at load; the desugar's package branch was otherwise correct (absolute taken literally, leading :: relative to the parent). Refs docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md and ADR-0057. --- server/python/src/metaobjects/yaml_desugar.py | 2 +- server/python/tests/unit/test_yaml_desugar.py | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/server/python/src/metaobjects/yaml_desugar.py b/server/python/src/metaobjects/yaml_desugar.py index ceffeb033..dfb7ecc93 100644 --- a/server/python/src/metaobjects/yaml_desugar.py +++ b/server/python/src/metaobjects/yaml_desugar.py @@ -46,7 +46,7 @@ ATTR_SUBTYPE_STRINGARRAY, ) from .registry import AttrSchema, TypeRegistry -from .shared.separators import ATTR_PREFIX, FUSED_KEY_SEP +from .shared.separators import ATTR_PREFIX, FUSED_KEY_SEP, PACKAGE_SEP from .shared.structural import ( KEY_ABSTRACT, KEY_CHILDREN, diff --git a/server/python/tests/unit/test_yaml_desugar.py b/server/python/tests/unit/test_yaml_desugar.py index fa235c1dc..c3ba50a35 100644 --- a/server/python/tests/unit/test_yaml_desugar.py +++ b/server/python/tests/unit/test_yaml_desugar.py @@ -6,6 +6,9 @@ """ from __future__ import annotations +from pathlib import Path + +from metaobjects import MetaDataLoader from metaobjects.core_types import core_providers from metaobjects.errors import ErrorCode from metaobjects.provider import compose_registry @@ -305,3 +308,26 @@ def test_top_level_multi_key_collects_error() -> None: result = desugar({"a": 1, "b": 2}, _registry()) assert len(result.errors) == 1 assert "exactly one type key" in result.errors[0].message + + +# --------------------------------------------------------------------------- +# FR-032 - a node's own `package` under a packaged root +# --------------------------------------------------------------------------- + + +def test_node_level_package_under_a_packaged_root_loads_strict(tmp_path: Path) -> None: + """An absolute `package` is taken literally and a leading `::` is relative to the + parent's. Both used to raise NameError (PACKAGE_SEP was never imported here).""" + (tmp_path / "meta.acme.yaml").write_text( + "metadata:\n" + " package: acme\n" + " children:\n" + " - object.entity: { name: Plain }\n" + " - object.entity: { name: Absolute, package: other::shop }\n" + " - object.entity: { name: Relative, package: '::billing' }\n", + encoding="utf-8", + ) + result = MetaDataLoader.from_directory(str(tmp_path), strict=True) + assert [str(e) for e in result.errors] == [] + keys = sorted(c.resolution_key() for c in result.root.children()) + assert keys == ["acme::Plain", "acme::billing::Relative", "other::shop::Absolute"] From 56bfc1e363787b787ec9f040e4cc7ec18162b353 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 6 Oct 2026 00:19:33 -0400 Subject: [PATCH 15/43] test(conformance): requirement-test identity corpus cases that an idiomatic wrong port would have passed Review of the identity corpus found rules a wrong port could pass. filter-by-package now nests a child under each package-declaring requirement, witness-key-collision gains a doubled underscore, digest-multibyte-and-crlf a CR LF in the statement, and digest-claim-fields a pair differing only in supersededBy; two cases are new, filter-by-absent-level (an eighth named predicate, unlevelled) and abstract-collected. The runner's and the script's filter tables become Maps, so a name found on Object.prototype is refused, and the key function gets a direct test with letters outside ASCII. Refs docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md and ADR-0057. --- docs/CONFORMANCE.md | 2 +- .../README.md | 47 +++++++++++----- .../abstract-collected/expected.json | 25 +++++++++ .../abstract-collected/input/meta.shop.yaml | 23 ++++++++ .../digest-claim-fields/expected.json | 20 +++++++ .../digest-claim-fields/input/meta.shop.yaml | 16 ++++++ .../digest-multibyte-and-crlf/expected.json | 2 +- .../input/meta.shop.yaml | 8 +-- .../filter-by-absent-level/expected.json | 25 +++++++++ .../input/meta.shop.yaml | 56 +++++++++++++++++++ .../filter-by-absent-level/options.json | 3 + .../filter-by-package/expected.json | 10 ++++ .../filter-by-package/input/meta.billing.yaml | 11 ++++ .../filter-by-package/input/meta.shop.yaml | 10 ++++ .../witness-key-collision/expected.json | 10 ++++ .../input/meta.shop.yaml | 11 ++++ scripts/write-requirement-corpus-expected.ts | 30 ++++++---- ...uirement-test-identity-conformance.test.ts | 36 +++++++----- .../codegen-ts/test/requirement-walk.test.ts | 14 +++++ 19 files changed, 314 insertions(+), 45 deletions(-) create mode 100644 fixtures/requirement-test-identity-conformance/abstract-collected/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/abstract-collected/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-absent-level/expected.json create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-absent-level/input/meta.shop.yaml create mode 100644 fixtures/requirement-test-identity-conformance/filter-by-absent-level/options.json diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 4b91e80d6..7dc01d90a 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -51,7 +51,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/fmt-conformance/`](../fixtures/fmt-conformance/) (#304 — `meta fmt`) | 12 | ✓ (reference) | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/field-lint-conformance/`](../fixtures/field-lint-conformance/) (the `verify` field authoring lint) | 15 | ✓ (reference) | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/requirement-check-conformance/`](../fixtures/requirement-check-conformance/) (the `verify` requirement gate, ADR-0057) | 43 cases | ✓ (reference) | — (not yet) | — (not yet) | — (not yet) | — (not yet) | -| [`fixtures/requirement-test-identity-conformance/`](../fixtures/requirement-test-identity-conformance/) (the identity, skip state and digest of each generated requirement test, and the filter seam, ADR-0057) | 24 cases | ✓ (reference) | — (not yet) | — (not yet) | — (not yet) | — (not yet) | +| [`fixtures/requirement-test-identity-conformance/`](../fixtures/requirement-test-identity-conformance/) (the identity, skip state and digest of each generated requirement test, and the filter seam, ADR-0057) | 26 cases | ✓ (reference) | — (not yet) | — (not yet) | — (not yet) | — (not yet) | | [`fixtures/naming-conformance/`](../fixtures/naming-conformance/) | 8 cases | ✓ | ✓ | inherits via Java (`RouteNaming.pluralize`) | ✓ | ✓ | | [`fixtures/codegen-noop/`](../fixtures/codegen-noop/) (FR-044 — reporting vocabulary: what is lowered, what stays inert) | 1 model pair (`reporting/with` vs `reporting/without`) | ✓ (codegen + migrate) | ✓ | ✓ | ✓ | ✓ | diff --git a/fixtures/requirement-test-identity-conformance/README.md b/fixtures/requirement-test-identity-conformance/README.md index a054195e4..fd02f0aae 100644 --- a/fixtures/requirement-test-identity-conformance/README.md +++ b/fixtures/requirement-test-identity-conformance/README.md @@ -38,7 +38,8 @@ each port's runner implements this closed list in its own language and hands the to the port's filter seam. A name outside the list fails the case. The predicate receives the **requirement view**: `subType`, `level`, `status`, `path`, -`package` and `implementedByTypes`. Between them the seven filters read every field. +`package` and `implementedByTypes`. Between them the eight filters read every field, and +`unlevelled` asks about one that is not there. | `filter` | The predicate over the requirement view | |---|---| @@ -46,20 +47,25 @@ The predicate receives the **requirement view**: `subType`, `level`, `status`, ` | `architectural` | `subType` is `architectural` | | `live` | `status` is `live` | | `level-5` | `level` is `5` | +| `unlevelled` | `level` is absent | | `package-acme-shop` | `package` is `acme::shop` | | `path-under-Shop` | `path` is `Shop` or starts with `Shop.` | | `claims-entity` | `implementedByTypes` contains `object.entity` | - `level` is **absent** on an architectural requirement that declares none. Absent is not - `5` and not `0`: the `level-5` predicate must answer false for it without failing. + a number: the `level-5` predicate must answer false for it without failing, and + `unlevelled` must answer true for it and for nothing else. A requirement that declares + level `0` or `-1` has a level. A view that stands a number in for "no level" cannot + tell the two apart. - `path` is the dotted chain of requirement names from the root, with no package. - `package` is the requirement's **effective** package: the one the node declares, else - its file's, else the empty string. + the one declared by the nearest requirement it is nested under, else its file's, else + the empty string. - `implementedByTypes` is the distinct `.` of the references that **resolve**, in first-seen order. A reference that does not resolve contributes nothing. With no `filter`, the port's default applies: a requirement gets a test when it is -`functional` and its `level` is 4 or 5. +`functional` and its `level` is 4 or above. ### What is compared @@ -72,7 +78,7 @@ collation; every `id` in this corpus is ASCII. Each record is one generated test | `path` | The dotted chain of requirement names from the root. | | `unit` | What this one test stands for. Under `grain: concern` (the default), one test per **distinct** `.` among the requirement's resolved references. Under `grain: member`, one test per **distinct** reference that resolves, spelt exactly as authored. In both grains a requirement with no resolved reference yields exactly one test, with unit `*`. | | `id` | `
[]`, where the address is `::`, or the bare path when the package is `""`. | -| `witnessKey` | `req_` + mangle(address), then `__` + mangle(unit) unless the unit is `*`. mangle replaces each maximal run of characters outside `[A-Za-z0-9]` with one `_`. | +| `witnessKey` | `req_` + mangle(address), then `__` + mangle(unit) unless the unit is `*`. mangle replaces each maximal run of characters outside `[A-Za-z0-9]` with one `_`. The class is ASCII and `_` is outside it: `Sales__Orders` mangles to `Sales_Orders`. | | `status` | The requirement's status. | | `skip` | `null` when the status is `live` or `partial`. Otherwise the status: `planned` or `retired`. | | `digest` | The requirement digest, below. The same for every test of one requirement. | @@ -132,11 +138,19 @@ it is wrong, unless the reference is shown to be wrong first. - **A filter replaces the default. It does not narrow it.** Every `filter-*` case keeps at least one requirement the default would drop, so a port that applies the project's - predicate on top of its own default fails all seven. A port that ignores the option - fails all seven too. + predicate on top of its own default fails every one of them. So does a port that + ignores the option. - **A filter chooses requirements, not tests.** In `filter-by-claimed-concern` the requirement that claims a template and an entity is kept, and its `template.prompt` test is kept with it. +- **A nested requirement takes its package from the nearest one above it that declares + one, before its file.** In `filter-by-package` each file holds a requirement that + declares the other file's package, with a child that declares none. The child goes + where its parent went, and its bare reference binds there. +- **The witness key keeps ASCII letters and digits and nothing else.** An underscore is + replaced like any other character, so a doubled one becomes one + (`witness-key-collision`). A language's own word class (`\w`) keeps the underscore and + gives a different key. - **Under `grain: concern`, distinct means distinct, not adjacent.** `concern-fanout` lists an entity, a template and a second entity, and yields two tests. - **A reference to a missing member does not fall back to its object.** @@ -155,8 +169,11 @@ it is wrong, unless the reference is shown to be wrong first. (`"\r\n"`, `"\r"`), so the value does not depend on the line endings of the file. What the corpus does **not** pin: the text of a generated test file; the warning that -names the requirements a filter excluded; a requirement name outside ASCII; and whether an -`abstract` requirement gets a test (no case declares one). +names the requirements a filter excluded; and a requirement name outside ASCII. No input +declares such a name, because the loaders are not known to agree on one. That a letter +outside ASCII is replaced in the witness key is pinned instead by a test of the key +function in each port: the address `acme::shop::Café.Réglé` with unit `*` gives +`req_acme_shop_Caf_R_gl_` (each `é` is the single code point U+00E9). ## Cases @@ -172,18 +189,20 @@ names the requirements a filter excluded; a requirement name outside ASCII; and | `member-grain` | `grain: member`: one test per reference. Two entities, which are one concern, are two tests. A bare reference and a package-qualified one each keep the spelling they were authored with, in the unit and in the key, for an object and for a member. | | `member-grain-unresolved-dropped` | `grain: member`: a reference that does not resolve yields no test, whether its object is missing or only its member is. A requirement none of whose references resolve yields one test with unit `*`. | | `member-grain-duplicate-ref` | `grain: member`: an entity named three times in two spellings yields two tests, one per distinct spelling. | -| `package-in-address` | The same path, with the same claim, in two packages: two ids and two keys, one digest. A bare reference binds in each requirement's own package. | +| `package-in-address` | The same path, with the same claim, in two packages: two ids and two keys, one digest. | | `unpackaged` | A document with no package: `package` is `""`, the address is the bare path, and the key starts `req_Orders_`. The digests are the worked example's. | | `nested-path` | A five-deep path. Two branches end in the same three names (`Orders.Recorded.Quotable`), and their tests are told apart by the rest of the path. | -| `digest-claim-fields` | Two requirements that differ only in name, `title`, `description`, `notes`, `disposition` and `trackedBy` have one digest. A third that differs from the first by one word of the statement has another. | +| `digest-claim-fields` | Two requirements that differ only in name, `title`, `description`, `notes`, `disposition` and `trackedBy` have one digest. A third that differs from the first by one word of the statement has another. Two retired requirements that differ only in name and `supersededBy` have one digest. | | `digest-inherited` | A requirement that declares only a counterexample and reaches its level, status, statement and `implementedBy` through `extends` has the digest of the same claim written out in full, and the unit its inherited reference resolves to. | -| `digest-multibyte-and-crlf` | A statement holding two-byte and three-byte characters (55 characters, 78 bytes), and a counterexample holding a CR LF pair and a lone CR (64 bytes as authored, 63 as hashed). | -| `witness-key-collision` | `Orders.Recorded` beside `Orders_Recorded`: two ids, one key, and one entry in `collisions`. | +| `digest-multibyte-and-crlf` | A statement holding two-byte and three-byte characters and a CR LF pair (55 characters and 76 bytes as authored; 54 and 75 as hashed), and a counterexample holding a CR LF pair and a lone CR (64 bytes as authored, 63 as hashed). Both fields are normalised. | +| `witness-key-collision` | `Orders.Recorded` beside `Orders_Recorded`: two ids, one key, and one entry in `collisions`. A third requirement, `Sales__Orders`, has the key `req_acme_shop_Sales_Orders__object_entity` and collides with nothing. | +| `abstract-collected` | An `abstract` functional L4 gets a test like any other requirement, and so does the concrete requirement that extends it. Pinned as the reference behaves, so that the ports cannot drift apart. The owner has not ruled on whether an abstract requirement should be tested. | | `filter-all` | `filter: all` over an L1 to L5 tree and one flat policy: all six requirements get a test, the L1 to L3 nodes with unit `*`. | | `filter-by-subtype` | `filter: architectural`: a flat policy and a levelled one get tests, and the functional L4 and L5 get none. Pins `subType`, and that a view with no `level` reaches a predicate. | | `filter-by-status` | `filter: live`: a `partial` and a `planned` L4 are dropped, and their live L3 parent is kept beside the live L4. Pins `status`. | | `filter-by-level` | `filter: level-5`: two functional L5 nodes and a levelled L5 policy get tests. Their L4 parent gets none, and neither does a flat policy, whose `level` is absent. Pins `level`. | -| `filter-by-package` | `filter: package-acme-shop` over two files: a root requirement and a nested one take their file's package, and one in each file declares the other file's package. The kept set follows the effective package, not the file. Pins `package`. | +| `filter-by-absent-level` | `filter: unlevelled`: two flat policies get tests. A levelled policy, a functional L4, and functional requirements that declare level `0` and level `-1` get none. Pins that an absent `level` reaches the predicate as absent. | +| `filter-by-package` | `filter: package-acme-shop` over two files: a root requirement and a nested one take their file's package; one requirement in each file declares the other file's package, and its child, which declares none, takes that package too. The kept set follows the effective package, not the file, and a kept requirement's bare reference binds in its effective package. Pins `package`. | | `filter-by-path` | `filter: path-under-Shop`: `Shop` and both its descendants are kept. `Shopfront` and its child are not. Pins `path` as the whole dotted chain, without the package. | | `filter-by-claimed-concern` | `filter: claims-entity`: requirements claiming an entity are kept, a flat policy among them. One claiming only a template and one naming only a node that does not exist are dropped. Pins `implementedByTypes` as the concerns that resolved. | diff --git a/fixtures/requirement-test-identity-conformance/abstract-collected/expected.json b/fixtures/requirement-test-identity-conformance/abstract-collected/expected.json new file mode 100644 index 000000000..1da7548c4 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/abstract-collected/expected.json @@ -0,0 +1,25 @@ +{ + "tests": [ + { + "id": "acme::shop::OrderRecorded [object.entity]", + "package": "acme::shop", + "path": "OrderRecorded", + "unit": "object.entity", + "witnessKey": "req_acme_shop_OrderRecorded__object_entity", + "status": "live", + "skip": null, + "digest": "8f3e0f0e75d0d4f62da5569800c771ab07863545888d513f92d7647149c96283" + }, + { + "id": "acme::shop::Recorded [*]", + "package": "acme::shop", + "path": "Recorded", + "unit": "*", + "witnessKey": "req_acme_shop_Recorded", + "status": "live", + "skip": null, + "digest": "16cf2dd765846ea73a51075c2c886f1fa8ea1917b5fdd49079a67223cb251bf5" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/abstract-collected/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/abstract-collected/input/meta.shop.yaml new file mode 100644 index 000000000..45e7b7074 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/abstract-collected/input/meta.shop.yaml @@ -0,0 +1,23 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + # A claim written once, for concrete requirements to extend. It names nothing. + - requirement.functional: + name: Recorded + abstract: true + level: 4 + status: live + statement: A business event is recorded when it happens. + counterexample: An event that happened has no row. + + # Takes its level, status, statement and counterexample from Recorded. + - requirement.functional: + name: OrderRecorded + extends: Recorded + implementedBy: [Order] diff --git a/fixtures/requirement-test-identity-conformance/digest-claim-fields/expected.json b/fixtures/requirement-test-identity-conformance/digest-claim-fields/expected.json index e1dec2d4f..6f374c259 100644 --- a/fixtures/requirement-test-identity-conformance/digest-claim-fields/expected.json +++ b/fixtures/requirement-test-identity-conformance/digest-claim-fields/expected.json @@ -10,6 +10,26 @@ "skip": null, "digest": "9c7f098dc9a7c74fd9c13cc705bf174c306a3259208abb2a7acca0d577c4e262" }, + { + "id": "acme::shop::Faxed [*]", + "package": "acme::shop", + "path": "Faxed", + "unit": "*", + "witnessKey": "req_acme_shop_Faxed", + "status": "retired", + "skip": "retired", + "digest": "ef38b10d440646c90a3f9a0a1fd0b331a0c6326ca23eab7462f3e10a961881fa" + }, + { + "id": "acme::shop::FaxedThenReplaced [*]", + "package": "acme::shop", + "path": "FaxedThenReplaced", + "unit": "*", + "witnessKey": "req_acme_shop_FaxedThenReplaced", + "status": "retired", + "skip": "retired", + "digest": "ef38b10d440646c90a3f9a0a1fd0b331a0c6326ca23eab7462f3e10a961881fa" + }, { "id": "acme::shop::Recorded [object.entity]", "package": "acme::shop", diff --git a/fixtures/requirement-test-identity-conformance/digest-claim-fields/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/digest-claim-fields/input/meta.shop.yaml index ec3d50e18..6ded25996 100644 --- a/fixtures/requirement-test-identity-conformance/digest-claim-fields/input/meta.shop.yaml +++ b/fixtures/requirement-test-identity-conformance/digest-claim-fields/input/meta.shop.yaml @@ -38,3 +38,19 @@ metadata: statement: An order is recorded when it is paid. counterexample: A placed order has no row. implementedBy: [Order] + + # A retired entry, and the same entry again saying what replaced it. + - requirement.functional: + name: Faxed + level: 4 + status: retired + statement: An order confirmation is sent by fax. + counterexample: A confirmed order with no fax. + + - requirement.functional: + name: FaxedThenReplaced + level: 4 + status: retired + supersededBy: Recorded + statement: An order confirmation is sent by fax. + counterexample: A confirmed order with no fax. diff --git a/fixtures/requirement-test-identity-conformance/digest-multibyte-and-crlf/expected.json b/fixtures/requirement-test-identity-conformance/digest-multibyte-and-crlf/expected.json index 39860f16c..c3cc71694 100644 --- a/fixtures/requirement-test-identity-conformance/digest-multibyte-and-crlf/expected.json +++ b/fixtures/requirement-test-identity-conformance/digest-multibyte-and-crlf/expected.json @@ -8,7 +8,7 @@ "witnessKey": "req_acme_shop_Recorded__object_entity", "status": "live", "skip": null, - "digest": "da577d1fc2d81eb2d559787bb6db70c84faea754fa0c8089d5461bc5eacc0567" + "digest": "807e7fd24cbe12c69f9e8d1d053a843ebbd2b8b523fb000321207cfc273fe695" } ], "collisions": [] diff --git a/fixtures/requirement-test-identity-conformance/digest-multibyte-and-crlf/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/digest-multibyte-and-crlf/input/meta.shop.yaml index 2f0e60b9e..11384ec0e 100644 --- a/fixtures/requirement-test-identity-conformance/digest-multibyte-and-crlf/input/meta.shop.yaml +++ b/fixtures/requirement-test-identity-conformance/digest-multibyte-and-crlf/input/meta.shop.yaml @@ -7,13 +7,13 @@ metadata: - field.uuid: { name: id } - identity.primary: { name: pk, fields: [id] } - # The statement holds two-byte and three-byte characters. The counterexample holds - # a CR LF pair and a lone CR, written as escapes so that the value does not depend - # on this file's own line endings. + # The statement holds two-byte and three-byte characters and a CR LF pair. The + # counterexample holds a CR LF pair and a lone CR. Each is written as an escape, so + # that the value does not depend on this file's own line endings. - requirement.functional: name: Recorded level: 4 status: live - statement: "Une commande passée au café est enregistrée — 注文は記録される。" + statement: "Une commande passée au café est enregistrée.\r\n注文は記録される。" counterexample: "A placed order has no row.\r\nIts total reads ¥0.\rNobody is told." implementedBy: [Order] diff --git a/fixtures/requirement-test-identity-conformance/filter-by-absent-level/expected.json b/fixtures/requirement-test-identity-conformance/filter-by-absent-level/expected.json new file mode 100644 index 000000000..280fc1e93 --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-absent-level/expected.json @@ -0,0 +1,25 @@ +{ + "tests": [ + { + "id": "acme::shop::Audited [*]", + "package": "acme::shop", + "path": "Audited", + "unit": "*", + "witnessKey": "req_acme_shop_Audited", + "status": "planned", + "skip": "planned", + "digest": "b798d2149134120f6039d88e8306642c5b1ff9fdc87e4b38bf8a00d689d1ce1c" + }, + { + "id": "acme::shop::Identified [object.entity]", + "package": "acme::shop", + "path": "Identified", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Identified__object_entity", + "status": "live", + "skip": null, + "digest": "1665da033b2ecf14f717530ac11fd4927dad2b573c1a48e9ed261135d951e410" + } + ], + "collisions": [] +} diff --git a/fixtures/requirement-test-identity-conformance/filter-by-absent-level/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/filter-by-absent-level/input/meta.shop.yaml new file mode 100644 index 000000000..03908c66f --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-absent-level/input/meta.shop.yaml @@ -0,0 +1,56 @@ +metadata: + package: acme::shop + children: + - object.entity: + name: Order + children: + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + + # Two flat policies: neither declares a level. + - requirement.architectural: + name: Identified + status: live + statement: Every row is addressed by a uuid. + counterexample: A row keyed by a name. + implementedBy: [Order] + + - requirement.architectural: + name: Audited + status: planned + statement: Every row records who last changed it. + counterexample: A changed row with no author. + + # A levelled policy. + - requirement.architectural: + name: Addressable + level: 4 + status: live + statement: An order is addressed by one stable key. + counterexample: An order that cannot be pointed at. + implementedBy: [Order] + + - requirement.functional: + name: Recorded + level: 4 + status: live + statement: An order is recorded when it is placed. + counterexample: A placed order has no row. + implementedBy: [Order] + + # Levels 0 and -1 are out of range, and verify reports them. They load, and they + # are here because they are the two values most often used to stand for "no + # level". Each of these requirements declares a level, so neither is unlevelled. + - requirement.functional: + name: Shelved + level: 0 + status: planned + statement: An order can be set aside and resumed. + counterexample: A shelved order that cannot be reopened. + + - requirement.functional: + name: Gifted + level: -1 + status: planned + statement: An order can be sent to someone else as a gift. + counterexample: A gift order that shows its price. diff --git a/fixtures/requirement-test-identity-conformance/filter-by-absent-level/options.json b/fixtures/requirement-test-identity-conformance/filter-by-absent-level/options.json new file mode 100644 index 000000000..4a9bf65fd --- /dev/null +++ b/fixtures/requirement-test-identity-conformance/filter-by-absent-level/options.json @@ -0,0 +1,3 @@ +{ + "filter": "unlevelled" +} diff --git a/fixtures/requirement-test-identity-conformance/filter-by-package/expected.json b/fixtures/requirement-test-identity-conformance/filter-by-package/expected.json index ecc80e909..5705a9a4a 100644 --- a/fixtures/requirement-test-identity-conformance/filter-by-package/expected.json +++ b/fixtures/requirement-test-identity-conformance/filter-by-package/expected.json @@ -29,6 +29,16 @@ "status": "live", "skip": null, "digest": "b8a6a5bf7873f5774105fc0ad3ee9d45994144dda20288f20d5245a4043a5461" + }, + { + "id": "acme::shop::Settled.Timed [field.timestamp]", + "package": "acme::shop", + "path": "Settled.Timed", + "unit": "field.timestamp", + "witnessKey": "req_acme_shop_Settled_Timed__field_timestamp", + "status": "live", + "skip": null, + "digest": "d4e7f45daaccabffd0861b3d917f44d4f53f7973493734ec2bfd8d306edc0829" } ], "collisions": [] diff --git a/fixtures/requirement-test-identity-conformance/filter-by-package/input/meta.billing.yaml b/fixtures/requirement-test-identity-conformance/filter-by-package/input/meta.billing.yaml index 81203e541..252f3f4aa 100644 --- a/fixtures/requirement-test-identity-conformance/filter-by-package/input/meta.billing.yaml +++ b/fixtures/requirement-test-identity-conformance/filter-by-package/input/meta.billing.yaml @@ -5,6 +5,7 @@ metadata: name: Invoice children: - field.uuid: { name: id } + - field.string: { name: number } - identity.primary: { name: pk, fields: [id] } # Takes this file's package. @@ -26,3 +27,13 @@ metadata: statement: An order records when it was paid. counterexample: A paid order with no payment time. implementedBy: [Order] + children: + # Declares no package: it takes its parent's, not this file's. Its bare + # reference binds there too. + - requirement.functional: + name: Timed + level: 5 + status: live + statement: A paid order carries the time it was paid. + counterexample: A paid order whose payment time is empty. + implementedBy: [Order.paidAt] diff --git a/fixtures/requirement-test-identity-conformance/filter-by-package/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/filter-by-package/input/meta.shop.yaml index 3b846f996..4bfc29866 100644 --- a/fixtures/requirement-test-identity-conformance/filter-by-package/input/meta.shop.yaml +++ b/fixtures/requirement-test-identity-conformance/filter-by-package/input/meta.shop.yaml @@ -5,6 +5,7 @@ metadata: name: Order children: - field.uuid: { name: id } + - field.timestamp: { name: paidAt } - identity.primary: { name: pk, fields: [id] } # Neither node declares a package: both take this file's. @@ -32,3 +33,12 @@ metadata: statement: An invoice is raised for every order. counterexample: An order with no invoice. implementedBy: [Invoice] + children: + # Declares no package: it takes its parent's, not this file's. + - requirement.functional: + name: Numbered + level: 5 + status: live + statement: An invoice carries a number the customer can quote. + counterexample: An invoice nobody can quote back. + implementedBy: [Invoice.number] diff --git a/fixtures/requirement-test-identity-conformance/witness-key-collision/expected.json b/fixtures/requirement-test-identity-conformance/witness-key-collision/expected.json index 02a3157a6..3a00667c9 100644 --- a/fixtures/requirement-test-identity-conformance/witness-key-collision/expected.json +++ b/fixtures/requirement-test-identity-conformance/witness-key-collision/expected.json @@ -19,6 +19,16 @@ "status": "live", "skip": null, "digest": "c7583c32576dd584f58627cdc42a03875f9e11af6606e833b2c3a4619c50ebd7" + }, + { + "id": "acme::shop::Sales__Orders [object.entity]", + "package": "acme::shop", + "path": "Sales__Orders", + "unit": "object.entity", + "witnessKey": "req_acme_shop_Sales_Orders__object_entity", + "status": "live", + "skip": null, + "digest": "b42b0be7e69633d28df90fd05ae28beb5675f778f2c719ea4f27da6b79e218a6" } ], "collisions": [ diff --git a/fixtures/requirement-test-identity-conformance/witness-key-collision/input/meta.shop.yaml b/fixtures/requirement-test-identity-conformance/witness-key-collision/input/meta.shop.yaml index 95ae7a8ec..b7508c51f 100644 --- a/fixtures/requirement-test-identity-conformance/witness-key-collision/input/meta.shop.yaml +++ b/fixtures/requirement-test-identity-conformance/witness-key-collision/input/meta.shop.yaml @@ -31,3 +31,14 @@ metadata: statement: An order is recorded once. counterexample: Two rows for one order. implementedBy: [Order] + + # A doubled underscore. An underscore is outside the kept class like any other + # character, so the pair is one run and becomes one underscore. No other + # requirement here mangles to the same key. + - requirement.functional: + name: Sales__Orders + level: 4 + status: live + statement: An order records the sale it belongs to. + counterexample: An order with no sale. + implementedBy: [Order] diff --git a/scripts/write-requirement-corpus-expected.ts b/scripts/write-requirement-corpus-expected.ts index ca504de8e..f4414640c 100644 --- a/scripts/write-requirement-corpus-expected.ts +++ b/scripts/write-requirement-corpus-expected.ts @@ -125,16 +125,24 @@ function checkExpected(root: MetaData, options: Readonly * (`codegen-ts/test/requirement-test-identity-conformance.test.ts`), on purpose: every * port's runner holds its own, and the runner is what fails when this one disagrees * with it. The corpus README is the definition of both. + * + * A Map, not an object literal: looking `constructor` up in a literal finds + * `Object.prototype`'s, so a misnamed filter would be written as a predicate that keeps + * everything instead of being refused. */ -const IDENTITY_FILTERS: Readonly boolean>> = { - "all": () => true, - "architectural": (r) => r.subType === REQUIREMENT_SUBTYPE_ARCHITECTURAL, - "live": (r) => r.status === REQUIREMENT_STATUS_LIVE, - "level-5": (r) => r.level === REQUIREMENT_LEVEL_MEMBER, - "package-acme-shop": (r) => r.package === "acme::shop", - "path-under-Shop": (r) => r.path === "Shop" || r.path.startsWith("Shop."), - "claims-entity": (r) => r.implementedByTypes.includes(`${TYPE_OBJECT}.${OBJECT_SUBTYPE_ENTITY}`), -}; +const IDENTITY_FILTERS: ReadonlyMap boolean> = new Map< + string, + (r: RequirementView) => boolean +>([ + ["all", () => true], + ["architectural", (r) => r.subType === REQUIREMENT_SUBTYPE_ARCHITECTURAL], + ["live", (r) => r.status === REQUIREMENT_STATUS_LIVE], + ["level-5", (r) => r.level === REQUIREMENT_LEVEL_MEMBER], + ["unlevelled", (r) => r.level === undefined], + ["package-acme-shop", (r) => r.package === "acme::shop"], + ["path-under-Shop", (r) => r.path === "Shop" || r.path.startsWith("Shop.")], + ["claims-entity", (r) => r.implementedByTypes.includes(`${TYPE_OBJECT}.${OBJECT_SUBTYPE_ENTITY}`)], +]); /** Key order is fixed so a rewrite produces no diff. `skip` is written as `null`, not * left out, on a test that runs: that it is not skipped is a statement, not an absence. */ @@ -161,11 +169,11 @@ function identityExpected(root: MetaData, options: Readonly boolean; + /** * The corpus's closed list of filters. A predicate cannot be written in a file five * languages read, so a case NAMES one and every port's runner holds this table in its own - * language, handing the predicate to its public filter seam. Between them the seven rows - * read every field of the requirement view. + * language, handing the predicate to its public filter seam. Between them the eight rows + * read every field of the requirement view, and `unlevelled` reads a field that is not + * there. + * + * A Map, not an object literal: looking `constructor` up in a literal finds + * `Object.prototype`'s, so a misnamed filter would be run as a predicate that keeps + * everything instead of being refused. */ -const FILTERS: Readonly boolean>> = { - "all": () => true, - "architectural": (r) => r.subType === REQUIREMENT_SUBTYPE_ARCHITECTURAL, - "live": (r) => r.status === REQUIREMENT_STATUS_LIVE, - "level-5": (r) => r.level === REQUIREMENT_LEVEL_MEMBER, - "package-acme-shop": (r) => r.package === "acme::shop", - "path-under-Shop": (r) => r.path === "Shop" || r.path.startsWith("Shop."), - "claims-entity": (r) => r.implementedByTypes.includes(`${TYPE_OBJECT}.${OBJECT_SUBTYPE_ENTITY}`), -}; +const FILTERS: ReadonlyMap = new Map([ + ["all", () => true], + ["architectural", (r) => r.subType === REQUIREMENT_SUBTYPE_ARCHITECTURAL], + ["live", (r) => r.status === REQUIREMENT_STATUS_LIVE], + ["level-5", (r) => r.level === REQUIREMENT_LEVEL_MEMBER], + ["unlevelled", (r) => r.level === undefined], + ["package-acme-shop", (r) => r.package === "acme::shop"], + ["path-under-Shop", (r) => r.path === "Shop" || r.path.startsWith("Shop.")], + ["claims-entity", (r) => r.implementedByTypes.includes(`${TYPE_OBJECT}.${OBJECT_SUBTYPE_ENTITY}`)], +]); function readOptions(caseDir: string): Options { const file = join(caseDir, "options.json"); @@ -71,10 +79,10 @@ function readOptions(caseDir: string): Options { return options as Options; } -function filterNamed(name: string): (r: RequirementView) => boolean { - const filter = FILTERS[name]; +function filterNamed(name: string): Predicate { + const filter = FILTERS.get(name); if (filter === undefined) { - throw new Error(`unknown filter '${name}'. The corpus names: ${Object.keys(FILTERS).join(", ")}`); + throw new Error(`unknown filter '${name}'. The corpus names: ${[...FILTERS.keys()].join(", ")}`); } return filter; } diff --git a/server/typescript/packages/codegen-ts/test/requirement-walk.test.ts b/server/typescript/packages/codegen-ts/test/requirement-walk.test.ts index 619c3c93b..341100d6b 100644 --- a/server/typescript/packages/codegen-ts/test/requirement-walk.test.ts +++ b/server/typescript/packages/codegen-ts/test/requirement-walk.test.ts @@ -314,6 +314,20 @@ describe("witnessKeyOf", () => { expect(witnessKeyOf("acme::shop::Orders.Recorded", "*")) .toBe("req_acme_shop_Orders_Recorded"); }); + + test("a letter outside ASCII is replaced, not kept", () => { + // The kept class is ASCII [A-Za-z0-9] and nothing else. A language's own notion of + // a letter (`\w`, isalnum, isLetterOrDigit) keeps "é" and gives a different key in + // each port. Every port carries this test with this value; the shared corpus cannot, + // because the loaders are not known to agree on a name outside ASCII. + // + // "é" is written as the one code point U+00E9, so the result does not depend on how + // this file was normalised. Run by run: + // acme "::"->_ shop "::"->_ Caf "é."->_ R "é"->_ gl "é"->_ + // "é." is ONE run, so one underscore, and the final "é" leaves a trailing one. The + // unit is "*", so there is no unit suffix. + expect(witnessKeyOf("acme::shop::Café.Réglé", "*")).toBe("req_acme_shop_Caf_R_gl_"); + }); }); describe("the requirement view's package", () => { From cc8fc8662f21ca0c165b9171ba07cda017495545 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 6 Oct 2026 00:20:38 -0400 Subject: [PATCH 16/43] =?UTF-8?q?docs(plan):=20requirements=20slice=201=20?= =?UTF-8?q?=E2=80=94=20say=20exactly=20which=20word=20class=20fails=20whic?= =?UTF-8?q?h=20mangle=20probe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs the plan and ADR-0057. --- ...026-10-05-requirements-slice-1-checks-and-test-generators.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md b/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md index 9a6510358..556ac4740 100644 --- a/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md +++ b/docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md @@ -165,7 +165,7 @@ One record per generated test. This is what the identity corpus pins, and it is | `path` | The requirement's path (Table A). | | `unit` | Under `grain: concern` (default): one test per **distinct** `.` among the requirement's resolved targets, first-seen order. Under `grain: member`: one test per **distinct** `implementedBy` entry that resolves, the reference exactly as authored. In both grains a requirement with no resolved target yields exactly one test with unit `*`. | | `id` | ` []`. | -| `witnessKey` | `req_` + mangle(qualified address), then `__` + mangle(unit) unless the unit is `*`. mangle replaces each maximal run of characters outside `[A-Za-z0-9]` with one `_`. The class is ASCII and excludes `_` itself: `Orders__Recorded` mangles to `Orders_Recorded`, and `Café.Réglé` to `Caf_R_gl_`. A port's idiomatic word class (`\W`, `isalnum`, `isLetterOrDigit`) is wrong on both. The corpus pins the doubled underscore; the non-ASCII half is pinned by a direct test of the key function in every port, because the loaders are not known to agree on non-ASCII names. | +| `witnessKey` | `req_` + mangle(qualified address), then `__` + mangle(unit) unless the unit is `*`. mangle replaces each maximal run of characters outside `[A-Za-z0-9]` with one `_`. The class is ASCII and excludes `_` itself: `Orders__Recorded` mangles to `Orders_Recorded`, and `Café.Réglé` to `Caf_R_gl_`. A port's idiomatic word class is wrong: `\W` keeps the underscore and non-ASCII letters, and `isalnum` / `isLetterOrDigit` keep non-ASCII letters. The corpus pins the doubled underscore, which catches `\W`; the non-ASCII half, which is the only thing that catches the other two, is pinned by a direct test of the key function in every port, because the loaders are not known to agree on non-ASCII names. | | `status` | The requirement's status. | | `skip` | `null` when the status is `live` or `partial`; otherwise the status (`planned` or `retired`). Derived from the status lists, never a literal set. | | `digest` | Table G. | From 2e752e8e8faff87e4aa765948c48dccb062a4068 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 6 Oct 2026 00:25:56 -0400 Subject: [PATCH 17/43] feat(python): the requirement gate in metaobjects verify Ports the requirement checks to Python, held to the TypeScript reference by the shared requirement-check corpus, and runs them on every verify. Refs docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md and ADR-0057. --- .../requirement-check-conformance/README.md | 1 + server/python/src/metaobjects/cli.py | 179 +++++- .../src/metaobjects/library/__init__.py | 4 +- .../metaobjects/library/library_sources.py | 15 + .../meta/core/requirement/meta_requirement.py | 15 + .../meta/core/requirement/resolve_claim.py | 93 +++ .../src/metaobjects/requirement_check.py | 543 ++++++++++++++++++ .../codegen/test_cli_verify_requirements.py | 146 +++++ .../test_requirement_check_conformance.py | 132 +++++ 9 files changed, 1095 insertions(+), 33 deletions(-) create mode 100644 server/python/src/metaobjects/meta/core/requirement/resolve_claim.py create mode 100644 server/python/src/metaobjects/requirement_check.py create mode 100644 server/python/tests/codegen/test_cli_verify_requirements.py create mode 100644 server/python/tests/conformance/test_requirement_check_conformance.py diff --git a/fixtures/requirement-check-conformance/README.md b/fixtures/requirement-check-conformance/README.md index 254ed642c..d5b678a97 100644 --- a/fixtures/requirement-check-conformance/README.md +++ b/fixtures/requirement-check-conformance/README.md @@ -140,5 +140,6 @@ it is wrong, unless the reference is shown to be wrong first. | Port | Runner | |---|---| | TypeScript (reference) | `server/typescript/packages/cli/test/requirement-check-conformance.test.ts` | +| Python | `server/python/tests/conformance/test_requirement_check_conformance.py` | | Java | `server/java/metadata/src/test/java/com/metaobjects/conformance/RequirementCheckConformanceTest.java` | | Kotlin | inherits via Java: Kotlin has no CLI of its own and runs `verify` through the same Maven `metaobjects:verify` goal | diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py index 006ff222a..088aa6c1d 100644 --- a/server/python/src/metaobjects/cli.py +++ b/server/python/src/metaobjects/cli.py @@ -65,7 +65,7 @@ refuse_unowned_packages, ) from metaobjects.config.neutral_config import read_neutral_config -from metaobjects.field_lint import FieldLintFinding, lint_duplicate_fields, lint_reference_fields +from metaobjects.field_lint import lint_duplicate_fields, lint_reference_fields from metaobjects.loader.meta_data_loader import LoadResult from metaobjects.loader.sources import FileSource from metaobjects.meta.core.object.meta_object import MetaObject @@ -74,7 +74,15 @@ agent_context_staleness, installed_metaobjects_version, ) +from metaobjects.meta.core.requirement.requirement_constants import REQUIREMENT_STATUSES from metaobjects.meta.meta_data import MetaData +from metaobjects.requirement_check import ( + SEVERITY_ERROR, + Diagnostic, + check_requirements, + scan_requirements, + summarise_requirements, +) from metaobjects.codegen.config import GenConfig from metaobjects.codegen.overwrite_policy import has_hash_manifest, read_generated_hash from metaobjects.codegen.project_config import ( @@ -2299,52 +2307,72 @@ def _verify_db(_args: argparse.Namespace) -> int: FIELD_LINT_ENV = "META_NO_FIELD_LINT" -def _field_lint_findings(args: argparse.Namespace) -> "list[FieldLintFinding] | None": - """Load the metadata ``verify`` was pointed at and run the field lint over it. +@dataclasses.dataclass(frozen=True) +class _VerifyModel: + """The metadata ``verify`` was pointed at, loaded once for every pass that reads it.""" + + root: MetaData + #: The project's OWN files — a dependency artifact is not the adopter's to edit. + own_files: list[Path] + #: Every file that loaded, dependency artifacts included. The denominator's provenance. + loaded_files: int + #: ``None`` for an explicit ````, which never resolves one. + collection: Collection | None + - Returns ``None`` when there is nothing to lint: the metadata could not be located - or did not load. Every such failure is already reported by the gate that ran, with - its own message and exit code, so this stays silent. +def _load_verify_model(args: argparse.Namespace) -> "_VerifyModel | None": + """Load the metadata ``verify`` was pointed at, for the passes that read the model. - Loads LENIENT, deliberately: the lint must report the same findings under - ``--lax`` as without it, and an unknown attr is the strict gate's finding, not - this one's. + Returns ``None`` when there is nothing to read: the metadata could not be located or + did not load. Every such failure is already reported by the gate that ran, with its own + message and exit code, so the passes that read this stay silent. + + Loads LENIENT, deliberately: the field lint and the requirement gate must report the + same findings under ``--lax`` as without it, and an unknown attr is the strict gate's + finding, not theirs. """ from metaobjects.loader.sources import DirectorySource - providers, _errors = _resolve_providers(getattr(args, "provider", None)) - if args.metadata_dir is not None: - root, _ = _load_root(args.metadata_dir, providers=providers) - files = [source.path for source in DirectorySource(args.metadata_dir).expand()] - else: - config_path = _find_config(args) - config = load_project_config(config_path) if config_path is not None else None - libraries: "list[str] | None" = None - if config is not None: - # Quiet on purpose: a bad provider is the gate's error to print, once. - providers, _errors = _resolve_providers(config.providers) - libraries = config.libraries - start = project_root_for(config.metadata_dir()) + try: + providers, _errors = _resolve_providers(getattr(args, "provider", None)) + if args.metadata_dir is not None: + root, _ = _load_root(args.metadata_dir, providers=providers) + files = [source.path for source in DirectorySource(args.metadata_dir).expand()] + collection = None + loaded_files = len(files) else: - start = Path.cwd() - collection = resolve_metadata_location(config=config, root=start) - root, _ = _load_root_from_collection(collection, providers=providers, libraries=libraries) - # The project's OWN files — a dependency artifact is not the adopter's to edit. - files = list(collection.own_files) + config_path = _find_config(args) + config = load_project_config(config_path) if config_path is not None else None + libraries: "list[str] | None" = None + if config is not None: + # Quiet on purpose: a bad provider is the gate's error to print, once. + providers, _errors = _resolve_providers(config.providers) + libraries = config.libraries + start = project_root_for(config.metadata_dir()) + else: + start = Path.cwd() + collection = resolve_metadata_location(config=config, root=start) + root, _ = _load_root_from_collection(collection, providers=providers, libraries=libraries) + files = list(collection.own_files) + loaded_files = len(collection.files) + except Exception: # noqa: BLE001 — a load failure is reported by the gate that ran + return None if root is None: return None - return [*lint_reference_fields(root), *lint_duplicate_fields(files)] + return _VerifyModel(root=root, own_files=files, loaded_files=loaded_files, collection=collection) -def _run_field_lint_advisory(args: argparse.Namespace) -> None: +def _run_field_lint_advisory(args: argparse.Namespace, model: "_VerifyModel | None") -> None: """The field AUTHORING lint (see :mod:`metaobjects.field_lint`) — runs on every ``verify``, whichever gates were selected. Warnings ONLY: it prints to stderr and never changes the exit code. Muted by ``--no-field-lint`` or ``META_NO_FIELD_LINT=1``. """ if getattr(args, "no_field_lint", False) or os.environ.get(FIELD_LINT_ENV) == "1": return + if model is None: + return try: - findings = _field_lint_findings(args) + findings = [*lint_reference_fields(model.root), *lint_duplicate_fields(model.own_files)] except Exception: # noqa: BLE001 — an advisory scan never breaks verify return if not findings: @@ -2358,6 +2386,83 @@ def _run_field_lint_advisory(args: argparse.Namespace) -> None: print(f" {finding.code} [{finding.path}]: {finding.message}", file=sys.stderr) +#: Raises ``WARN_REQUIREMENT_NOTHING_IMPLEMENTS`` to an error, beside ``--require-implementers`` +#: (Node `meta` parity, ADR-0057). +REQUIRE_IMPLEMENTERS_ENV = "META_REQUIRE_IMPLEMENTERS" + + +def _format_requirement_diagnostic(d: Diagnostic) -> str: + return f" {d.code}{'' if d.path is None else f' [{d.path}]'}: {d.message}" + + +def _verify_requirements(args: argparse.Namespace, model: "_VerifyModel | None" = None) -> int: + """The requirement (capability) gate (see :mod:`metaobjects.requirement_check`) — runs + on EVERY ``verify``, with no subverb to select it: ``requirement.*`` nodes are metadata, + so a model declaring none is silent, not in drift. + + Prints the summary line on every run that has a requirement, clean or not (a gate that + says nothing when it passes cannot be told apart from one that checked nothing), then + every finding, uncapped. Returns 1 when any finding is an error. Returns 0 and prints + nothing when the metadata did not load: the gate that ran already reported that. + """ + if model is None: + model = _load_verify_model(args) + if model is None: + return 0 + collection = model.collection + scan = scan_requirements( + model.root, + coverable=collection.in_scope if collection is not None else None, + require_implementers=bool(getattr(args, "require_implementers", False)) + or os.environ.get(REQUIRE_IMPLEMENTERS_ENV) == "1", + ) + summary = summarise_requirements(model.root, scan) + if summary is None: + return 0 + + statuses = ", ".join( + f"{summary.by_status[k]} {k}" for k in REQUIREMENT_STATUSES if summary.by_status.get(k, 0) > 0 + ) + if summary.entities_total is None: + coverage = "coverage: not measured (no project-authored requirements)." + else: + # The file count is the DENOMINATOR'S PROVENANCE: `entities_total` is only ever taken + # over what actually loaded, so a spine that covers half an estate reports the + # covered half as fully claimed unless the count says what it was taken over. + from_dependencies = ( + f", {len(collection.dependencies)} from dependencies." + if collection is not None and collection.dependencies + else "." + ) + coverage = ( + f"{summary.entities_claimed}/{summary.entities_total} entities claimed, " + f"counted over {model.loaded_files} metadata file(s)" + from_dependencies + ) + print( + f"metaobjects verify — requirements: {summary.total} entries ({summary.functional} functional, " + f"{summary.architectural} architectural) — {statuses}; {coverage}", + file=sys.stderr, + ) + if summary.undecided > 0: + print( + f"metaobjects verify — requirements: {summary.undecided} recorded gap(s) with no @disposition. " + "These are known problems nobody has ruled on — set 'accepted' or 'deferred' to close the question.", + file=sys.stderr, + ) + + diagnostics = check_requirements(model.root, scan) + errors = [d for d in diagnostics if d.severity == SEVERITY_ERROR] + warnings = [d for d in diagnostics if d.severity != SEVERITY_ERROR] + for d in errors: + print(_format_requirement_diagnostic(d), file=sys.stderr) + for d in warnings: + print(_format_requirement_diagnostic(d), file=sys.stderr) + if errors: + print(f"metaobjects verify — requirements: {len(errors)} error(s).", file=sys.stderr) + return 1 + return 0 + + def _cmd_verify(args: argparse.Namespace) -> int: """Subverb dispatch (ADR-0021 D2). Run each requested mode; aggregate exit = max (non-zero if ANY mode drifts). Bare ``verify`` (no subverb) keeps the @@ -2386,7 +2491,10 @@ def _cmd_verify(args: argparse.Namespace) -> int: exit_code = max(exit_code, _verify_codegen(args)) if run_templates: exit_code = max(exit_code, _verify_templates(args)) - _run_field_lint_advisory(args) + # One load for both passes that read the model. The requirement gate runs on EVERY verify. + model = _load_verify_model(args) + exit_code = max(exit_code, _verify_requirements(args, model)) + _run_field_lint_advisory(args, model) return exit_code @@ -2654,6 +2762,15 @@ def _build_parser() -> argparse.ArgumentParser: "build. META_NO_FIELD_LINT=1 does the same." ), ) + verify.add_argument( + "--require-implementers", + action="store_true", + help=( + "raise WARN_REQUIREMENT_NOTHING_IMPLEMENTS (a live functional requirement whose " + "subtree names no implementing node) from a warning to an error. " + "META_REQUIRE_IMPLEMENTERS=1 does the same." + ), + ) verify.add_argument( "--lax", action="store_true", diff --git a/server/python/src/metaobjects/library/__init__.py b/server/python/src/metaobjects/library/__init__.py index 8e3fc5d74..c93121f29 100644 --- a/server/python/src/metaobjects/library/__init__.py +++ b/server/python/src/metaobjects/library/__init__.py @@ -4,6 +4,6 @@ ``extends: "metaobjects::ai::LlmCallBase"`` on their own entity. """ -from .library_sources import known_packages, library_sources +from .library_sources import known_packages, library_packages, library_sources -__all__ = ["known_packages", "library_sources"] +__all__ = ["known_packages", "library_packages", "library_sources"] diff --git a/server/python/src/metaobjects/library/library_sources.py b/server/python/src/metaobjects/library/library_sources.py index 54be13dea..cb5af8577 100644 --- a/server/python/src/metaobjects/library/library_sources.py +++ b/server/python/src/metaobjects/library/library_sources.py @@ -46,6 +46,21 @@ } +def library_packages() -> frozenset[str]: + """The union of ``packages`` across every shipped library manifest — the metamodel + packages the libraries own (``metaobjects::iam``, ``metaobjects::ai``). + + The provenance key for requirement-coverage activation (FR-043 §5.4): a requirement + whose effective package is in this set came from a shipped library, not from the + adopter. Read from the embedded manifests, never from a node's source id — an id + differs between a checkout (a path) and an installed wheel (``library:.yaml``). + Mirrors the TS ``libraryPackages``. + """ + return frozenset( + pkg for manifest in LIBRARY_MANIFESTS.values() for pkg in manifest.get("packages", []) + ) + + #: The prefix every library source id carries — the discriminator for "did a shipped #: library contribute this file", and the reason the id is stable rather than derived #: from a path (see :func:`library_file_id`). diff --git a/server/python/src/metaobjects/meta/core/requirement/meta_requirement.py b/server/python/src/metaobjects/meta/core/requirement/meta_requirement.py index bb646f1a9..f928114e1 100644 --- a/server/python/src/metaobjects/meta/core/requirement/meta_requirement.py +++ b/server/python/src/metaobjects/meta/core/requirement/meta_requirement.py @@ -13,9 +13,11 @@ REQUIREMENT_ATTR_IMPLEMENTED_BY, REQUIREMENT_ATTR_LEVEL, REQUIREMENT_ATTR_STATUS, + REQUIREMENT_ATTR_SUPERSEDED_BY, REQUIREMENT_ATTR_TRACKED_BY, REQUIREMENT_LINK_FLOOR_LEVEL, REQUIREMENT_STATUS_PLANNED, + REQUIREMENT_STATUS_RETIRED, REQUIREMENT_STATUSES_REQUIRING_LIVE_NODES, REQUIREMENT_STATUSES_WITH_OUTSTANDING_WORK, REQUIREMENT_SUBTYPE_ARCHITECTURAL, @@ -67,6 +69,19 @@ def tracked_by(self) -> list[str]: v = self.get_meta_attr(REQUIREMENT_ATTR_TRACKED_BY) return [str(x) for x in v] if isinstance(v, list) else [] + def superseded_by(self) -> str | None: + """ADR-0039: resolving. The requirement that REPLACED this one (FR-039). + Legal on ``retired`` only; ``verify`` resolves it against the ledger, so a + supersession chain stays walkable. ``None`` when absent or blank.""" + v = self.get_meta_attr(REQUIREMENT_ATTR_SUPERSEDED_BY) + return v if isinstance(v, str) and v.strip() != "" else None + + def is_retired(self) -> bool: + """Built, then deliberately removed. Carries no ``@implementedBy`` (the + loader refuses it), never counts toward object coverage, and is exempt from + the architectural universality check — a withdrawn policy governs nothing.""" + return self.status() == REQUIREMENT_STATUS_RETIRED + def is_planned(self) -> bool: """Intended but not built. Its nodes may legitimately not exist yet, and it must NOT count toward object coverage — planning a capability cannot diff --git a/server/python/src/metaobjects/meta/core/requirement/resolve_claim.py b/server/python/src/metaobjects/meta/core/requirement/resolve_claim.py new file mode 100644 index 000000000..73af4c002 --- /dev/null +++ b/server/python/src/metaobjects/meta/core/requirement/resolve_claim.py @@ -0,0 +1,93 @@ +"""resolve_claim — resolve an ``@implementedBy`` reference to the node it names. + +ONE resolver shared by the requirement gate (:mod:`metaobjects.requirement_check`) and +the requirement-test generator, so the ADR-0042 package-local binding contract is not +forked. Mirrors the TS reference ``metadata/src/core/requirement/resolve-claim.ts`` +(contract Table B). +""" + +from __future__ import annotations + +from metaobjects.meta.meta_data import MetaData +from metaobjects.naming_refs import resolve_object_ref +from metaobjects.shared.base_types import TYPE_OBJECT, TYPE_REQUIREMENT +from metaobjects.shared.separators import PACKAGE_SEP + + +def split_member_ref(ref: str) -> tuple[str, list[str]]: + """Split a member reference into its owning object ref and the dotted member path. + + ``::`` qualifies the ROOT-level node only, so the object ref ends at the FIRST + ``.`` after the last ``::``. + ``acme::sales::Order.total.display`` -> ``("acme::sales::Order", ["total", "display"])``. + """ + pkg_end = ref.rfind(PACKAGE_SEP) + start = 0 if pkg_end == -1 else pkg_end + len(PACKAGE_SEP) + dot = ref.find(".", start) + if dot == -1: + return ref, [] + return ref[:dot], ref[dot + 1 :].split(".") + + +def resolve_claim_target(root: MetaData, owner: str, referrer_pkg: str) -> MetaData | None: + """Resolve the owner segment of an ``@implementedBy`` reference to the node it names. + + OBJECTS FIRST, through the loader's own resolver, so package-local binding stays + the ADR-0042 contract and never a parallel name scan (#228). + + Then ROOT-LEVEL NON-OBJECT nodes — ``template.prompt`` and its siblings today. A + declared prompt is a model node a capability can live in, so L4 means "a declared + top-level model node", not "an object". Requirements themselves are excluded: + hierarchy is nesting, and a requirement claiming a requirement would be a second, + contradictory parent mechanism. + """ + node = resolve_object_ref(root, owner, referrer_pkg) + if node is not None: + return node + + candidates = [c for c in root.children() if c.type != TYPE_OBJECT and c.type != TYPE_REQUIREMENT] + + # A fully-qualified reference binds exactly, like every other FQN in the model. + if PACKAGE_SEP in owner: + return next((c for c in candidates if c.resolution_key() == owner), None) + # A bare reference prefers the referrer's own package, then a root-level node of that + # bare name. An ambiguous bare name binds NOTHING — the same fail-closed rule objects + # use, because silently picking one of two same-named nodes is how a claim ends up + # pointing at the wrong thing without anyone noticing. + if referrer_pkg: + local = [c for c in candidates if c.resolution_key() == f"{referrer_pkg}{PACKAGE_SEP}{owner}"] + if len(local) == 1: + return local[0] + # Root-level (unpackaged) only, matching resolve_object_ref's own bare fallback. A bare + # ref must not reach into an arbitrary package just because the name is unique there. + bare = [c for c in candidates if c.name == owner and c.resolution_key() == owner] + return bare[0] if len(bare) == 1 else None + + +def resolve_member(obj: MetaData, path: list[str]) -> MetaData | None: + """Walk dotted member segments by CHILD NAME from an object node, to full depth. + + Exported beside :func:`resolve_claim_target` because the gate's coverage pass needs the + OWNER node's ``resolution_key()`` while using member resolution only as a yes/no + validity test; composing them would key coverage on the member instead of the object. + """ + cur: MetaData | None = obj + for seg in path: + if cur is None: + return None + cur = next((c for c in cur.children() if c.name == seg), None) + return cur + + +def resolve_claim(root: MetaData, ref: str, referrer_pkg: str) -> MetaData | None: + """Resolve a full ``@implementedBy`` reference — owner segment plus any dotted member + segments — to the node it names, or ``None`` when it does not resolve. + + Resolution walks to the FULL depth of the reference, so ``Council.slug.display`` + yields the view node rather than stopping at the field. + """ + segs = ref.split(".") + owner = resolve_claim_target(root, segs[0], referrer_pkg) + if owner is None or len(segs) == 1: + return owner + return resolve_member(owner, segs[1:]) diff --git a/server/python/src/metaobjects/requirement_check.py b/server/python/src/metaobjects/requirement_check.py new file mode 100644 index 000000000..2c43f2727 --- /dev/null +++ b/server/python/src/metaobjects/requirement_check.py @@ -0,0 +1,543 @@ +"""``metaobjects verify`` — the requirement (capability) gate. + +Requirements are METADATA: ``requirement.functional`` / ``requirement.architectural`` +are registered metamodel types, declared beside the entities they describe. So this +module parses NOTHING. It reads ``requirement.*`` nodes off the already-loaded model and +checks the things the loader cannot. + +Division of labour: + +* LOADER (unconditional) — the ``@status`` enum, required attrs, child rules. +* VERIFY (conditional) — ``@implementedBy`` resolution, whose SEVERITY DEPENDS ON + ``@status``: a ``planned`` requirement names nodes that do not exist YET, so a loader + ``references`` descriptor (which always errors on an unresolved target) cannot hold it. + +Two kinds, opposite checks: ``functional`` WARNS when nothing implements it (and a named +implementor that is gone is an error); ``architectural`` fails a live policy applied to +nothing — claim-set arithmetic, deliberately not a predicate DSL. + +Mirrors the TS reference ``packages/cli/src/lib/requirement-check.ts`` — the codes, their +conditions, their order and their message text. The shared corpus is +``fixtures/requirement-check-conformance/``; ADR-0057. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable, cast + +from metaobjects.library import library_packages +from metaobjects.meta.core.requirement.meta_requirement import MetaRequirement +from metaobjects.meta.core.requirement.requirement_constants import ( + REQUIREMENT_DISPOSITION_DEFERRED, + REQUIREMENT_LEVEL_MEMBER, + REQUIREMENT_LINK_FLOOR_LEVEL, + REQUIREMENT_MAX_LEVEL, + REQUIREMENT_MIN_LEVEL, + REQUIREMENT_STATUSES_REQUIRING_LIVE_NODES, + REQUIREMENT_SUBTYPE_ARCHITECTURAL, +) +from metaobjects.meta.core.requirement.resolve_claim import ( + resolve_claim_target, + resolve_member, + split_member_ref, +) +from metaobjects.meta.core.object.object_constants import OBJECT_SUBTYPE_ENTITY +from metaobjects.meta.meta_data import MetaData +from metaobjects.naming_refs import did_you_mean_hint +from metaobjects.shared.base_types import TYPE_OBJECT, TYPE_REQUIREMENT + +SEVERITY_ERROR = "error" +SEVERITY_WARN = "warn" + +ERR_REQUIREMENT_LINK_ABOVE_FLOOR = "ERR_REQUIREMENT_LINK_ABOVE_FLOOR" +ERR_REQUIREMENT_DANGLING_REF = "ERR_REQUIREMENT_DANGLING_REF" +ERR_REQUIREMENT_BAD_LEVEL = "ERR_REQUIREMENT_BAD_LEVEL" +ERR_REQUIREMENT_LEVEL_NESTING = "ERR_REQUIREMENT_LEVEL_NESTING" +ERR_REQUIREMENT_L4_NOT_OBJECT = "ERR_REQUIREMENT_L4_NOT_OBJECT" +ERR_REQUIREMENT_L5_NOT_MEMBER = "ERR_REQUIREMENT_L5_NOT_MEMBER" +ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS = "ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS" +WARN_REQUIREMENT_OBJECT_UNCLAIMED = "WARN_REQUIREMENT_OBJECT_UNCLAIMED" +WARN_REQUIREMENT_DISPOSITION_NOT_APPLICABLE = "WARN_REQUIREMENT_DISPOSITION_NOT_APPLICABLE" +WARN_REQUIREMENT_DEFERRED_UNTRACKED = "WARN_REQUIREMENT_DEFERRED_UNTRACKED" +WARN_REQUIREMENT_NOTHING_IMPLEMENTS = "WARN_REQUIREMENT_NOTHING_IMPLEMENTS" + +#: Severity of the object-coverage gate. It stays ``"warn"``: on a real estate carrying a +#: single requirement the gate reports every entity, so at ``"error"`` a project adopting +#: requirements incrementally would fail its first ``verify`` after authoring one entry, +#: which teaches people to delete the entry. Promotion is a one-line flip here. +OBJECT_COVERAGE_SEVERITY = SEVERITY_WARN + + +@dataclass(frozen=True) +class Diagnostic: + severity: str # "error" | "warn" + code: str + message: str + #: The subject's ADDRESS — the dotted child-name path. Two branches of a ledger may + #: reuse a NAME, so a bare name does not locate the node. ``None`` on a diagnostic + #: whose subject is not a requirement (object coverage names the entity instead). + path: str | None = None + + +@dataclass(frozen=True) +class AddressedRequirement: + """A requirement paired with its ADDRESS — the dotted child-name path from the root.""" + + node: MetaRequirement + path: str + + +@dataclass +class RequirementSummary: + """Counts behind the summary line ``verify`` prints on every run, clean or not.""" + + total: int + functional: int = 0 + architectural: int = 0 + by_status: dict[str, int] = field(default_factory=dict) + #: planned or partial with NO disposition recorded — the unreviewed gaps. + undecided: int = 0 + #: deferred entries naming no ticket, so nobody will be reminded. + deferred_untracked: int = 0 + #: ``None`` when coverage was not measured: a number here is a ratio the project is + #: held to; ``None`` is the honest reading of "this project authored no requirement + #: of its own, so it asked to be held to none". + entities_total: int | None = None + entities_claimed: int | None = None + + +@dataclass(frozen=True) +class RequirementScan: + """What one ``verify`` run computes once and both requirement passes read.""" + + addressed: list[AddressedRequirement] + claimed_objects: frozenset[str] + #: Whether object coverage applies at all on this run (FR-043 §5.4). + measure_coverage: bool + #: ADR-0057 — the strict switch: ``WARN_REQUIREMENT_NOTHING_IMPLEMENTS`` is reported + #: at severity ``error``. The code, path and message do not change. + require_implementers: bool + #: FR-023 — narrows which entities count; ``None`` means every non-abstract entity. + coverable: Callable[[str], bool] | None = None + + +def _effective_package(req: MetaRequirement) -> str: + return req.package or req.file_default_package or "" + + +def collect_addressed_requirements(root: MetaData) -> list[AddressedRequirement]: + """Every ``requirement.*`` node in the tree, at any depth, each with its dotted path. + + Hierarchy IS nesting, so this is a walk. Only a requirement contributes a path + segment; an intervening non-requirement node is traversed THROUGH. The traversal + descends through EVERY node, so a requirement somewhere the child rules did not + anticipate is still gated — the fail-closed direction for a gate. + """ + out: list[AddressedRequirement] = [] + + def walk(n: MetaData, prefix: str) -> None: + for c in n.children(): + is_req = c.type == TYPE_REQUIREMENT + path = (c.name if prefix == "" else f"{prefix}.{c.name}") if is_req else prefix + if is_req: + out.append(AddressedRequirement(cast(MetaRequirement, c), path)) + walk(c, path) + + walk(root, "") + return out + + +def _resolve_requirement_ref( + addressed: list[AddressedRequirement], ref: str, referrer_pkg: str +) -> MetaRequirement | None: + """Resolve a ``@supersededBy`` reference against the LEDGER, not the model: a + capability is replaced by another capability. Keyed by ``::`` and by the + bare path (first one wins, so a bare path ambiguous across packages still binds).""" + keyed: dict[str, MetaRequirement] = {} + for item in addressed: + pkg = _effective_package(item.node) + if pkg != "": + keyed[f"{pkg}::{item.path}"] = item.node + if item.path not in keyed: + keyed[item.path] = item.node + exact = keyed.get(ref) + if exact is not None: + return exact + if referrer_pkg != "": + return keyed.get(f"{referrer_pkg}::{ref}") + return None + + +def _missing_member_hint(obj: MetaData, path: list[str]) -> str: + """Names the FIRST segment of a non-empty, unresolvable ``path`` under ``obj``, and the + node it was looked for under.""" + found = 0 + while found < len(path) - 1 and resolve_member(obj, path[: found + 1]) is not None: + found += 1 + parent = ".".join([obj.resolution_key(), *path[:found]]) + return f" '{parent}' has no member '{path[found]}'." + + +def _subtypes_of(root: MetaData, ancestor: MetaData) -> list[str]: + """Resolution keys of every root-level object whose ``extends`` chain reaches + ``ancestor``. Walks the RESOLVED super pointer, never the raw string.""" + out: list[str] = [] + for cand in root.children(): + if cand.type != TYPE_OBJECT or cand is ancestor: + continue + seen: set[int] = set() + cur = cand.super_data + while cur is not None and id(cur) not in seen: + seen.add(id(cur)) + if cur is ancestor: + out.append(cand.resolution_key()) + break + cur = cur.super_data + return out + + +def _subtree_claims_anything(req: MetaRequirement) -> bool: + """True when this requirement, or anything nested beneath it, names an implementing + node. Subtree-scoped deliberately: an L1 solution that delegates everything to its + children implements nothing directly, and flagging that would fire on every tree.""" + if req.implemented_by(): + return True + for child in req.children(): + if child.type != TYPE_REQUIREMENT: + continue + if _subtree_claims_anything(cast(MetaRequirement, child)): + return True + return False + + +def _project_authored_requirements(addressed: list[AddressedRequirement]) -> bool: + """Did the ADOPTER author any of these requirements? (FR-043 §5.4.) + + Provenance is the library's declared PACKAGE, a manifest fact; a node's source id + differs between a checkout and an installed wheel. An adopter OVERLAYING a library + requirement stays in the library's package and is deliberately not authoring one.""" + lib_pkgs = library_packages() + return any(_effective_package(item.node) not in lib_pkgs for item in addressed) + + +def _claimed_object_keys(root: MetaData, reqs: list[MetaRequirement]) -> frozenset[str]: + """Resolution keys of every object claimed by a requirement, shared by the gate and + the summary so the two cannot disagree.""" + claimed: set[str] = set() + for req in reqs: + # A PLANNED requirement never contributes to coverage: otherwise the cheapest way + # to clear an unclaimed-entity warning would be to declare an intention. + if req.is_planned(): + continue + referrer_pkg = _effective_package(req) + for ref in req.implemented_by(): + owner, path = split_member_ref(ref) + node = resolve_claim_target(root, owner, referrer_pkg) + if node is None: + continue + if path and resolve_member(node, path) is None: + continue + claimed.add(node.resolution_key()) + # ARCHITECTURAL claims propagate DOWN the extends chain; functional ones do not. + if req.sub_type == REQUIREMENT_SUBTYPE_ARCHITECTURAL: + claimed.update(_subtypes_of(root, node)) + return frozenset(claimed) + + +def scan_requirements( + root: MetaData, + *, + coverable: Callable[[str], bool] | None = None, + measure_coverage: bool | None = None, + require_implementers: bool = False, +) -> RequirementScan: + """Compute, once, what the gate and the summary both read. + + ``measure_coverage`` forces coverage on or off instead of deriving it; the one caller + that legitimately knows better is a gate over a shipped library loaded standalone. + """ + addressed = collect_addressed_requirements(root) + return RequirementScan( + addressed=addressed, + claimed_objects=_claimed_object_keys(root, [a.node for a in addressed]), + measure_coverage=( + measure_coverage if measure_coverage is not None else _project_authored_requirements(addressed) + ), + require_implementers=require_implementers, + coverable=coverable, + ) + + +def _coverable_entities(root: MetaData, coverable: Callable[[str], bool] | None) -> list[MetaData]: + """The entities object coverage measures — the same set for the gate and the summary. + + An ABSTRACT entity is shape, not data, so it is exempt; ``object.value`` and + ``object.projection`` are exempt too. ``coverable`` (FR-023) is applied INSIDE this + function so every call site inherits it.""" + return [ + n + for n in root.children() + if n.type == TYPE_OBJECT + and n.sub_type == OBJECT_SUBTYPE_ENTITY + and not n.is_abstract + and (coverable is None or coverable(n.resolution_key())) + ] + + +def check_requirements(root: MetaData, scan: RequirementScan | None = None) -> list[Diagnostic]: + """Check the requirement tree against the loaded model. + + What a clean run proves: referential integrity — links sit at or below the link floor, + nesting agrees with levels, and references resolve. What it CANNOT prove: that a status + is *true*, or that a node actually implements the requirement claiming it. + """ + scan = scan if scan is not None else scan_requirements(root) + out: list[Diagnostic] = [] + if not scan.addressed: + return out # opt-in by declaration — no requirements, nothing to say + + for item in scan.addressed: + req, req_path = item.node, item.path + architectural = req.sub_type == REQUIREMENT_SUBTYPE_ARCHITECTURAL + level = req.level() + refs = req.implemented_by() + + # -- the level rules. A functional requirement MUST be levelled; an architectural + # one MAY be, and levelling is the OPT-IN. + levelled = level is not None + if not architectural or levelled: + if level is None or level < REQUIREMENT_MIN_LEVEL or level > REQUIREMENT_MAX_LEVEL: + out.append( + Diagnostic( + SEVERITY_ERROR, + ERR_REQUIREMENT_BAD_LEVEL, + f"level must be an integer {REQUIREMENT_MIN_LEVEL}-{REQUIREMENT_MAX_LEVEL} " + f"(got {'undefined' if level is None else level}). " + "L1 solution, L2 segment (app/library), L3 service, L4 object, L5 member." + + ( + " On an architectural requirement the level is optional — omit it for a flat policy." + if architectural + else "" + ), + req_path, + ) + ) + # Nesting IS the hierarchy, so a child must sit strictly below its parent. + parent = req.parent + if parent is not None and parent.type == TYPE_REQUIREMENT: + pl = cast(MetaRequirement, parent).level() + if pl is not None and level is not None and level <= pl: + out.append( + Diagnostic( + SEVERITY_ERROR, + ERR_REQUIREMENT_LEVEL_NESTING, + f'nested under "{parent.name}" (level {pl}) but declares level {level}. ' + "Nesting is the hierarchy — a child sits strictly below its parent.", + req_path, + ) + ) + + # -- the link boundary + if refs and not req.may_reference_model(): + out.append( + Diagnostic( + SEVERITY_ERROR, + ERR_REQUIREMENT_LINK_ABOVE_FLOOR, + f"'implementedBy' is legal at L{REQUIREMENT_LINK_FLOOR_LEVEL} (object) and " + f"L{REQUIREMENT_MAX_LEVEL} (member) only. L1-L3 are organisational and never reference " + f"the model — move the links to a nested L{REQUIREMENT_LINK_FLOOR_LEVEL} child.", + req_path, + ) + ) + continue + + for ref in refs: + owner, path = split_member_ref(ref) + referrer_pkg = _effective_package(req) + node = resolve_claim_target(root, owner, referrer_pkg) + is_object_ref = not path + + # GRAIN stays functional-only DELIBERATELY: on a levelled architectural + # requirement the upper tiers are a quality taxonomy, and a policy whose claim + # set legitimately mixes grains must not be forced to split by grain. + if not architectural and level == REQUIREMENT_LINK_FLOOR_LEVEL and not is_object_ref: + out.append( + Diagnostic( + SEVERITY_ERROR, + ERR_REQUIREMENT_L4_NOT_OBJECT, + f"L{REQUIREMENT_LINK_FLOOR_LEVEL} references an object; '{ref}' names a member. " + f"Move it to a nested L{REQUIREMENT_LEVEL_MEMBER} child, or reference the object itself.", + req_path, + ) + ) + continue + if not architectural and level == REQUIREMENT_LEVEL_MEMBER and is_object_ref: + out.append( + Diagnostic( + SEVERITY_ERROR, + ERR_REQUIREMENT_L5_NOT_MEMBER, + f"L{REQUIREMENT_LEVEL_MEMBER} references a member (field, view or identity); " + f"'{ref}' names an object. Move it to its L{REQUIREMENT_LINK_FLOOR_LEVEL} parent.", + req_path, + ) + ) + continue + + resolved = node is not None and (is_object_ref or resolve_member(node, path) is not None) + if not resolved and req.requires_live_nodes(): + # The did-you-mean hint answers an OBJECT that failed to resolve. When the + # object resolved and only the member is gone, name the member instead. + hint = did_you_mean_hint(root, owner) if node is None else _missing_member_hint(node, path) + out.append( + Diagnostic( + SEVERITY_ERROR, + ERR_REQUIREMENT_DANGLING_REF, + f"'{ref}' does not resolve in the loaded model (status '{req.status()}' — " + "the model moved and the requirement is stale)." + hint, + req_path, + ) + ) + + # -- @supersededBy resolution (FR-039): the target is a REQUIREMENT, resolved + # package-locally under ADR-0042 through the requirement's own effective package. + superseded = req.superseded_by() + if superseded is not None: + if _resolve_requirement_ref(scan.addressed, superseded, _effective_package(req)) is None: + out.append( + Diagnostic( + SEVERITY_ERROR, + ERR_REQUIREMENT_DANGLING_REF, + f"@supersededBy '{superseded}' does not name a requirement in the loaded " + "ledger. It must name the requirement that REPLACED this one — if nothing did, " + "drop the attribute and let `notes` carry why the capability went.", + req_path, + ) + ) + + # -- architectural universality, v1: claim-set arithmetic. Two structural + # exemptions: `planned` (supposed to be applied to nothing), and an + # ORGANISATIONAL node of a levelled tree (may_reference_model() is false there). + status = req.status() + live = status is not None and status in REQUIREMENT_STATUSES_REQUIRING_LIVE_NODES + if architectural and live and not refs and req.may_reference_model(): + out.append( + Diagnostic( + SEVERITY_ERROR, + ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS, + f"architectural requirement is '{status}' but nothing implements it. " + "Its check is universality — a claim set of zero means the policy is declared and unapplied.", + req_path, + ) + ) + + # -- disposition: the decision, not the state + disposition = req.disposition() + if disposition is not None and not req.has_outstanding_work(): + out.append( + Diagnostic( + SEVERITY_WARN, + WARN_REQUIREMENT_DISPOSITION_NOT_APPLICABLE, + f"carries @disposition '{disposition}' but its status is '{status}', which has no " + "outstanding work to decide about. A disposition is meaningful on 'planned' and 'partial' only — " + "on any other status the decision IS the status.", + req_path, + ) + ) + + # -- functional existence, SUBTREE-scoped. The strict switch (ADR-0057) raises the + # SEVERITY only; the code keeps its `WARN_` name because it identifies the finding. + if not architectural and live and not _subtree_claims_anything(req): + out.append( + Diagnostic( + SEVERITY_ERROR if scan.require_implementers else SEVERITY_WARN, + WARN_REQUIREMENT_NOTHING_IMPLEMENTS, + f"is '{status}' but neither it nor anything nested under it names an " + "implementing node. A functional requirement's check is existence — a subtree that claims " + "nothing is a capability nobody built.", + req_path, + ) + ) + + if disposition == REQUIREMENT_DISPOSITION_DEFERRED and not req.tracked_by(): + out.append( + Diagnostic( + SEVERITY_WARN, + WARN_REQUIREMENT_DEFERRED_UNTRACKED, + "is deferred but names no @trackedBy issue. Deferring without a ticket is how a known gap " + "becomes an unknown one — nothing will raise it again.", + req_path, + ) + ) + + # -- object coverage: adding an entity forces a requirement. Binary per entity, never a + # ratio. ENTITIES ONLY, OBJECT GRAIN ONLY, ADOPTER-AUTHORED ONLY (FR-043 §5.4). + if scan.measure_coverage: + for ent in _coverable_entities(root, scan.coverable): + key = ent.resolution_key() + if key not in scan.claimed_objects: + out.append( + Diagnostic( + OBJECT_COVERAGE_SEVERITY, + WARN_REQUIREMENT_OBJECT_UNCLAIMED, + f"no requirement claims '{key}'. Add it to an L{REQUIREMENT_LINK_FLOOR_LEVEL} " + "requirement's 'implementedBy'.", + ) + ) + + return out + + +def summarise_requirements(root: MetaData, scan: RequirementScan | None = None) -> RequirementSummary | None: + """Count what the ledger contains, for the line ``verify`` prints on EVERY run — + including a clean one, because silence cannot be told apart from "checked nothing". + ``None`` when the model declares no requirement.""" + scan = scan if scan is not None else scan_requirements(root) + if not scan.addressed: + return None # opt-in by declaration + + summary = RequirementSummary(total=len(scan.addressed)) + + # `undecided` counts only the requirements a `@disposition` could actually SETTLE. A + # parent is `partial` because a descendant is, so a disposition on it would settle + # nothing. EVERY ancestor of a node with outstanding work is excluded, by path + # SEGMENT (`::` carries no dot, so a package-qualified root segment stays intact), and + # whether or not that descendant is itself disposed: once the only outstanding leaf has + # been ruled on, nothing beneath the parent is owed. + roll_up_ancestors: set[str] = set() + for item in scan.addressed: + if not item.node.has_outstanding_work(): + continue + segments = item.path.split(".") + for i in range(1, len(segments)): + roll_up_ancestors.add(".".join(segments[:i])) + + for item in scan.addressed: + req = item.node + if req.sub_type == REQUIREMENT_SUBTYPE_ARCHITECTURAL: + summary.architectural += 1 + else: + summary.functional += 1 + + status = req.status() + # by_status is UNCHANGED by the roll-up rule: a parent is still genuinely `partial`. + if status is not None: + summary.by_status[status] = summary.by_status.get(status, 0) + 1 + + if req.has_outstanding_work() and req.disposition() is None and item.path not in roll_up_ancestors: + summary.undecided += 1 + if req.disposition() == REQUIREMENT_DISPOSITION_DEFERRED and not req.tracked_by(): + summary.deferred_untracked += 1 + + # Both sides of the ratio come from the SAME scan the gate read. + if scan.measure_coverage: + total = 0 + claimed_count = 0 + for ent in _coverable_entities(root, scan.coverable): + total += 1 + if ent.resolution_key() in scan.claimed_objects: + claimed_count += 1 + summary.entities_total = total + summary.entities_claimed = claimed_count + + return summary diff --git a/server/python/tests/codegen/test_cli_verify_requirements.py b/server/python/tests/codegen/test_cli_verify_requirements.py new file mode 100644 index 000000000..1965a2ead --- /dev/null +++ b/server/python/tests/codegen/test_cli_verify_requirements.py @@ -0,0 +1,146 @@ +"""``metaobjects verify`` — the requirement gate, end to end. + +It runs on EVERY ``verify`` (no subverb selects it), prints its summary and findings on +stderr, and exits 1 on an error. The codes, paths and message text themselves are gated +cross-port by ``tests/conformance/test_requirement_check_conformance.py``; these tests +pin the wiring: what is printed, what reaches the exit code, and what must NOT change for +a model that declares no ``requirement.*`` node. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from metaobjects import cli +from metaobjects.cli import main +from tests.codegen.gen_suite import GEN_SUITE + +_PK = {"identity.primary": {"name": "pk", "@fields": ["id"]}} + + +def _requirement(name: str, status: str, **attrs: Any) -> dict[str, Any]: + return { + "requirement.functional": { + "name": name, + "@level": 4, + "@status": status, + "@statement": f"{name} holds.", + "@counterexample": f"{name} does not hold.", + **{f"@{k}": v for k, v in attrs.items()}, + } + } + + +def _meta_dir(tmp_path: Path, requirements: list[dict[str, Any]]) -> str: + doc = { + "metadata.root": { + "package": "app", + "children": [ + {"object.entity": {"name": "Order", "children": [{"field.long": {"name": "id"}}, _PK]}}, + *requirements, + ], + } + } + d = tmp_path / "meta" + d.mkdir() + (d / "meta.app.json").write_text(json.dumps(doc)) + return str(d) + + +def _verify(tmp_path: Path, meta_dir: str, *extra: str) -> int: + out = tmp_path / "out" + assert main(["gen", "--generators", GEN_SUITE, meta_dir, "--out", str(out)]) == 0 + return main(["verify", "--codegen", "--generators", GEN_SUITE, meta_dir, "--out", str(out), *extra]) + + +def test_a_model_with_no_requirements_prints_nothing_and_exits_as_before( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + meta_dir = _meta_dir(tmp_path, []) + capsys.readouterr() + + # AFTER: the gate wired in. + after_code = _verify(tmp_path, meta_dir) + after = capsys.readouterr() + + # BEFORE: the same run with the gate unplugged — what verify printed without it. + monkeypatch.setattr(cli, "_verify_requirements", lambda args, loaded=None: 0) + before_code = _verify(tmp_path, meta_dir) + before = capsys.readouterr() + + assert (after_code, after.err, after.out) == (before_code, before.err, before.out) + assert after_code == 0 + assert "requirements" not in after.err + + +def test_a_dangling_live_reference_exits_1_and_prints_the_code_the_path_and_the_summary( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + meta_dir = _meta_dir(tmp_path, [_requirement("Recorded", "live", implementedBy=["Ordr"])]) + capsys.readouterr() + assert _verify(tmp_path, meta_dir) == 1 + err = capsys.readouterr().err + assert "metaobjects verify — requirements: 1 entries (1 functional, 0 architectural) — 1 live; " in err + assert "0/1 entities claimed, counted over 1 metadata file(s)." in err + assert " ERR_REQUIREMENT_DANGLING_REF [Recorded]: 'Ordr' does not resolve in the loaded model" in err + assert "metaobjects verify — requirements: 1 error(s)." in err + + +def test_warnings_alone_exit_0(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + meta_dir = _meta_dir(tmp_path, [_requirement("Recorded", "live")]) + capsys.readouterr() + assert _verify(tmp_path, meta_dir) == 0 + err = capsys.readouterr().err + assert " WARN_REQUIREMENT_NOTHING_IMPLEMENTS [Recorded]: is 'live' but neither it" in err + assert " WARN_REQUIREMENT_OBJECT_UNCLAIMED: no requirement claims 'app::Order'." in err + assert "error(s)." not in err + + +def test_require_implementers_flag_exits_1_on_a_nothing_implements_warning( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + meta_dir = _meta_dir(tmp_path, [_requirement("Recorded", "live")]) + capsys.readouterr() + assert _verify(tmp_path, meta_dir, "--require-implementers") == 1 + err = capsys.readouterr().err + assert "WARN_REQUIREMENT_NOTHING_IMPLEMENTS [Recorded]" in err + assert "metaobjects verify — requirements: 1 error(s)." in err + + +def test_require_implementers_env_var_exits_1_on_a_nothing_implements_warning( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("META_REQUIRE_IMPLEMENTERS", "1") + meta_dir = _meta_dir(tmp_path, [_requirement("Recorded", "live")]) + capsys.readouterr() + assert _verify(tmp_path, meta_dir) == 1 + assert "metaobjects verify — requirements: 1 error(s)." in capsys.readouterr().err + + +def test_the_gate_runs_with_templates_alone(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + meta_dir = _meta_dir(tmp_path, [_requirement("Recorded", "live", implementedBy=["Ordr"])]) + prompts = tmp_path / "prompts" + prompts.mkdir() + capsys.readouterr() + assert main(["verify", "--templates", "--prompts", str(prompts), meta_dir]) == 1 + err = capsys.readouterr().err + assert "ERR_REQUIREMENT_DANGLING_REF [Recorded]" in err + + +def test_a_metadata_load_failure_prints_no_requirement_line( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + meta_dir = tmp_path / "broken" + meta_dir.mkdir() + (meta_dir / "meta.app.json").write_text("{ not json") + prompts = tmp_path / "prompts" + prompts.mkdir() + capsys.readouterr() + code = main(["verify", "--templates", "--prompts", str(prompts), str(meta_dir)]) + err = capsys.readouterr().err + assert code != 0 + assert "requirements" not in err diff --git a/server/python/tests/conformance/test_requirement_check_conformance.py b/server/python/tests/conformance/test_requirement_check_conformance.py new file mode 100644 index 000000000..c9c6b8933 --- /dev/null +++ b/server/python/tests/conformance/test_requirement_check_conformance.py @@ -0,0 +1,132 @@ +"""Cross-port requirement-check conformance corpus — fixtures/requirement-check-conformance/. + +See that directory's README.md for the fixture format. Every case is LOADED strict +first, so "this model loads today" is proven by the fixture, and only then checked. +Mirrors the TS reference (requirement-check-conformance.test.ts); a mismatch here is +a bug in THIS port's gate, never in the fixture, and a committed ``expected.json`` is +never edited to make this port pass. +""" +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +import pytest + +from metaobjects import MetaDataLoader +from metaobjects.requirement_check import ( + RequirementSummary, + check_requirements, + scan_requirements, + summarise_requirements, +) + + +def _corpus_root() -> Path: + here = Path(__file__).resolve() + for parent in [here, *here.parents]: + candidate = parent / "fixtures" / "requirement-check-conformance" + if candidate.is_dir(): + return candidate + raise RuntimeError("could not locate fixtures/requirement-check-conformance from " + str(here)) + + +CORPUS = _corpus_root() +FIXTURES = sorted(p.name for p in CORPUS.iterdir() if p.is_dir()) + +#: The whole of ``options.json``. A key outside this list, or a value of the wrong +#: type, is refused rather than ignored: a misspelt ``requireImplementers``, or one +#: written as the string "true", would otherwise run the case without the strict +#: switch and pin the wrong severity. +OPTION_KEYS = {"libraries", "requireImplementers"} + +#: A case whose ``input/`` must be, file for file and byte for byte, another case's. +SAME_INPUT_AS = {"require-implementers": "nothing-implements-subtree"} + + +def _read_options(case_dir: Path) -> dict[str, Any]: + file = case_dir / "options.json" + if not file.exists(): + return {} + options: dict[str, Any] = json.loads(file.read_text(encoding="utf-8")) + unknown = sorted(set(options) - OPTION_KEYS) + if unknown: + raise AssertionError(f"{file}: unknown option(s) {', '.join(unknown)}") + libraries = options.get("libraries") + if libraries is not None and not ( + isinstance(libraries, list) and all(isinstance(x, str) for x in libraries) + ): + raise AssertionError(f"{file}: 'libraries' must be an array of strings") + require = options.get("requireImplementers") + if require is not None and not isinstance(require, bool): + raise AssertionError(f"{file}: 'requireImplementers' must be a boolean") + return options + + +def _summary_dict(summary: RequirementSummary | None) -> dict[str, Any] | None: + """The corpus's JSON shape. ``entities*`` are absent unless coverage was measured.""" + if summary is None: + return None + out: dict[str, Any] = { + "total": summary.total, + "functional": summary.functional, + "architectural": summary.architectural, + "byStatus": dict(summary.by_status), + "undecided": summary.undecided, + "deferredUntracked": summary.deferred_untracked, + } + if summary.entities_total is not None: + out["entitiesClaimed"] = summary.entities_claimed + out["entitiesTotal"] = summary.entities_total + return out + + +def _input_files(name: str) -> dict[str, str]: + directory = CORPUS / name / "input" + return {p.name: p.read_text(encoding="utf-8") for p in sorted(directory.iterdir())} + + +def _documented_cases() -> list[str]: + readme = (CORPUS / "README.md").read_text(encoding="utf-8") + section = next((s for s in re.split(r"^## ", readme, flags=re.M) if s.startswith("Cases\n")), None) + assert section is not None, "README.md has no '## Cases' section" + return re.findall(r"^\| `([^`]+)` \|", section, flags=re.M) + + +def test_found_fixtures() -> None: + assert FIXTURES, "fixtures/requirement-check-conformance/ discovered no fixture directories" + + +def test_every_case_on_disk_is_documented_in_the_readme_and_nothing_else_is() -> None: + assert sorted(_documented_cases()) == FIXTURES + + +@pytest.mark.parametrize("name", FIXTURES) +def test_fixture(name: str) -> None: + case_dir = CORPUS / name + expected = json.loads((case_dir / "expected.json").read_text(encoding="utf-8")) + options = _read_options(case_dir) + + twin = SAME_INPUT_AS.get(name) + if twin is not None: + assert _input_files(name) == _input_files(twin) + + result = MetaDataLoader.from_directory( + str(case_dir / "input"), strict=True, libraries=options.get("libraries") + ) + assert [str(e) for e in result.errors] == [] + + # No scope predicate and no forced coverage answer: both are the port's defaults. + scan = scan_requirements(result.root, require_implementers=options.get("requireImplementers", False)) + + actual = sorted( + (d.severity, d.code, d.path or "", d.message) for d in check_requirements(result.root, scan) + ) + wanted = sorted( + (d["severity"], d["code"], d.get("path", ""), d["message"]) for d in expected["diagnostics"] + ) + assert actual == wanted + + assert _summary_dict(summarise_requirements(result.root, scan)) == expected["summary"] From 50af5f84b876744d036ba273d7ca4f276f6f5eeb Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 6 Oct 2026 00:30:23 -0400 Subject: [PATCH 18/43] test(conformance): a member reference whose owner does not resolve gets the object hint dangling-object-did-you-mean gains an L5 claiming Invoice.total from a package where Invoice is not local: the message quotes the whole reference and the did-you-mean hint names the object. No case reached that branch of the dangling check before. Refs docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md and ADR-0057. --- fixtures/requirement-check-conformance/README.md | 2 +- .../dangling-object-did-you-mean/expected.json | 12 +++++++++--- .../input/meta.shop.yaml | 10 ++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/fixtures/requirement-check-conformance/README.md b/fixtures/requirement-check-conformance/README.md index d5b678a97..65c874b0e 100644 --- a/fixtures/requirement-check-conformance/README.md +++ b/fixtures/requirement-check-conformance/README.md @@ -96,7 +96,7 @@ it is wrong, unless the reference is shown to be wrong first. | `dangling-object-live` | `ERR_REQUIREMENT_DANGLING_REF` on a `live` requirement naming an object that does not exist. No object has that short name, so there is no hint. | | `dangling-object-partial` | The same on `partial`. The message carries the status. | | `dangling-object-planned-clean` | The same reference on `planned` reports nothing: a planned requirement may name nodes that do not exist yet. | -| `dangling-object-did-you-mean` | The hint when the short name exists in one other package. | +| `dangling-object-did-you-mean` | The hint when the short name exists in one other package, for a bare object reference and for a member reference whose owner does not resolve. The second message quotes the whole reference (`Invoice.total`) while its hint names the object (`Invoice`), not the member. | | `dangling-bare-name-in-two-packages` | A bare name that exists in two other packages binds neither. The hint lists both, `acme::billing::Order` first. | | `dangling-member-of-resolved-object` | The object resolves and its member does not: the hint names the object's resolution key and the missing member. | | `dangling-member-first-missing-segment` | Two-segment member paths. The hint names the first segment that did not resolve and the node it was looked for under: once when the first segment resolves, once when it does not. | diff --git a/fixtures/requirement-check-conformance/dangling-object-did-you-mean/expected.json b/fixtures/requirement-check-conformance/dangling-object-did-you-mean/expected.json index 0497c08ae..7aa314814 100644 --- a/fixtures/requirement-check-conformance/dangling-object-did-you-mean/expected.json +++ b/fixtures/requirement-check-conformance/dangling-object-did-you-mean/expected.json @@ -1,5 +1,11 @@ { "diagnostics": [ + { + "severity": "error", + "code": "ERR_REQUIREMENT_DANGLING_REF", + "path": "Invoiced.Totalled", + "message": "'Invoice.total' does not resolve in the loaded model (status 'live' — the model moved and the requirement is stale). An object named \"Invoice\" exists in: acme::billing::Invoice. Qualify it with its package (FQN)." + }, { "severity": "error", "code": "ERR_REQUIREMENT_DANGLING_REF", @@ -8,11 +14,11 @@ } ], "summary": { - "total": 3, - "functional": 3, + "total": 4, + "functional": 4, "architectural": 0, "byStatus": { - "live": 3 + "live": 4 }, "undecided": 0, "deferredUntracked": 0, diff --git a/fixtures/requirement-check-conformance/dangling-object-did-you-mean/input/meta.shop.yaml b/fixtures/requirement-check-conformance/dangling-object-did-you-mean/input/meta.shop.yaml index 6b25eb927..f29b1f5aa 100644 --- a/fixtures/requirement-check-conformance/dangling-object-did-you-mean/input/meta.shop.yaml +++ b/fixtures/requirement-check-conformance/dangling-object-did-you-mean/input/meta.shop.yaml @@ -23,6 +23,16 @@ metadata: statement: An invoice is raised for every order. counterexample: An order with no invoice. implementedBy: ["acme::billing::Invoice"] + children: + # A member reference whose owner does not bind: Invoice is not local to acme::shop. + # The hint is about the object, Invoice, and never about the member. + - requirement.functional: + name: Totalled + level: 5 + status: live + statement: An invoice carries the total the customer pays. + counterexample: An invoice with no total. + implementedBy: [Invoice.total] # Invoice lives in acme::billing, so the bare name does not bind from acme::shop. - requirement.functional: From 4a7a58d4abc33a19b884704a3e935d11b99c81fb Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 6 Oct 2026 00:37:49 -0400 Subject: [PATCH 19/43] feat(java): requirement-tests generator emitting JUnit with a typed witness interface Adds the Java identity function and the ejectable JUnit Jupiter generator over the shared identity corpus, and hands the project class loader to a generator that loads a renderer or filter class by name. Refs docs/superpowers/plans/2026-10-05-requirements-slice-1-checks-and-test-generators.md and ADR-0057. --- docs/ports/java.md | 51 ++ .../registry.json | 2 +- .../README.md | 1 + .../generator/ProjectClassLoaderAware.java | 18 + .../generator/requirement/RenderedTest.java | 25 + .../requirement/RequirementTestArgs.java | 31 ++ .../requirement/RequirementTestRenderer.java | 18 + server/java/codegen-spring/pom.xml | 26 + .../generator/GeneratorRegistry.java | 6 + .../JUnitRequirementTestsGenerator.java | 406 ++++++++++++++ .../spring/EjectedGeneratorsCompileTest.java | 3 +- .../JUnitRequirementTestsGeneratorTest.java | 526 ++++++++++++++++++ .../mojo/AbstractMetaDataMojo.java | 6 + .../RequirementTestsEjectRoundTripTest.java | 157 ++++++ .../RequirementTestsGeneratorMojoTest.java | 161 ++++++ .../requirement/RequirementClaims.java | 7 +- .../requirement/RequirementTestFilter.java | 17 + .../RequirementTestIdentities.java | 279 ++++++++++ ...equirementTestIdentityConformanceTest.java | 258 +++++++++ 19 files changed, 1995 insertions(+), 3 deletions(-) create mode 100644 server/java/codegen-base/src/main/java/com/metaobjects/generator/ProjectClassLoaderAware.java create mode 100644 server/java/codegen-base/src/main/java/com/metaobjects/generator/requirement/RenderedTest.java create mode 100644 server/java/codegen-base/src/main/java/com/metaobjects/generator/requirement/RequirementTestArgs.java create mode 100644 server/java/codegen-base/src/main/java/com/metaobjects/generator/requirement/RequirementTestRenderer.java create mode 100644 server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/JUnitRequirementTestsGenerator.java create mode 100644 server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/JUnitRequirementTestsGeneratorTest.java create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/RequirementTestsEjectRoundTripTest.java create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/RequirementTestsGeneratorMojoTest.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/requirement/RequirementTestFilter.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/requirement/RequirementTestIdentities.java create mode 100644 server/java/metadata/src/test/java/com/metaobjects/conformance/RequirementTestIdentityConformanceTest.java diff --git a/docs/ports/java.md b/docs/ports/java.md index 2ab4b67d6..d4443a429 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -280,6 +280,7 @@ name in every port. | `filter-allowlist` | `SpringFilterAllowlistGenerator` | `metaobjects-codegen-spring` | One `FilterAllowlist.java` per writable entity: the filterable field set plus the operator set permitted per field, gated by field subtype (FR-009 §5, identical across ports). Only `@filterable: true` fields appear. Emitted even when no field is filterable (with empty constants), so the generated controller delegates to it unconditionally. | | `value-object` | `SpringValueObjectGenerator` | `metaobjects-codegen-spring` | One Java 21 `record` per concrete `object.value` and per sourceless `object.projection`, in the value object's own package. It carries jakarta bean-validation constraints plus `@Valid` on nested members, so a VO jsonb column POSTs and PATCHes with validation cascading to depth ≥ 2. It is THE Java type for the value object (ADR-0056): `Dto` / `Patch` bind to it, a render helper takes it, and a response parser returns it. The template tier declares no copy, so it needs this generator in the same run. | | `names` | `SpringNamesGenerator` | `metaobjects-codegen-spring` | One `Names.java` per object with a declared/inherited primary `source.rdb` — `public static final` physical database name constants (table/view name, schema, per-field columns). See "`Names`" below. | +| `requirement-tests` | `JUnitRequirementTestsGenerator` | `metaobjects-codegen-spring` | Per metamodel package that holds a tested requirement, two files in `testPackage`: `Requirements__Witnesses.java` (an interface with one default member per non-skipped test, failing with `unimplemented requirement: ...`) and `Requirements__Test.java` (one JUnit Jupiter `@Test` per requirement, calling that member on your `witnessClass`; a planned or retired requirement is `@Disabled`). Args: `testPackage` and `witnessClass` (required), `grain` (`concern` or `member`), `filter` and `renderer` (class names on your project classpath), `warnUncovered`. Your test classpath needs `org.junit.jupiter:junit-jupiter-api`. A model with no requirement writes nothing. See "Requirement tests" below. | | `entity` | `JavaObjectCodeGenerator` | `metaobjects-codegen-base` | Flavor-selected via the `flavor` generator arg (`com.metaobjects.generator.direct.object.javacode`). `flavor=pojoAware` emits `class extends PojoObject` — a concrete `MetaObjectAware` class whose inherited `getMetaData()` back-reference breaks a default Jackson/Gson mapper (see [Serializing generated objects](#serializing-generated-objects) below). `flavor=valueObject` emits a map-backed `class extends ValueObject` instead. Either flavor also emits a `Extractor` and a self-registering `ObjectClassBindingProvider`. For a plain default-Jackson-friendly type, use the `codegen-spring` record surface instead — never `pojoAware`. | | `output-parser` | `SpringOutputParserGenerator` | `metaobjects-codegen-spring` | One `