Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,99 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]

### Fixed — `{{#hasField}}` rendered as absent on a populated payload, in every port (npm/PyPI/NuGet/Maven)

A prompt's conditional section — *"include the abilities block only when there ARE
abilities"* — is expressed as `{{#hasAbilities}}`, a **derived** boolean accessor over the
declared field `abilities`. The JVM has emitted `has<Field>()` onto every generated payload
record since 7.7.7 and accepts the section in its static drift check, sharing one naming
rule so the two "can never drift apart".

**No render engine implemented the other half.** Given the same payload *data* — a map, which
is what the runtime and the conformance corpus actually pass — all five ports rendered the
section as absent:

```
payload {"abilities":[{"name":"Fireball"}]}
template "Abilities:{{#hasAbilities}} {{#abilities}}[{{name}}]{{/abilities}}{{/hasAbilities}}"
before "Abilities:" ← content silently dropped, no error
after "Abilities: [Fireball]"
```

Silent wrong output, not a failure: the prompt shipped without its block. The JVM looked
correct only because a *generated record* answers `hasFoo()` by its own method — so the same
payload rendered differently depending on whether it arrived as a record or as a map.

`PayloadAccessors` now exists in all five ports carrying one shared rule (`"has" +
capitalize`, and presence semantics mirroring the JVM emitter exactly: string → non-blank,
collection → non-empty, reference → non-null, **number/boolean → no accessor at all**, since
`{{#hasCount}}` over an int is drift rather than a conditional). Render derives them
non-mutatingly, recursing into nested objects and collection elements so a section sees the
element it is iterating; an **authored** `hasFoo` always wins. `verify` accepts exactly what
render resolves, mirroring the JVM's deliberate permissiveness (acceptance keys off the
field existing, not its type), and still reports drift inside a has-section body.

Found by an adopter with a JVM-authored prompt estate whose Node gate reported **157**
`ERR_VAR_NOT_ON_PAYLOAD`, all `has`-prefixed, while its JVM gate reported none. Now 0 on
both. Gated by the shared `render-derived-has-accessor` conformance case — **the corpus had
no fixture using a derived accessor at all**, which is precisely why a divergence in the
pillar that promises byte-identical rendering survived this long.

### Fixed — a requirement could not claim a prompt template (npm)

`@implementedBy` is documented as naming "the model nodes realising this requirement", and
it resolved through the OBJECT resolver only. So a requirement could claim an entity, a
value or a projection — and naming a `template.prompt` produced
`ERR_REQUIREMENT_DANGLING_REF` ("the model moved and the requirement is stale") for a
template sitting in the loaded tree.

That excluded the estate with the **most** to gain from a status. A retired entity leaves a
table behind; a retired prompt leaves nothing, which is exactly the invisibility
`@status: abandoned` exists to fix. A project whose prompts are a first-class pillar could
describe every table it owns and not one of its prompts.

**L4 now means "a declared top-level model node"** — an `object.*` or a `template.*` — and
L5 a member of one. Bare references bind package-locally and ambiguous ones bind nothing,
the same fail-closed rule objects use. Requirements themselves are excluded: hierarchy is
nesting, and a requirement claiming a requirement would be a second, contradictory parent
mechanism. Object coverage is deliberately untouched and stays entity-grain — claiming a
template must not silence the unclaimed-entity warning.

Also verified rather than assumed, since the same report asked about them: **fields, views,
validators and identities were already claimable at L5** and needed no change. They are now
pinned by tests so that stays true. Gated by `cli/test/requirement-template-refs.test.ts`.

### Fixed — `@verifiedBy` decided what a test file is, and was wrong about a mainstream convention (npm)

`@verifiedBy`'s scan carried one closed list of test-file patterns for the five ported
ecosystems, with no way to extend it. **That list is a guess about someone else's repository,
and it was wrong on a mainstream case from the day it shipped:** Maven Failsafe names
integration tests `FooIT.java` / `FooIT.kt`, which matched nothing. Because the scan only fails
OPEN at *zero* test files, a JVM project with unit tests (matched) and integration tests
(unmatched) got a confident `ERR_REQUIREMENT_TEST_MISSING` — *"the claim was never true"* — for
a test sitting in the repo. An adopter hit exactly this: every repository test in the project is
an `*IT`, so `@verifiedBy` was unusable there and the honest workaround was to stop using the
attribute.

Three changes, of which only the first is a patch to the guess:

- **Failsafe's own defaults are now built in** (`*IT`, `*ITCase`, `IT*` for `.java`; `*IT` /
`*ITCase` for `.kt`).
- **`verify.testFiles` in `metaobjects.config.ts`** lets a project declare its own conventions
as globs, added to the built-ins. What counts as a test file is project-specific; a list
shipped by this repo cannot be authoritative about a convention it has never seen.
- **An unrecognised convention is no longer reported as a broken claim.** When a name is absent
from the corpus, `verify` now searches the unclassified source files before deciding. If the
name is there, it emits `WARN_REQUIREMENT_TEST_UNCLASSIFIED` naming the file and pointing at
`verify.testFiles`; `ERR_REQUIREMENT_TEST_MISSING` is reserved for a name that appears
**nowhere**. The second pass runs only on the miss path, so the cost is per broken claim
rather than per run.

The reusable lesson is the failure mode, not the regex: a gate that hardcodes another
ecosystem's conventions will eventually tell a correct project that it is broken, and the
default posture when the tool cannot classify something must be to say so rather than to
convict. Gated by `cli/test/verified-by-corpus.test.ts`.

### Fixed — `verify` gates the committed schema snapshot, which nothing checked (npm) — [#292](https://github.com/metaobjectsdev/metaobjects/issues/292)

`meta migrate` diffs metadata against `.metaobjects/migrations/.schema.<dialect>.json` by default
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,15 @@ line: *would this sentence have to change if the code changed but the model did
is `notes`.

**Hierarchy is nesting, and links live at the bottom.** L1 solution, L2 segment, L3
service — these never reference the model. **L4** binds an object, **L5** binds a field,
view or identity. `implementedBy` above L4 is an error. Regrouping *moves* a node; it does
not edit a parent string.
service — these never reference the model. **L4** binds a declared top-level node — an
`object.*` **or a `template.*`** — and **L5** binds a member of one: a field, view,
validator, identity, or a template's child. `implementedBy` above L4 is an error.
Regrouping *moves* a node; it does not edit a parent string.

Claim your prompts. A `template.prompt` is a model node realising a capability exactly as
an entity is, and it is the node whose retirement is hardest to see later — a removed
prompt leaves no table behind. A prompt estate with no requirement entries is the same
blind spot this whole mechanism exists to close.

**L1–L3 are levels of abstraction and ownership in the problem domain** — whose need is
this, and at what altitude — and are NEVER a directory, package, deployable or module.
Expand Down
17 changes: 17 additions & 0 deletions agent-context/skills/metaobjects-verify/references/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,26 @@ mechanism exists to preserve.
| `@implementedBy` above the L4 link floor | 1 |
| live `requirement.architectural` claimed by nothing | 1 |
| `@verifiedBy` naming a test that exists nowhere | 1 |
| `@verifiedBy` naming a name found only in an **unrecognised** test file | 0 (warning) |
| `@verifiedBy` naming a test that is **skipped** | 0 (warning) |
| an entity no requirement claims | 0 (warning) |

## What counts as a test file is YOUR project's call

The scan ships patterns for jest/vitest/bun, JUnit, Maven Failsafe (`*IT`), xUnit/NUnit,
pytest and Kotlin. Those are a convenience, **not an authority** — a built-in list is a guess
about your repository, and a wrong guess reports a real test as a broken claim. Declare your
conventions and they are added to the built-ins:

```ts
// metaobjects.config.ts
export default defineConfig({ verify: { testFiles: ["**/*IT.kt", "**/*.feature"] } });
```

If a named test is missing from the corpus but present in some other source file, `verify`
warns and names that file rather than failing — an unrecognised convention is the tool's
ignorance, not your mistake.

## What a green run does NOT prove

It proves **referential integrity**: statuses parse, levels are in range, links sit at or
Expand Down
33 changes: 33 additions & 0 deletions docs/features/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,21 @@ no `id` and no `parent`: regrouping moves a subtree.
L4 object, L5 member. `@implementedBy` is legal at **L4 and L5 only** — L1–L3 are
organisational and never reference the model.

**What L4 and L5 may name.** L4 names a declared top-level node: an `object.*` **or a
`template.*`**. A declared prompt is a model node realising a capability in the same sense
an entity is — and it is the one most in need of a status, because a retired prompt leaves
no table behind to notice. L5 names a member of one: a field, a view, a validator, an
identity, or a template's child.

```jsonc
{ "requirement.functional": {
"name": "sceneBrief", "@level": 4, "@status": "live",
"@statement": "The game master is told what the party can currently see.",
"@violation": "A scene narrated from world state the party has no way to know.",
"@implementedBy": ["acme::play::sceneBrief"] // a template.prompt
}}
```

**L1–L3 are levels of abstraction and ownership in the problem domain** — whose need is this,
and at what altitude — and are **never** a directory, package, deployable or module. Binding
to technical constructs happens only at L4 and L5, which is the allocation step. The test to
Expand Down Expand Up @@ -149,6 +164,24 @@ entry.
`@verifiedBy` names tests: `verify` checks each exists and is not skipped. It never runs
them. `@trackedBy` names issues or tickets and is **not** resolved — `verify` has no network.

**What counts as a test file is your project's call.** The scan ships patterns for the
conventions this repo ports to — jest/vitest/bun, JUnit, Maven Failsafe (`*IT`), xUnit/NUnit,
pytest, Kotlin — and they are a *convenience, not an authority*: a built-in list is a guess
about someone else's repository, and a wrong guess turns a real test into a "broken claim".
Declare yours and they are added to the built-ins:

```ts
// metaobjects.config.ts
export default defineConfig({
verify: { testFiles: ["**/*IT.kt", "**/*.feature"] },
});
```

If a named test cannot be found in the corpus but *does* appear in some other source file,
`verify` says so (`WARN_REQUIREMENT_TEST_UNCLASSIFIED`, naming the file) instead of claiming
the requirement is broken — an unrecognised convention is the tool's ignorance, not your
mistake. `ERR_REQUIREMENT_TEST_MISSING` is reserved for a name that appears **nowhere**.

> **`@verifiedBy` is existence evidence, not proof — and the difference matters most to whoever
> authored it.** The scan matches a name anywhere in the test corpus, as a whole word, in any
> language; that generosity is deliberate (a "missing" verdict then means the name appears in no
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,15 @@ line: *would this sentence have to change if the code changed but the model did
is `notes`.

**Hierarchy is nesting, and links live at the bottom.** L1 solution, L2 segment, L3
service — these never reference the model. **L4** binds an object, **L5** binds a field,
view or identity. `implementedBy` above L4 is an error. Regrouping *moves* a node; it does
not edit a parent string.
service — these never reference the model. **L4** binds a declared top-level node — an
`object.*` **or a `template.*`** — and **L5** binds a member of one: a field, view,
validator, identity, or a template's child. `implementedBy` above L4 is an error.
Regrouping *moves* a node; it does not edit a parent string.

Claim your prompts. A `template.prompt` is a model node realising a capability exactly as
an entity is, and it is the node whose retirement is hardest to see later — a removed
prompt leaves no table behind. A prompt estate with no requirement entries is the same
blind spot this whole mechanism exists to close.

**L1–L3 are levels of abstraction and ownership in the problem domain** — whose need is
this, and at what altitude — and are NEVER a directory, package, deployable or module.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,26 @@ mechanism exists to preserve.
| `@implementedBy` above the L4 link floor | 1 |
| live `requirement.architectural` claimed by nothing | 1 |
| `@verifiedBy` naming a test that exists nowhere | 1 |
| `@verifiedBy` naming a name found only in an **unrecognised** test file | 0 (warning) |
| `@verifiedBy` naming a test that is **skipped** | 0 (warning) |
| an entity no requirement claims | 0 (warning) |

## What counts as a test file is YOUR project's call

The scan ships patterns for jest/vitest/bun, JUnit, Maven Failsafe (`*IT`), xUnit/NUnit,
pytest and Kotlin. Those are a convenience, **not an authority** — a built-in list is a guess
about your repository, and a wrong guess reports a real test as a broken claim. Declare your
conventions and they are added to the built-ins:

```ts
// metaobjects.config.ts
export default defineConfig({ verify: { testFiles: ["**/*IT.kt", "**/*.feature"] } });
```

If a named test is missing from the corpus but present in some other source file, `verify`
warns and names that file rather than failing — an unrecognised convention is the tool's
ignorance, not your mistake.

## What a green run does NOT prove

It proves **referential integrity**: statuses parse, levels are in range, links sit at or
Expand Down
4 changes: 2 additions & 2 deletions fixtures/metamodel-docs/expected/types/requirement.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ How the system is built, applied uniformly across the model. Its check is UNIVER
| `@status` | string | yes | | `planned`, `live`, `partial`, `abandoned`, `superseded` | — | As on requirement.functional. A live or partial architectural requirement claimed by NOTHING is an error: a policy declared and applied to nothing. A planned one is exempt from that check — it is not applied yet by definition. |
| `@supersededBy` | string | no | | | — | The requirement that replaced this one. Expected on status=superseded. |
| `@trackedBy` | string[] | no | | | — | As on requirement.functional. Issue or ticket references for outstanding work; free-form, not resolved. |
| `@verifiedBy` | string[] | no | | | — | Names of the tests proving the policy holds. verify checks each exists and is not skipped; it never runs them. |
| `@verifiedBy` | string[] | no | | | — | OPTIONAL — omit unless you have opened the test and read what it asserts. Names of tests that assert the policy holds. verify checks each name EXISTS and is not skipped; it never runs them, and it cannot tell whether the named test verifies this requirement — any occurrence in the test corpus satisfies it. |
| `@violation` | string | yes | | | — | What breaking it looks like — the node that would contradict it. This is what makes universality checkable. |

**Allowed children**
Expand All @@ -51,7 +51,7 @@ What the product does for a user, stated as one violable claim. Its check is EXI
| `@status` | string | yes | | `planned`, `live`, `partial`, `abandoned`, `superseded` | — | planned intended but not built yet; live implemented and in use; partial implemented with known gaps; abandoned built then deliberately retired; superseded replaced by a different mechanism. A dangling @implementedBy is an ERROR on live/partial (the model moved, the requirement is stale) and ALLOWED on planned/abandoned/superseded — on planned the nodes do not exist YET, on the other two they are meant to be gone, and that is the entry doing its job. A planned requirement also never contributes to object coverage: planning a capability must not silence the warning that nothing implements it. |
| `@supersededBy` | string | no | | | — | The requirement that replaced this one. Expected on status=superseded. |
| `@trackedBy` | string[] | no | | | — | Issue or ticket references for outstanding work — a URL, an owner/repo#123 shorthand, or a tracker key. Free-form and NOT resolved by verify, which does not reach the network; unlike @verifiedBy, nothing here is checked to exist. Its job is to stop a deferred gap becoming invisible, so verify warns when a deferred requirement names no ticket. Also the right place to link the ticket that a planned requirement will be built under. |
| `@verifiedBy` | string[] | no | | | — | Names of the tests proving the behaviour. verify checks each exists and is not skipped; it never runs them. |
| `@verifiedBy` | string[] | no | | | — | OPTIONAL — omit unless you have opened the test and read what it asserts. Names of tests that assert the behaviour. verify checks each name EXISTS and is not skipped; it never runs them, and it cannot tell whether the named test verifies this requirement — any occurrence in the test corpus satisfies it. |
| `@violation` | string | yes | | | — | What breaking it looks like, in one sentence. A requirement MUST be violable: 'every entity has a uuid primary key' is (point at one with a composite string key); 'things are persisted' is not, and is a description rather than a requirement. |

**Allowed children**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
title:Party
bio: (none)
sponsor: Guild
companions: (none)
abilities: Fireball[fire aoe ] Mend[untagged]
details: present
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "format": "text", "note": "Derived has<Field> boolean accessors: present/absent/blank across scalar, collection and nested scope" }
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"title": "Party",
"bio": " ",
"abilities": [
{
"name": "Fireball",
"tags": [
"fire",
"aoe"
]
},
{
"name": "Mend",
"tags": []
}
],
"companions": [],
"sponsor": {
"name": "Guild"
},
"emptyDetails": {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
title:{{title}}
bio:{{#hasBio}} {{bio}}{{/hasBio}}{{^hasBio}} (none){{/hasBio}}
sponsor:{{#hasSponsor}} {{sponsor.name}}{{/hasSponsor}}
companions:{{#hasCompanions}} some{{/hasCompanions}}{{^hasCompanions}} (none){{/hasCompanions}}
abilities:{{#hasAbilities}}{{#abilities}} {{name}}{{#hasTags}}[{{#tags}}{{.}} {{/tags}}]{{/hasTags}}{{^hasTags}}[untagged]{{/hasTags}}{{/abilities}}{{/hasAbilities}}
details:{{#hasEmptyDetails}} present{{/hasEmptyDetails}}{{^hasEmptyDetails}} absent{{/hasEmptyDetails}}
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,13 @@ public static class Fr019SharedEnum
return new SharedEnum(
Name: CSharpNaming.Pascal(decl.Name),
Values: values,
// ADR-0039: resolving — @provided may be inherited via extends (TS reads decl.attr).
Provided: decl.Attr(FIELD_ATTR_PROVIDED) is true,
// ADR-0039 sanctioned own: @provided is a declaration-layer provenance marker
// ("THIS type is supplied by hand-written/third-party code"), like IsAbstract —
// it does not flow down an extends chain. A resolving read misfires on a chained
// declaration (root abstract B extends root abstract @provided A): B would be
// reported provided and emit a reference to a hand-written B the adopter never
// declared, instead of materializing B. Matches the JVM ports.
Provided: decl.OwnAttr(FIELD_ATTR_PROVIDED) is true,
Package: PackageOf(decl));
}

Expand Down
Loading
Loading