From 1c42e24681d83cc2cb7f4d48b4f283b1363c39c9 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 10 Nov 2026 22:56:28 -0500 Subject: [PATCH] docs(agent-context): audit skill: enum-literal signature, status-guard check, stale-copy and include-only fixes Four fixes from the audit skill's first run on an adopter project: - drift signature 13: an enum member or event name typed as a hand string beside the generated constant - axis G3: every write to a status-like enum column is conditional on the current value; the fix uses the port's ORM with generated constants, never raw SQL - an owned generator copy with lines of its own is not proof of a customization: DIFFERS also marks a copy stale by version, so check history and report lines behind and lines of the project's own separately - exempt include-only templates from the byte-identical template duplication rule Per-port files and the regenerated agent-context conformance goldens updated to match. --- .../skills/metaobjects-audit/SKILL.md | 18 ++++++++--- .../references/calibration.md | 6 ++++ .../references/capability-checklist.md | 6 +++- .../metaobjects-audit/references/csharp.md | 17 ++++++++-- .../references/drift-signatures.md | 10 ++++++ .../metaobjects-audit/references/java.md | 18 +++++++++-- .../metaobjects-audit/references/kotlin.md | 17 ++++++++-- .../references/prompt-anti-patterns.md | 2 +- .../metaobjects-audit/references/python.md | 17 ++++++++-- .../references/surface-review-axes.md | 32 +++++++++++++++++++ .../references/typescript.md | 15 +++++++-- .../.claude/skills/metaobjects-audit/SKILL.md | 18 ++++++++--- .../references/calibration.md | 6 ++++ .../references/capability-checklist.md | 6 +++- .../references/drift-signatures.md | 10 ++++++ .../metaobjects-audit/references/java.md | 18 +++++++++-- .../metaobjects-audit/references/kotlin.md | 17 ++++++++-- .../references/prompt-anti-patterns.md | 2 +- .../references/surface-review-axes.md | 32 +++++++++++++++++++ .../.claude/skills/metaobjects-audit/SKILL.md | 18 ++++++++--- .../references/calibration.md | 6 ++++ .../references/capability-checklist.md | 6 +++- .../references/drift-signatures.md | 10 ++++++ .../metaobjects-audit/references/java.md | 18 +++++++++-- .../references/prompt-anti-patterns.md | 2 +- .../references/surface-review-axes.md | 32 +++++++++++++++++++ .../.claude/skills/metaobjects-audit/SKILL.md | 18 ++++++++--- .../references/calibration.md | 6 ++++ .../references/capability-checklist.md | 6 +++- .../references/drift-signatures.md | 10 ++++++ .../references/prompt-anti-patterns.md | 2 +- .../metaobjects-audit/references/python.md | 17 ++++++++-- .../references/surface-review-axes.md | 32 +++++++++++++++++++ .../.claude/skills/metaobjects-audit/SKILL.md | 18 ++++++++--- .../references/calibration.md | 6 ++++ .../references/capability-checklist.md | 6 +++- .../references/drift-signatures.md | 10 ++++++ .../references/prompt-anti-patterns.md | 2 +- .../references/surface-review-axes.md | 32 +++++++++++++++++++ .../references/typescript.md | 15 +++++++-- .../.claude/skills/metaobjects-audit/SKILL.md | 18 ++++++++--- .../references/calibration.md | 6 ++++ .../references/capability-checklist.md | 6 +++- .../references/drift-signatures.md | 10 ++++++ .../references/prompt-anti-patterns.md | 2 +- .../references/surface-review-axes.md | 32 +++++++++++++++++++ .../references/typescript.md | 15 +++++++-- 47 files changed, 564 insertions(+), 64 deletions(-) diff --git a/agent-context/skills/metaobjects-audit/SKILL.md b/agent-context/skills/metaobjects-audit/SKILL.md index 5225faad9..a50a9e32c 100644 --- a/agent-context/skills/metaobjects-audit/SKILL.md +++ b/agent-context/skills/metaobjects-audit/SKILL.md @@ -25,7 +25,7 @@ Actual cutovers run through the existing skills mapped per finding tier (§ Brid | `references/calibration.md` | Before raising any finding: it lists the port gaps and non-defects that are never the adopter's fault. | | `references/surface-review-axes.md` | Working any axis (A–I) of Phase 1b. | | `references/capability-checklist.md` | On every axis: the exhaustive list of what the metamodel can express, one hunt per capability. | -| `references/drift-signatures.md` | Hunting hand-written second sources of truth (validators, mappers, comments, views, JSON bags). | +| `references/drift-signatures.md` | Hunting hand-written second sources of truth (validators, mappers, comments, views, JSON bags, physical-name literals, enum-member and event-name literals). | | `references/prompt-anti-patterns.md` | Axis D: the project builds text sent to an LLM or parses a reply. | | `references/vocabulary-hygiene.md` | Axis I: retired or deprecated authoring forms, custom vocabulary, vocabulary opportunities. | | `references/requirements.md` | Axis E2: the project declares `requirement.*` nodes. | @@ -65,7 +65,13 @@ Actual cutovers run through the existing skills mapped per finding tier (§ Brid not removed or reordered ones: confirm those from the file's history (`git log -p -- codegen/generators/.ts` shows no edit after the eject) first, and report a copy you cannot confirm as "possibly customized: check its history", not as a - copy to drop. For `entity`, + copy to drop. **A copy with lines of its own is not proof of a customization either.** + `meta eject --list` (and each port's listing) prints `DIFFERS` for BOTH a deliberate + customization and a copy gone stale by version, and its `M of your own` count includes + every line the reference has since removed or rewritten, which are older upstream lines. + Run the history check on every `DIFFERS` row, `M` above 0 included, and report the two + figures separately: lines behind the reference, and lines of the project's own that the + history confirms. A copy stale by version is a finding (`owned-generator-stale`). For `entity`, `routes` and `routes-hono` that holds only when every `codegen/runtime/` file the copy reaches is also marked identical: an edited adapter is a customization, and such a copy is not a finding. Those runtime rows appear only with `--format text` (or as the @@ -134,7 +140,7 @@ Axes (the full hunt for each is in `references/surface-review-axes.md` — open - **C.** Drift hotspot — validators, mappers, runtime models · **D.** Prompt pillar - **E.** Owned generators and scaffold-and-own · **E2.** Capability ledger (`requirement.*`, opt-in) - **F.** Drift-gate adoption — does it block? · **F2.** Over-generation and generated-but-bypassed (codegen waste) -- **G.** Runtime-contract anti-patterns · **G2.** `own*()` accessor discipline (a correctness defect, not advisory) +- **G.** Runtime-contract anti-patterns · **G2.** `own*()` accessor discipline (a correctness defect, not advisory) · **G3.** Guarded status transitions (every write to a status-like enum column is conditional on the current value; the fix goes through the port's ORM with generated constants, never raw SQL) - **H.** Authoring correctness / ADR conformance · **H2.** Wrong native type (`field.string` + `@dbColumnType: uuid`) - **I.** Vocabulary hygiene / modernization (advisory, never a failing finding) @@ -173,7 +179,9 @@ hidden candidate. **Stub trap:** demo-data routes have nothing to replace — cl the Phase 0 owned-generators check: recommend the package import. Importing any other generator from that subpath is correct and is not a finding. - Audit owned generators: (a) regenerate clean? (b) drifted from reference templates — - intentional (good) vs stale/accidental (missed upstream fix)? (c) hand-rolling a walk + intentional (good) vs stale/accidental (missed upstream fix)? Decide it from the copy's + history, not from the listing's `own` count: `DIFFERS` reads the same for both, and lines + the reference has since removed count as "own" (Phase 0, owned-generators check). (c) hand-rolling a walk that a declarative `scope` + `outputPattern` could replace? (d) bespoke shape better as a `templateGenerator` than a forked generator? - **Authoring ladder:** built-in fits → import it as shipped · close → **eject it and @@ -274,7 +282,7 @@ Each roadmap item: LOC retired, prerequisite, **parity-gate before deleting hand | Field | Content | |---|---| -| `id` | stable kebab id (e.g. `handwritten-crud-route`, `manual-zod-validator`) | +| `id` | stable kebab id (e.g. `handwritten-crud-route`, `manual-zod-validator`, `enum-literal-spelling`, `unguarded-status-transition`, `owned-generator-stale`) | | `title` | "you hand-wrote X that metadata can generate / model" | | `pillar` | `codegen` / `runtime` / `drift` / `prompt` | | `surface` | `entity` / `route` / `validator` / `repository` / `dto` / `hooks` / `prompt` / `migration` | diff --git a/agent-context/skills/metaobjects-audit/references/calibration.md b/agent-context/skills/metaobjects-audit/references/calibration.md index 4271e57ee..c605cd7a0 100644 --- a/agent-context/skills/metaobjects-audit/references/calibration.md +++ b/agent-context/skills/metaobjects-audit/references/calibration.md @@ -10,6 +10,9 @@ - Planned, not shipped - Cross-port version-number skew - Stale upstream prose +- Include-only templates +- Owned generator copies with lines of their own +- Enum-member literals at a checked position - **Filter-operator route codegen — the baseline grammar ships in all five ports.** The `?filter[field][op]=value` grammar (all 9 operators `eq/ne/gt/gte/lt/lte/in/like/isNull`, @@ -32,3 +35,6 @@ - **Planned, not shipped** — `api.*` / `operation.*` / `binding.*` (FR-024) and MCP exposure of declared prompts/tools are not yet in the registry; their absence is not an adopter defect. - **Cross-port version-NUMBER skew is by design** — TS/C#/Python `0.x` vs Java/Kotlin `7.x` Maven is correct; never flag the *number lines* differing. But that is exactly why you can't eyeball cross-language drift: compare **`metamodelVersion`** (Phase 0 cross-language consistency item), not the package numbers. A `metamodelVersion` MISMATCH across ports *is* a finding; so is a port lagging its ecosystem's latest release. Also flag *intra-port* drift (mixed versions within one port, or a runtime package in `devDependencies`). - **Stale upstream prose** — "hand-write the Spring controller" (Java/Kotlin) is out of date; trust `meta gen --list`, not stale prose. +- **Include-only templates are not duplicates.** A prompt template whose body is only `{{> partial }}` includes is already the shared-partial fix the template-duplication check recommends. Identical stubs are the shape to want, never a byte-identical cluster to report (`references/prompt-anti-patterns.md`, template-text duplication). +- **An owned generator copy with lines of its own is not thereby customized.** The listing prints `DIFFERS` for a deliberate customization and for a copy stale by version, and counts lines the reference later removed as "of your own". Check the copy's history before calling either (Phase 0, owned-generators check). +- **An enum-member literal at a position the type checker verifies is not drift.** A value narrowed by the generated union or enum type breaks the build when the member is renamed. Signature 13 scores only a literal in a position nothing checks: a string-typed parameter, raw SQL, a JSON payload, an event name (`references/drift-signatures.md`). diff --git a/agent-context/skills/metaobjects-audit/references/capability-checklist.md b/agent-context/skills/metaobjects-audit/references/capability-checklist.md index 71672d2a8..5741cf9de 100644 --- a/agent-context/skills/metaobjects-audit/references/capability-checklist.md +++ b/agent-context/skills/metaobjects-audit/references/capability-checklist.md @@ -58,7 +58,11 @@ classify it (using the classification scheme in `SKILL.md`) and route the cutove - **`field.date` / `field.time` / `field.timestamp`** (`@autoSet`) — hunt hand-stamped `createdAt` / `updatedAt` assignments and ad-hoc temporal parsing; `@autoSet` stamps them. - **`field.enum`** (`@values`) — hunt hand-written TS unions, language `enum`s, or DB - `CHECK ... IN (...)` lists that restate a modeled enum's members. + `CHECK ... IN (...)` lists that restate a modeled enum's members. Two more hunts on the same + capability: a member or event name typed as a hand string beside the generated constant + (`drift-signatures.md`, signature 13), and a status-like enum column written without a + condition on its current value (`surface-review-axes.md`, G3: every write conditional, through + the port's ORM with generated constants, never raw SQL). - **`field.uuid`** — hunt UUIDs typed as bare strings / hand-validated. - **`field.object`** (`@objectRef`, `@storage`) — hunt hand-flattened owned columns or hand-rolled jsonb (de)serialization the `@storage` mode (`flattened`/`jsonb`/`subdocument`) diff --git a/agent-context/skills/metaobjects-audit/references/csharp.md b/agent-context/skills/metaobjects-audit/references/csharp.md index 66b150442..7bb28c7c7 100644 --- a/agent-context/skills/metaobjects-audit/references/csharp.md +++ b/agent-context/skills/metaobjects-audit/references/csharp.md @@ -67,6 +67,8 @@ rejected (exit 2). | `FromSqlInterpolated(` outside `.g.cs` | stored-proc call — candidate for the `callable` generator | | `// keep in sync with` / `// mirrors the` | second-source-of-truth comment — always a finding | | `HasPrecision(` hand-coded | `field.decimal` with `@precision`/`@scale` drives this from the `entity` generator | +| a member of a generated enum typed as a hand string (`"Error"` in raw SQL, a JSON body, a topic or event name, a `switch` over a `string`) beside the generated enum constant | **signature 13.** Value-first from each enum's `@values`. A value typed as the generated C# enum is resolved and exempt; a `string` position, SQL text or a payload is compared and scores | +| a status-like column written with a property assignment then `SaveChanges`, or `ExecuteSqlRaw("UPDATE ... SET Status")`, with no condition on the current value | **axis G3.** Guard it through EF Core: `Where(x => x.Id == id && x.Status == )` then `ExecuteUpdateAsync`, with the generated enum constant, and check the affected-row count. Never raw SQL | | a table/column string in raw-SQL EF calls, or `nameof(Entity.Prop)` standing in for a column | second spelling of a declared physical name — reference `Names.g.cs` (`AuthorNames.SourcePrimaryTable` / `Column`, emitted when `codegen/Generators.cs` lists `names` — `Names.Name` is the OBJECT's name, not the table); an EF property inside LINQ is the typed handle — correct | --- @@ -92,8 +94,19 @@ identical too, proves a copy unmodified; that row is a **finding**: point the (`dotnet meta eject --undo` removes every identical copy and prints the edit). `DIFFERS` with `0 of your own` rules out added lines, not removed ones: confirm from `git log -p` on the copy that nothing changed after the eject, else report it as **possibly customized: -check its history**. A copy with lines of its own, or an edited runtime file, is a -customization; check only how far behind it is. Count lines for the Phase 0 total: lines in +check its history**. + +**`DIFFERS` is printed for BOTH a deliberate customization and a copy gone stale by version**, +so the listing cannot tell them apart, and a copy with lines of its own is NOT thereby a +customization: `M of your own` counts every line absent from today's reference, so after the +reference moved on, a line it removed or rewrote reads as "yours" and is an older upstream +line. Run the history check on any `DIFFERS` row, `M` above 0 included: lines the copy's later +commits added are the project's own; lines already in the eject commit are upstream's and +count as behind. Report two figures, ` lines behind the reference` and ` lines of the +project's own (confirmed from history)`. A copy whose own lines history does not confirm is +**possibly customized or stale: check its history**; one that is stale by version is a finding +(`owned-generator-stale`): re-eject at the current version or return to the stock entry. +Count lines for the Phase 0 total: lines in copies proven unmodified, over all lines under `codegen/generators/` and `codegen/runtime/`. A generator the project WRITES is an `IGenerator` listed in diff --git a/agent-context/skills/metaobjects-audit/references/drift-signatures.md b/agent-context/skills/metaobjects-audit/references/drift-signatures.md index 5148b26db..492c015b6 100644 --- a/agent-context/skills/metaobjects-audit/references/drift-signatures.md +++ b/agent-context/skills/metaobjects-audit/references/drift-signatures.md @@ -13,6 +13,7 @@ - 10. N declarations of one field across objects - 11. A metadata-derived value spelled a second time by hand - 12. An open JSON bag whose readers agree on a shape +- 13. An enum member or event name typed by hand beside the generated constant Per finding: `file:line` → what → generated-equivalent exists? → recommendation. @@ -55,3 +56,12 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend **Do NOT flag:** a bag whose readers genuinely pass it through (logged, echoed, stored, forwarded to a third party) without reading a key; a third-party or LLM raw response whose shape is not the adopter's to declare; an **array of scalars**, which is `isArray: true` on the base subtype and never an object; a bag the metadata's own comment justifies as open with a reason. **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — nested map values are validated on every write path on every port as of 1.0.1 ([#362](https://github.com/metaobjectsdev/metaobjects/issues/362) — before that, Java skipped the cascade on TPH write paths and C# skipped it everywhere, silently accepting invalid values rather than erroring). **Check the adopter's pinned version before recommending this rung to a Java-TPH or C# consumer: on 1.0.0 the hole is open and they need their own boundary check.** Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor. + +13. **An enum member or event name typed as a hand string beside the generated constant.** Signature 11 covers physical names; this is the same rule for a model's enum values and for the event or message names an enum member spells. A literal in non-generated code that EQUALS a member of a generated enum (or the name of a declared event) is a second spelling, and it is the commonest drift an estate that already uses the constants carries: the file imports `CouncilStatusEnum` and uses `CouncilStatusEnum.enum.complete` on one line, then writes `status: "error"` on the next. The two spellings agree today and nothing ties them together. **Hunt VALUE-first, from the declaration outward:** list every enum's members from the metadata (`@values` on each `field.enum`, plus the member names of any enum-backed event or message type), then grep the non-generated tree, config and fixtures included, for each member as a quoted literal. Distinctive members (`"edited_approved"`, `"awaiting_review"`) score on the hit; generic ones (`"error"`, `"done"`, `"active"`) only by reading the site, because the same word may belong to something the metadata does not own. **A file that already imports the enum's generated constant is the strongest tell**: its author knew the constant existed, so a bare literal of another member of that enum in the same file is almost never intentional. Count those first. + + **Apply signature 11's discriminator, then say which side of it the site is on.** Rename the member in metadata and regenerate. Where the site is RESOLVED, the regeneration breaks it before anything runs and it is not a finding: a value narrowed by the generated union (`export type = "a" | "b"` on TypeScript, a `Literal[...]` annotation on Python, a generated `enum` type on Java, Kotlin and C#) at a position the compiler or type checker actually checks. Where the site is COMPARED, it survives the regeneration and fails later, on data: a literal in a string-typed position (a `Record`, a `string` parameter, a loosely typed object spread into a `.set(...)`, a `dict`), in raw SQL (`WHERE status = 'error'`), in a JSON payload or fixture, as a broker topic or an SSE / websocket event name, in a `switch` over a `string` that the type checker cannot exhaust, or in a comparison against a column read back as `string`. **Verify the position before scoring it, never the shape of the literal:** the TypeScript case that earned the exemption (332 narrowed literals, 0 findings) and the case that earned this signature (`status: "error"` beside `CouncilStatusEnum.enum.complete`) look identical in a grep, and only the type at the site separates them. If you cannot tell which side a site is on, read the declared type of the target; report it as unverified rather than skipping it. + + **Do not flag:** a literal narrowed by the generated union or enum type at a checked position; a value the metadata does not own (an application-level state machine's label that merely coincides with a member, a page route, copy); a literal inside a generated file; and a test asserting the wire value of a member on purpose (a test that pins `"complete"` is a contract test and says so). A literal inside a `source.rdb` `@sql` body is metadata and is exempt, as in signature 11. + + **Remedy:** reference the generated constant, which is the one rule for every port: the Zod enum's member (for example `CouncilStatusEnum.enum.complete`) or the generated union type on TypeScript, the generated `Literal` alias or enum on Python, the generated enum constant on Java, Kotlin and C#. In raw SQL the value moves to a bound parameter fed from the constant (never a literal inside the SQL text), and the table and column in that statement are signature 11's names artifact. Report the site count as `/ sites` exactly as signature 11 does, and carry a deferral forward with its count. This signature needs no names artifact; it needs the generated enum type. If the project generates none, the un-wired `entity` generator is the first finding. + diff --git a/agent-context/skills/metaobjects-audit/references/java.md b/agent-context/skills/metaobjects-audit/references/java.md index 7a20c3849..1b34fd085 100644 --- a/agent-context/skills/metaobjects-audit/references/java.md +++ b/agent-context/skills/metaobjects-audit/references/java.md @@ -66,6 +66,8 @@ plugin `` — that is the source of truth for which generators ru | `// keep in sync with` / `// mirrors the` | second-source-of-truth comment — always a finding | | `ObjectMapper.readValue(` outside a `*Parser.java` file | check if a responding `template.prompt` (`@responseRef`) exists — output-parser codegen ships in Java | | `LIMIT ?` / `OFFSET ?` assembled by hand | generated CRUD routes handle pagination; OMDB `getObjects` accepts `QueryOptions` | +| a member of a generated enum typed as a hand string (`"ERROR"` in JDBC SQL, a JSON body, a topic or event name, a `switch` over a `String`) beside the generated enum constant | **signature 13.** Value-first from each enum's `@values`. A value typed as the generated enum is resolved and exempt; a `String` position, SQL text or a payload is compared and scores | +| a status-like column written with a JDBC `UPDATE ... SET status`, a repository `save` of an entity whose status was set freely, or a Spring Data query with no `AND status = :from` | **axis G3.** Guard it through OMDB, the generated repository or a JPA modifying query over entity attributes, with the generated enum constant, and check the updated-row count. Never raw SQL | | a table/column string in a repository implementation — JDBC SQL, jOOQ `table("authors")`, a Spring Data query | second spelling of a declared physical name — reference `Names` (`AuthorNames.SOURCE_PRIMARY_TABLE` / `_COLUMN`; opt-in `SpringNamesGenerator`): an un-wired generator is the finding first | --- @@ -91,8 +93,20 @@ row is a **finding**: point the `pom.xml` entry back at the stock class and remo (`mvn metaobjects:eject -Dundo=true` removes every identical copy and prints the `pom.xml` edit). `DIFFERS` with `0 of your own` rules out added lines, not removed ones: confirm from `git log -p` on the copy that nothing changed after the eject, else report it as **possibly -customized: check its history**. A copy with lines of its own is a customization; check -only how far behind it is. Count lines for the Phase 0 total: lines in copies proven +customized: check its history**. + +**`DIFFERS` is printed for BOTH a deliberate customization and a copy gone stale by version**, +so the listing cannot tell them apart, and a copy with lines of its own is NOT thereby a +customization: `M of your own` counts every line absent from today's reference, so after the +reference moved on, a line it removed or rewrote reads as "yours" and is an older upstream +line. Run the history check on any `DIFFERS` row, `M` above 0 included: lines the copy's later +commits added are the project's own; lines already in the eject commit are upstream's and +count as behind. Report two figures, ` lines behind the reference` and ` lines of the +project's own (confirmed from history)`. A copy whose own lines history does not confirm is +**possibly customized or stale: check its history**; one that is stale by version is a finding +(`owned-generator-stale`): re-eject at the current version or return to the stock entry. + +Count lines for the Phase 0 total: lines in copies proven unmodified, over all lines under `codegen/` that eject wrote or the project wrote. Java also has **both** authoring paths, so a project needing a shape the diff --git a/agent-context/skills/metaobjects-audit/references/kotlin.md b/agent-context/skills/metaobjects-audit/references/kotlin.md index 58903ecdc..67d3bc0dc 100644 --- a/agent-context/skills/metaobjects-audit/references/kotlin.md +++ b/agent-context/skills/metaobjects-audit/references/kotlin.md @@ -71,6 +71,8 @@ The active generator list is declared in `pom.xml` under the plugin `` in the predicate, or an `exec("UPDATE ... SET status")` | **axis G3.** Guard it through Exposed: `T.update({ (T.id eq id) and (T.status eq ) })` with the generated constant, and check the returned count. Never raw SQL | | a table/column string in `exec("…")` raw SQL or a Flyway script | reference `Names` (`KotlinNamesGenerator`, opt-in); `AuthorTable.name` inside the Exposed DSL is the typed handle — correct, not a finding | --- @@ -95,8 +97,19 @@ own]`, and a generator copied with companion classes adds `; differing: ` back at the stock class and remove the copy (`mvn metaobjects:eject -Dundo=true` removes every identical copy and prints the `pom.xml` edit). `DIFFERS` with `0 of your own` rules out added lines, not removed ones: confirm from `git log -p` on the copy that nothing -changed after the eject, else report it as **possibly customized: check its history**. A -copy with lines of its own is a customization; check only how far behind it is. Count lines +changed after the eject, else report it as **possibly customized: check its history**. + +**`DIFFERS` is printed for BOTH a deliberate customization and a copy gone stale by version**, +so the listing cannot tell them apart, and a copy with lines of its own is NOT thereby a +customization: `M of your own` counts every line absent from today's reference, so after the +reference moved on, a line it removed or rewrote reads as "yours" and is an older upstream +line. Run the history check on any `DIFFERS` row, `M` above 0 included: lines the copy's later +commits added are the project's own; lines already in the eject commit are upstream's and +count as behind. Report two figures, ` lines behind the reference` and ` lines of the +project's own (confirmed from history)`. A copy whose own lines history does not confirm is +**possibly customized or stale: check its history**; one that is stale by version is a finding +(`owned-generator-stale`): re-eject at the current version or return to the stock entry. +Count lines for the Phase 0 total: lines in copies proven unmodified, over all lines under `codegen/` that eject wrote or the project wrote. diff --git a/agent-context/skills/metaobjects-audit/references/prompt-anti-patterns.md b/agent-context/skills/metaobjects-audit/references/prompt-anti-patterns.md index 4f5678520..af230bbb5 100644 --- a/agent-context/skills/metaobjects-audit/references/prompt-anti-patterns.md +++ b/agent-context/skills/metaobjects-audit/references/prompt-anti-patterns.md @@ -59,4 +59,4 @@ format-built argument at the project's own LLM request call sites. `voRequest` / `voResponse` jsonb columns must be authored `field.object` — the loader must not mutate the tree; vendor SDK client + pricing are BYO (ADR-0024). - No `meta verify --templates` gate; no declared `@maxChars` / `@maxTokens` budget. -- **Template-text duplication.** Hash every external template file (`md5sum` the template dir) and diff within groups sharing a `payloadRef`. A byte-identical cluster, or N≥3 templates sharing an identical skeleton with only slot lines differing, is ONE prompt maintained N times — a wording fix must be applied N times and WILL be missed. Recommend a shared `{{> group/partial }}` include (the render engine inlines partials recursively) or one template + payload-carried variant fields. Do NOT flag: the per-node `payloadRef` / `@format` repetition across `template.prompt` nodes (loader-forced, not a copy); templates meant to diverge independently — require N≥3 and structural identity before flagging. +- **Template-text duplication.** Hash every external template file (`md5sum` the template dir; skip include-only files, below) and diff within groups sharing a `payloadRef`. A byte-identical cluster, or N≥3 templates sharing an identical skeleton with only slot lines differing, is ONE prompt maintained N times — a wording fix must be applied N times and WILL be missed. Recommend a shared `{{> group/partial }}` include (the render engine inlines partials recursively) or one template + payload-carried variant fields. Do NOT flag: the per-node `payloadRef` / `@format` repetition across `template.prompt` nodes (loader-forced, not a copy); templates meant to diverge independently — require N≥3 and structural identity before flagging; **an include-only template**. A template whose body is nothing but partial includes (every non-blank line is a `{{> group/partial }}` and there is no prose of its own) is already the fix this section recommends: the wording lives once in the partial, and the stub is the per-prompt entry point the loader requires. Several such stubs being byte-identical, or sharing the same include list, is the recommended shape, not a cluster. Drop include-only files from the hash groups BEFORE comparing, and apply the cluster test to what remains. A template that mixes includes with a skeleton of its own prose still counts, and its prose is what is compared. When every member of a cluster is include-only, report it as the gold standard, the way a repository that imports `Names` is called out as good. diff --git a/agent-context/skills/metaobjects-audit/references/python.md b/agent-context/skills/metaobjects-audit/references/python.md index 08974a711..9b61d0f53 100644 --- a/agent-context/skills/metaobjects-audit/references/python.md +++ b/agent-context/skills/metaobjects-audit/references/python.md @@ -68,6 +68,8 @@ regardless of server language — see the migration reference. | `def get_all_` / `def create_` / `def update_` / `def delete_` in non-generated files | hand-rolled CRUD — compare to the generated router | | `# keep in sync with` / `# mirrors the` | second-source-of-truth comment — always a finding | | `try: ... except KeyError` / `?? ''` around format strings in prompt code | silent-degradation hack around a prompt payload — flag it | +| a member of a generated enum typed as a hand string beside its generated `Literal` / enum (`status="error"` in a `dict`, an f-string SQL, a JSON body, an event name) | **signature 13.** Value-first from each enum's `@values`. A value at a position the type checker verifies against the generated `Literal[...]` is resolved and exempt; a `dict`, a `str` parameter or SQL text is compared and scores | +| a status-like column written with `update(T).values(status=...)` / `UPDATE ... SET status` and no condition on the current value | **axis G3.** Guard it through SQLAlchemy (`update(T).where(T.id == id, T.status == )`) or the `ObjectManager`, with the generated constant, and check `rowcount`. Never raw SQL | | a table/column string in the repository implementation (SQLAlchemy Core, asyncpg/psycopg SQL) | second spelling of a declared physical name — reference `_names.py` (`AUTHOR_SOURCE_PRIMARY_TABLE` / `AUTHOR__COLUMN`, emitted when a target lists `names`); no typed handle exists on this port, so that is never the reason to waive it | --- @@ -95,8 +97,19 @@ a generator that reads a `codegen/runtime/` module (`routes`: `filter_parser`, copy (`metaobjects eject --undo` removes every identical copy and prints the config edit). `DIFFERS` with `0 of your own` rules out added lines, not removed ones: confirm from `git log -p` on the copy that nothing changed after the eject, else report it as **possibly -customized: check its history**. A copy with lines of its own, or an edited companion, is a -customization; check only how far behind it is. Count lines for the Phase 0 total: lines in +customized: check its history**. + +**`DIFFERS` is printed for BOTH a deliberate customization and a copy gone stale by version**, +so the listing cannot tell them apart, and a copy with lines of its own is NOT thereby a +customization: `M of your own` counts every line absent from today's reference, so after the +reference moved on, a line it removed or rewrote reads as "yours" and is an older upstream +line. Run the history check on any `DIFFERS` row, `M` above 0 included: lines the copy's later +commits added are the project's own; lines already in the eject commit are upstream's and +count as behind. Report two figures, ` lines behind the reference` and ` lines of the +project's own (confirmed from history)`. A copy whose own lines history does not confirm is +**possibly customized or stale: check its history**; one that is stale by version is a finding +(`owned-generator-stale`): re-eject at the current version or return to the stock entry. +Count lines for the Phase 0 total: lines in copies proven unmodified, over all lines under `codegen/generators/` and `codegen/runtime/`. **A finding of the form "the built-ins do not emit the shape this project needs", or a diff --git a/agent-context/skills/metaobjects-audit/references/surface-review-axes.md b/agent-context/skills/metaobjects-audit/references/surface-review-axes.md index 4a5e29277..58c7f3feb 100644 --- a/agent-context/skills/metaobjects-audit/references/surface-review-axes.md +++ b/agent-context/skills/metaobjects-audit/references/surface-review-axes.md @@ -13,6 +13,7 @@ The full hunt for each axis of Phase 1b (Partial / Deep path). Each axis runs in - F2. Over-generation — unnecessary generated files - G. Runtime-contract anti-patterns - G2. `own*()` accessor discipline +- G3. Guarded status transitions - H. Authoring correctness / ADR conformance - H2. Wrong native type - I. Vocabulary hygiene / modernization (advisory) @@ -200,6 +201,37 @@ The full hunt for each axis of Phase 1b (Partial / Deep path). Each axis runs in deliberately-own attribute `@dbColumnType` (a physical column-type override, never inherited). Any own read that carries a comment naming one of these cases is fine; an uncommented own read of an effective property is the defect. +- [ ] **G3. Guarded status transitions (a correctness check, `unguarded-status-transition`).** + For each model whose enum column is status-like (a `field.enum`, or a `field.string` named + `status` / `state` / `phase` / `stage`, whose members read as a lifecycle: pending, running, + complete, error), list **every write to that column** in non-generated code: an ORM + `update(...).set({ status })`, a `UPDATE ... SET status`, an entity property assignment that is + then saved, an upsert. For each write ask one question: **is it conditional on the current + value?** A guarded write names the state it moves from (`WHERE id = ? AND status = `, a + compare-and-set whose affected-row count is checked, an optimistic-lock version, a database + constraint that rejects the move). An unguarded write (`SET status = 'complete' WHERE id = ?`) + lets two workers, a retry or a late callback move a row backwards or out of a terminal state + (`complete` overwritten by `error`), and nothing reports it. An insert's initial value is not a + transition. A write that is the only possible transition and has one writer is lower + confidence, not exempt: say why it is safe to leave. Score `guarded writes / all writes` per + status column and list the unguarded ones; also state whether the allowed `from` states are + written down anywhere, because a rule held only in scattered `WHERE` clauses is a second + source of truth. + **The remedy never uses raw SQL.** Raw SQL spells the table, the column and the enum member by + hand, and those names drift. At a minimum the guarded write references the generated constants + for the table, the field and the enum member; ideally it goes through the port's ORM, which + carries them as typed handles, with the constraints the model already generates (the enum's + `CHECK`) behind it. Per port the guard is a predicate on the current value inside the ORM + update: Drizzle / Kysely `.where(and(eq(t.id, id), eq(t.status, )))` on TypeScript; + SQLAlchemy `update(T).where(T.id == id, T.status == )` or the `ObjectManager` on Python; + OMDB or the generated repository, or a JPA modifying query over entity attributes, on Java; + Exposed `T.update({ (T.id eq id) and (T.status eq ) })` on Kotlin; EF Core + `ExecuteUpdateAsync` after a `Where(x => x.Status == )` on C#. Each checks the + affected-row count. `` is the generated enum constant, never a literal (signature 13 in + `drift-signatures.md`). **If the allowed transitions need a declared form, that is a question + for the human, never a new attribute** (ADR-0023): the sketch proposes the ORM guard and + records the transition table as an open decision. Pillar `runtime`, surface `repository`. + - [ ] **H. Authoring-correctness / ADR-conformance (deep).** Invented/unregistered `@`-attrs or post-bootstrap registration (ADR-0023 — custom attrs belong in a registered provider or `attr.properties`); retired source-v2 forms (`source.dbTable` / `@name` / diff --git a/agent-context/skills/metaobjects-audit/references/typescript.md b/agent-context/skills/metaobjects-audit/references/typescript.md index d9041026b..cada45c52 100644 --- a/agent-context/skills/metaobjects-audit/references/typescript.md +++ b/agent-context/skills/metaobjects-audit/references/typescript.md @@ -70,6 +70,8 @@ here — the table is its second pass, not its definition. | the resource path in an API call or mount — `app.get("/subscribers")`, the `/subscribers` inside `fetch("/api/subscribers")`, a TanStack `queryKey` path | second spelling of `$path` on the entity module and its `.meta.ts` twin. The `/api` prefix is NOT part of the finding: the constants surface deliberately carries no prefix member — it is the provider's `baseUrl`, a deployment fact — so the prefix has no constant by design and is never a gap either | | a field name passed as text where the parameter's type is a bare `string` — `sort: "created_at"`, `filter["email"]`, a column-id string in a grid config | the per-field constants object carries `name`. Check the parameter type first: where the generated hook types it as a union of field names it is resolved and this row does not apply, exactly as a typed property access does not | | a form label or a validation limit/message re-typed in hand JSX or a hand validator (`maxLength={255}`, `"Email is required"`) | derived from `@title` and the field's validator children into `label` / `rules`; the hand copy is a second source of truth | +| a member of a generated enum typed as a hand string where the file already imports its constant (`status: "error"` beside `CouncilStatusEnum.enum.complete`), in a string-typed position, raw SQL, a JSON payload or an event name | **signature 13.** Value-first: grep the non-generated tree for each enum's `@values`. Check the declared type at the site first: a position narrowed by the generated `export type ` union is resolved and exempt; a `Record`, a `string` parameter or a `sql` fragment is compared and scores | +| a status-like column written with `.set({ status })` and no condition on the current value, or a `sql` `UPDATE ... SET status` | **axis G3.** Guard it through Drizzle / Kysely: `.where(and(eq(t.id, id), eq(t.status, )))` with the generated constant, and check the affected-row count. Never raw SQL | | **NOT a finding — the rule's consequences, not a list:** anything RESOLVED (a typed handle, a typed property access, a typed filter key, an enum member narrowed by the generated `export type ` union — measured: 332 such literals on one estate, 0 real) and anything the metadata does not OWN (a page route or link, `` / `` — 11 apparent `$path` hits on another estate, 0 real; the deployment prefix above) | rewriting a resolved reference to a string lookup is a regression; converting a page route couples the site's URL structure to the API's. Emitted `options: [...] as const` populates a dropdown, not replaces checked literals. A shape that is neither resolved nor unowned and has a constant → score it, whatever it is | --- @@ -118,8 +120,17 @@ are the `--format text` labels. In `--format json` or `toon` the same row carrie `diff -u node_modules/@metaobjectsdev/codegen-ts/src/reference/.ts codegen/generators/.ts` showing only upstream changes. Confirmed, it is the same finding. Not confirmed, report it as **possibly customized: check its history**, never as a copy to drop. -- A copy with lines of its own is an intentional customization. Check only how far behind - it is. +- A copy with lines of its own (`M > 0`) is **not** thereby an intentional customization. The + listing prints `DIFFERS` for both a deliberate customization and a copy gone stale by + version, and `M line(s) of your own` counts every line absent from today's reference: after + the reference moved on, a line it removed or rewrote reads as "yours" and is an older + upstream line. Run the same history check on any `DIFFERS` row, `M > 0` included + (`git log -p -- codegen/generators/.ts`): lines the copy's later commits added are the + project's own; lines already in the eject commit are upstream's, and count as behind. Report + ` lines behind the reference` and ` lines of the project's own (confirmed from + history)` as two figures. A copy whose own lines history does not confirm is **possibly + customized or stale: check its history**; a copy that is stale by version is a finding + (`owned-generator-stale`): re-eject at the current version or return to the package import. Count lines for the Phase 0 total: lines in copies proven unmodified, with the `codegen/runtime/` files they reach, over all lines the project owns under `codegen/` diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md index 5225faad9..a50a9e32c 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md @@ -25,7 +25,7 @@ Actual cutovers run through the existing skills mapped per finding tier (§ Brid | `references/calibration.md` | Before raising any finding: it lists the port gaps and non-defects that are never the adopter's fault. | | `references/surface-review-axes.md` | Working any axis (A–I) of Phase 1b. | | `references/capability-checklist.md` | On every axis: the exhaustive list of what the metamodel can express, one hunt per capability. | -| `references/drift-signatures.md` | Hunting hand-written second sources of truth (validators, mappers, comments, views, JSON bags). | +| `references/drift-signatures.md` | Hunting hand-written second sources of truth (validators, mappers, comments, views, JSON bags, physical-name literals, enum-member and event-name literals). | | `references/prompt-anti-patterns.md` | Axis D: the project builds text sent to an LLM or parses a reply. | | `references/vocabulary-hygiene.md` | Axis I: retired or deprecated authoring forms, custom vocabulary, vocabulary opportunities. | | `references/requirements.md` | Axis E2: the project declares `requirement.*` nodes. | @@ -65,7 +65,13 @@ Actual cutovers run through the existing skills mapped per finding tier (§ Brid not removed or reordered ones: confirm those from the file's history (`git log -p -- codegen/generators/.ts` shows no edit after the eject) first, and report a copy you cannot confirm as "possibly customized: check its history", not as a - copy to drop. For `entity`, + copy to drop. **A copy with lines of its own is not proof of a customization either.** + `meta eject --list` (and each port's listing) prints `DIFFERS` for BOTH a deliberate + customization and a copy gone stale by version, and its `M of your own` count includes + every line the reference has since removed or rewritten, which are older upstream lines. + Run the history check on every `DIFFERS` row, `M` above 0 included, and report the two + figures separately: lines behind the reference, and lines of the project's own that the + history confirms. A copy stale by version is a finding (`owned-generator-stale`). For `entity`, `routes` and `routes-hono` that holds only when every `codegen/runtime/` file the copy reaches is also marked identical: an edited adapter is a customization, and such a copy is not a finding. Those runtime rows appear only with `--format text` (or as the @@ -134,7 +140,7 @@ Axes (the full hunt for each is in `references/surface-review-axes.md` — open - **C.** Drift hotspot — validators, mappers, runtime models · **D.** Prompt pillar - **E.** Owned generators and scaffold-and-own · **E2.** Capability ledger (`requirement.*`, opt-in) - **F.** Drift-gate adoption — does it block? · **F2.** Over-generation and generated-but-bypassed (codegen waste) -- **G.** Runtime-contract anti-patterns · **G2.** `own*()` accessor discipline (a correctness defect, not advisory) +- **G.** Runtime-contract anti-patterns · **G2.** `own*()` accessor discipline (a correctness defect, not advisory) · **G3.** Guarded status transitions (every write to a status-like enum column is conditional on the current value; the fix goes through the port's ORM with generated constants, never raw SQL) - **H.** Authoring correctness / ADR conformance · **H2.** Wrong native type (`field.string` + `@dbColumnType: uuid`) - **I.** Vocabulary hygiene / modernization (advisory, never a failing finding) @@ -173,7 +179,9 @@ hidden candidate. **Stub trap:** demo-data routes have nothing to replace — cl the Phase 0 owned-generators check: recommend the package import. Importing any other generator from that subpath is correct and is not a finding. - Audit owned generators: (a) regenerate clean? (b) drifted from reference templates — - intentional (good) vs stale/accidental (missed upstream fix)? (c) hand-rolling a walk + intentional (good) vs stale/accidental (missed upstream fix)? Decide it from the copy's + history, not from the listing's `own` count: `DIFFERS` reads the same for both, and lines + the reference has since removed count as "own" (Phase 0, owned-generators check). (c) hand-rolling a walk that a declarative `scope` + `outputPattern` could replace? (d) bespoke shape better as a `templateGenerator` than a forked generator? - **Authoring ladder:** built-in fits → import it as shipped · close → **eject it and @@ -274,7 +282,7 @@ Each roadmap item: LOC retired, prerequisite, **parity-gate before deleting hand | Field | Content | |---|---| -| `id` | stable kebab id (e.g. `handwritten-crud-route`, `manual-zod-validator`) | +| `id` | stable kebab id (e.g. `handwritten-crud-route`, `manual-zod-validator`, `enum-literal-spelling`, `unguarded-status-transition`, `owned-generator-stale`) | | `title` | "you hand-wrote X that metadata can generate / model" | | `pillar` | `codegen` / `runtime` / `drift` / `prompt` | | `surface` | `entity` / `route` / `validator` / `repository` / `dto` / `hooks` / `prompt` / `migration` | diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/calibration.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/calibration.md index 4271e57ee..c605cd7a0 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/calibration.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/calibration.md @@ -10,6 +10,9 @@ - Planned, not shipped - Cross-port version-number skew - Stale upstream prose +- Include-only templates +- Owned generator copies with lines of their own +- Enum-member literals at a checked position - **Filter-operator route codegen — the baseline grammar ships in all five ports.** The `?filter[field][op]=value` grammar (all 9 operators `eq/ne/gt/gte/lt/lte/in/like/isNull`, @@ -32,3 +35,6 @@ - **Planned, not shipped** — `api.*` / `operation.*` / `binding.*` (FR-024) and MCP exposure of declared prompts/tools are not yet in the registry; their absence is not an adopter defect. - **Cross-port version-NUMBER skew is by design** — TS/C#/Python `0.x` vs Java/Kotlin `7.x` Maven is correct; never flag the *number lines* differing. But that is exactly why you can't eyeball cross-language drift: compare **`metamodelVersion`** (Phase 0 cross-language consistency item), not the package numbers. A `metamodelVersion` MISMATCH across ports *is* a finding; so is a port lagging its ecosystem's latest release. Also flag *intra-port* drift (mixed versions within one port, or a runtime package in `devDependencies`). - **Stale upstream prose** — "hand-write the Spring controller" (Java/Kotlin) is out of date; trust `meta gen --list`, not stale prose. +- **Include-only templates are not duplicates.** A prompt template whose body is only `{{> partial }}` includes is already the shared-partial fix the template-duplication check recommends. Identical stubs are the shape to want, never a byte-identical cluster to report (`references/prompt-anti-patterns.md`, template-text duplication). +- **An owned generator copy with lines of its own is not thereby customized.** The listing prints `DIFFERS` for a deliberate customization and for a copy stale by version, and counts lines the reference later removed as "of your own". Check the copy's history before calling either (Phase 0, owned-generators check). +- **An enum-member literal at a position the type checker verifies is not drift.** A value narrowed by the generated union or enum type breaks the build when the member is renamed. Signature 13 scores only a literal in a position nothing checks: a string-typed parameter, raw SQL, a JSON payload, an event name (`references/drift-signatures.md`). diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md index 71672d2a8..5741cf9de 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -58,7 +58,11 @@ classify it (using the classification scheme in `SKILL.md`) and route the cutove - **`field.date` / `field.time` / `field.timestamp`** (`@autoSet`) — hunt hand-stamped `createdAt` / `updatedAt` assignments and ad-hoc temporal parsing; `@autoSet` stamps them. - **`field.enum`** (`@values`) — hunt hand-written TS unions, language `enum`s, or DB - `CHECK ... IN (...)` lists that restate a modeled enum's members. + `CHECK ... IN (...)` lists that restate a modeled enum's members. Two more hunts on the same + capability: a member or event name typed as a hand string beside the generated constant + (`drift-signatures.md`, signature 13), and a status-like enum column written without a + condition on its current value (`surface-review-axes.md`, G3: every write conditional, through + the port's ORM with generated constants, never raw SQL). - **`field.uuid`** — hunt UUIDs typed as bare strings / hand-validated. - **`field.object`** (`@objectRef`, `@storage`) — hunt hand-flattened owned columns or hand-rolled jsonb (de)serialization the `@storage` mode (`flattened`/`jsonb`/`subdocument`) diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md index 5148b26db..492c015b6 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md @@ -13,6 +13,7 @@ - 10. N declarations of one field across objects - 11. A metadata-derived value spelled a second time by hand - 12. An open JSON bag whose readers agree on a shape +- 13. An enum member or event name typed by hand beside the generated constant Per finding: `file:line` → what → generated-equivalent exists? → recommendation. @@ -55,3 +56,12 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend **Do NOT flag:** a bag whose readers genuinely pass it through (logged, echoed, stored, forwarded to a third party) without reading a key; a third-party or LLM raw response whose shape is not the adopter's to declare; an **array of scalars**, which is `isArray: true` on the base subtype and never an object; a bag the metadata's own comment justifies as open with a reason. **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — nested map values are validated on every write path on every port as of 1.0.1 ([#362](https://github.com/metaobjectsdev/metaobjects/issues/362) — before that, Java skipped the cascade on TPH write paths and C# skipped it everywhere, silently accepting invalid values rather than erroring). **Check the adopter's pinned version before recommending this rung to a Java-TPH or C# consumer: on 1.0.0 the hole is open and they need their own boundary check.** Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor. + +13. **An enum member or event name typed as a hand string beside the generated constant.** Signature 11 covers physical names; this is the same rule for a model's enum values and for the event or message names an enum member spells. A literal in non-generated code that EQUALS a member of a generated enum (or the name of a declared event) is a second spelling, and it is the commonest drift an estate that already uses the constants carries: the file imports `CouncilStatusEnum` and uses `CouncilStatusEnum.enum.complete` on one line, then writes `status: "error"` on the next. The two spellings agree today and nothing ties them together. **Hunt VALUE-first, from the declaration outward:** list every enum's members from the metadata (`@values` on each `field.enum`, plus the member names of any enum-backed event or message type), then grep the non-generated tree, config and fixtures included, for each member as a quoted literal. Distinctive members (`"edited_approved"`, `"awaiting_review"`) score on the hit; generic ones (`"error"`, `"done"`, `"active"`) only by reading the site, because the same word may belong to something the metadata does not own. **A file that already imports the enum's generated constant is the strongest tell**: its author knew the constant existed, so a bare literal of another member of that enum in the same file is almost never intentional. Count those first. + + **Apply signature 11's discriminator, then say which side of it the site is on.** Rename the member in metadata and regenerate. Where the site is RESOLVED, the regeneration breaks it before anything runs and it is not a finding: a value narrowed by the generated union (`export type = "a" | "b"` on TypeScript, a `Literal[...]` annotation on Python, a generated `enum` type on Java, Kotlin and C#) at a position the compiler or type checker actually checks. Where the site is COMPARED, it survives the regeneration and fails later, on data: a literal in a string-typed position (a `Record`, a `string` parameter, a loosely typed object spread into a `.set(...)`, a `dict`), in raw SQL (`WHERE status = 'error'`), in a JSON payload or fixture, as a broker topic or an SSE / websocket event name, in a `switch` over a `string` that the type checker cannot exhaust, or in a comparison against a column read back as `string`. **Verify the position before scoring it, never the shape of the literal:** the TypeScript case that earned the exemption (332 narrowed literals, 0 findings) and the case that earned this signature (`status: "error"` beside `CouncilStatusEnum.enum.complete`) look identical in a grep, and only the type at the site separates them. If you cannot tell which side a site is on, read the declared type of the target; report it as unverified rather than skipping it. + + **Do not flag:** a literal narrowed by the generated union or enum type at a checked position; a value the metadata does not own (an application-level state machine's label that merely coincides with a member, a page route, copy); a literal inside a generated file; and a test asserting the wire value of a member on purpose (a test that pins `"complete"` is a contract test and says so). A literal inside a `source.rdb` `@sql` body is metadata and is exempt, as in signature 11. + + **Remedy:** reference the generated constant, which is the one rule for every port: the Zod enum's member (for example `CouncilStatusEnum.enum.complete`) or the generated union type on TypeScript, the generated `Literal` alias or enum on Python, the generated enum constant on Java, Kotlin and C#. In raw SQL the value moves to a bound parameter fed from the constant (never a literal inside the SQL text), and the table and column in that statement are signature 11's names artifact. Report the site count as `/ sites` exactly as signature 11 does, and carry a deferral forward with its count. This signature needs no names artifact; it needs the generated enum type. If the project generates none, the un-wired `entity` generator is the first finding. + diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/java.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/java.md index 7a20c3849..1b34fd085 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/java.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/java.md @@ -66,6 +66,8 @@ plugin `` — that is the source of truth for which generators ru | `// keep in sync with` / `// mirrors the` | second-source-of-truth comment — always a finding | | `ObjectMapper.readValue(` outside a `*Parser.java` file | check if a responding `template.prompt` (`@responseRef`) exists — output-parser codegen ships in Java | | `LIMIT ?` / `OFFSET ?` assembled by hand | generated CRUD routes handle pagination; OMDB `getObjects` accepts `QueryOptions` | +| a member of a generated enum typed as a hand string (`"ERROR"` in JDBC SQL, a JSON body, a topic or event name, a `switch` over a `String`) beside the generated enum constant | **signature 13.** Value-first from each enum's `@values`. A value typed as the generated enum is resolved and exempt; a `String` position, SQL text or a payload is compared and scores | +| a status-like column written with a JDBC `UPDATE ... SET status`, a repository `save` of an entity whose status was set freely, or a Spring Data query with no `AND status = :from` | **axis G3.** Guard it through OMDB, the generated repository or a JPA modifying query over entity attributes, with the generated enum constant, and check the updated-row count. Never raw SQL | | a table/column string in a repository implementation — JDBC SQL, jOOQ `table("authors")`, a Spring Data query | second spelling of a declared physical name — reference `Names` (`AuthorNames.SOURCE_PRIMARY_TABLE` / `_COLUMN`; opt-in `SpringNamesGenerator`): an un-wired generator is the finding first | --- @@ -91,8 +93,20 @@ row is a **finding**: point the `pom.xml` entry back at the stock class and remo (`mvn metaobjects:eject -Dundo=true` removes every identical copy and prints the `pom.xml` edit). `DIFFERS` with `0 of your own` rules out added lines, not removed ones: confirm from `git log -p` on the copy that nothing changed after the eject, else report it as **possibly -customized: check its history**. A copy with lines of its own is a customization; check -only how far behind it is. Count lines for the Phase 0 total: lines in copies proven +customized: check its history**. + +**`DIFFERS` is printed for BOTH a deliberate customization and a copy gone stale by version**, +so the listing cannot tell them apart, and a copy with lines of its own is NOT thereby a +customization: `M of your own` counts every line absent from today's reference, so after the +reference moved on, a line it removed or rewrote reads as "yours" and is an older upstream +line. Run the history check on any `DIFFERS` row, `M` above 0 included: lines the copy's later +commits added are the project's own; lines already in the eject commit are upstream's and +count as behind. Report two figures, ` lines behind the reference` and ` lines of the +project's own (confirmed from history)`. A copy whose own lines history does not confirm is +**possibly customized or stale: check its history**; one that is stale by version is a finding +(`owned-generator-stale`): re-eject at the current version or return to the stock entry. + +Count lines for the Phase 0 total: lines in copies proven unmodified, over all lines under `codegen/` that eject wrote or the project wrote. Java also has **both** authoring paths, so a project needing a shape the diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/kotlin.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/kotlin.md index 58903ecdc..67d3bc0dc 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/kotlin.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/kotlin.md @@ -71,6 +71,8 @@ The active generator list is declared in `pom.xml` under the plugin `` in the predicate, or an `exec("UPDATE ... SET status")` | **axis G3.** Guard it through Exposed: `T.update({ (T.id eq id) and (T.status eq ) })` with the generated constant, and check the returned count. Never raw SQL | | a table/column string in `exec("…")` raw SQL or a Flyway script | reference `Names` (`KotlinNamesGenerator`, opt-in); `AuthorTable.name` inside the Exposed DSL is the typed handle — correct, not a finding | --- @@ -95,8 +97,19 @@ own]`, and a generator copied with companion classes adds `; differing: ` back at the stock class and remove the copy (`mvn metaobjects:eject -Dundo=true` removes every identical copy and prints the `pom.xml` edit). `DIFFERS` with `0 of your own` rules out added lines, not removed ones: confirm from `git log -p` on the copy that nothing -changed after the eject, else report it as **possibly customized: check its history**. A -copy with lines of its own is a customization; check only how far behind it is. Count lines +changed after the eject, else report it as **possibly customized: check its history**. + +**`DIFFERS` is printed for BOTH a deliberate customization and a copy gone stale by version**, +so the listing cannot tell them apart, and a copy with lines of its own is NOT thereby a +customization: `M of your own` counts every line absent from today's reference, so after the +reference moved on, a line it removed or rewrote reads as "yours" and is an older upstream +line. Run the history check on any `DIFFERS` row, `M` above 0 included: lines the copy's later +commits added are the project's own; lines already in the eject commit are upstream's and +count as behind. Report two figures, ` lines behind the reference` and ` lines of the +project's own (confirmed from history)`. A copy whose own lines history does not confirm is +**possibly customized or stale: check its history**; one that is stale by version is a finding +(`owned-generator-stale`): re-eject at the current version or return to the stock entry. +Count lines for the Phase 0 total: lines in copies proven unmodified, over all lines under `codegen/` that eject wrote or the project wrote. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md index 4f5678520..af230bbb5 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md @@ -59,4 +59,4 @@ format-built argument at the project's own LLM request call sites. `voRequest` / `voResponse` jsonb columns must be authored `field.object` — the loader must not mutate the tree; vendor SDK client + pricing are BYO (ADR-0024). - No `meta verify --templates` gate; no declared `@maxChars` / `@maxTokens` budget. -- **Template-text duplication.** Hash every external template file (`md5sum` the template dir) and diff within groups sharing a `payloadRef`. A byte-identical cluster, or N≥3 templates sharing an identical skeleton with only slot lines differing, is ONE prompt maintained N times — a wording fix must be applied N times and WILL be missed. Recommend a shared `{{> group/partial }}` include (the render engine inlines partials recursively) or one template + payload-carried variant fields. Do NOT flag: the per-node `payloadRef` / `@format` repetition across `template.prompt` nodes (loader-forced, not a copy); templates meant to diverge independently — require N≥3 and structural identity before flagging. +- **Template-text duplication.** Hash every external template file (`md5sum` the template dir; skip include-only files, below) and diff within groups sharing a `payloadRef`. A byte-identical cluster, or N≥3 templates sharing an identical skeleton with only slot lines differing, is ONE prompt maintained N times — a wording fix must be applied N times and WILL be missed. Recommend a shared `{{> group/partial }}` include (the render engine inlines partials recursively) or one template + payload-carried variant fields. Do NOT flag: the per-node `payloadRef` / `@format` repetition across `template.prompt` nodes (loader-forced, not a copy); templates meant to diverge independently — require N≥3 and structural identity before flagging; **an include-only template**. A template whose body is nothing but partial includes (every non-blank line is a `{{> group/partial }}` and there is no prose of its own) is already the fix this section recommends: the wording lives once in the partial, and the stub is the per-prompt entry point the loader requires. Several such stubs being byte-identical, or sharing the same include list, is the recommended shape, not a cluster. Drop include-only files from the hash groups BEFORE comparing, and apply the cluster test to what remains. A template that mixes includes with a skeleton of its own prose still counts, and its prose is what is compared. When every member of a cluster is include-only, report it as the gold standard, the way a repository that imports `Names` is called out as good. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md index 4a5e29277..58c7f3feb 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md @@ -13,6 +13,7 @@ The full hunt for each axis of Phase 1b (Partial / Deep path). Each axis runs in - F2. Over-generation — unnecessary generated files - G. Runtime-contract anti-patterns - G2. `own*()` accessor discipline +- G3. Guarded status transitions - H. Authoring correctness / ADR conformance - H2. Wrong native type - I. Vocabulary hygiene / modernization (advisory) @@ -200,6 +201,37 @@ The full hunt for each axis of Phase 1b (Partial / Deep path). Each axis runs in deliberately-own attribute `@dbColumnType` (a physical column-type override, never inherited). Any own read that carries a comment naming one of these cases is fine; an uncommented own read of an effective property is the defect. +- [ ] **G3. Guarded status transitions (a correctness check, `unguarded-status-transition`).** + For each model whose enum column is status-like (a `field.enum`, or a `field.string` named + `status` / `state` / `phase` / `stage`, whose members read as a lifecycle: pending, running, + complete, error), list **every write to that column** in non-generated code: an ORM + `update(...).set({ status })`, a `UPDATE ... SET status`, an entity property assignment that is + then saved, an upsert. For each write ask one question: **is it conditional on the current + value?** A guarded write names the state it moves from (`WHERE id = ? AND status = `, a + compare-and-set whose affected-row count is checked, an optimistic-lock version, a database + constraint that rejects the move). An unguarded write (`SET status = 'complete' WHERE id = ?`) + lets two workers, a retry or a late callback move a row backwards or out of a terminal state + (`complete` overwritten by `error`), and nothing reports it. An insert's initial value is not a + transition. A write that is the only possible transition and has one writer is lower + confidence, not exempt: say why it is safe to leave. Score `guarded writes / all writes` per + status column and list the unguarded ones; also state whether the allowed `from` states are + written down anywhere, because a rule held only in scattered `WHERE` clauses is a second + source of truth. + **The remedy never uses raw SQL.** Raw SQL spells the table, the column and the enum member by + hand, and those names drift. At a minimum the guarded write references the generated constants + for the table, the field and the enum member; ideally it goes through the port's ORM, which + carries them as typed handles, with the constraints the model already generates (the enum's + `CHECK`) behind it. Per port the guard is a predicate on the current value inside the ORM + update: Drizzle / Kysely `.where(and(eq(t.id, id), eq(t.status, )))` on TypeScript; + SQLAlchemy `update(T).where(T.id == id, T.status == )` or the `ObjectManager` on Python; + OMDB or the generated repository, or a JPA modifying query over entity attributes, on Java; + Exposed `T.update({ (T.id eq id) and (T.status eq ) })` on Kotlin; EF Core + `ExecuteUpdateAsync` after a `Where(x => x.Status == )` on C#. Each checks the + affected-row count. `` is the generated enum constant, never a literal (signature 13 in + `drift-signatures.md`). **If the allowed transitions need a declared form, that is a question + for the human, never a new attribute** (ADR-0023): the sketch proposes the ORM guard and + records the transition table as an open decision. Pillar `runtime`, surface `repository`. + - [ ] **H. Authoring-correctness / ADR-conformance (deep).** Invented/unregistered `@`-attrs or post-bootstrap registration (ADR-0023 — custom attrs belong in a registered provider or `attr.properties`); retired source-v2 forms (`source.dbTable` / `@name` / diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/SKILL.md index 5225faad9..a50a9e32c 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/SKILL.md @@ -25,7 +25,7 @@ Actual cutovers run through the existing skills mapped per finding tier (§ Brid | `references/calibration.md` | Before raising any finding: it lists the port gaps and non-defects that are never the adopter's fault. | | `references/surface-review-axes.md` | Working any axis (A–I) of Phase 1b. | | `references/capability-checklist.md` | On every axis: the exhaustive list of what the metamodel can express, one hunt per capability. | -| `references/drift-signatures.md` | Hunting hand-written second sources of truth (validators, mappers, comments, views, JSON bags). | +| `references/drift-signatures.md` | Hunting hand-written second sources of truth (validators, mappers, comments, views, JSON bags, physical-name literals, enum-member and event-name literals). | | `references/prompt-anti-patterns.md` | Axis D: the project builds text sent to an LLM or parses a reply. | | `references/vocabulary-hygiene.md` | Axis I: retired or deprecated authoring forms, custom vocabulary, vocabulary opportunities. | | `references/requirements.md` | Axis E2: the project declares `requirement.*` nodes. | @@ -65,7 +65,13 @@ Actual cutovers run through the existing skills mapped per finding tier (§ Brid not removed or reordered ones: confirm those from the file's history (`git log -p -- codegen/generators/.ts` shows no edit after the eject) first, and report a copy you cannot confirm as "possibly customized: check its history", not as a - copy to drop. For `entity`, + copy to drop. **A copy with lines of its own is not proof of a customization either.** + `meta eject --list` (and each port's listing) prints `DIFFERS` for BOTH a deliberate + customization and a copy gone stale by version, and its `M of your own` count includes + every line the reference has since removed or rewritten, which are older upstream lines. + Run the history check on every `DIFFERS` row, `M` above 0 included, and report the two + figures separately: lines behind the reference, and lines of the project's own that the + history confirms. A copy stale by version is a finding (`owned-generator-stale`). For `entity`, `routes` and `routes-hono` that holds only when every `codegen/runtime/` file the copy reaches is also marked identical: an edited adapter is a customization, and such a copy is not a finding. Those runtime rows appear only with `--format text` (or as the @@ -134,7 +140,7 @@ Axes (the full hunt for each is in `references/surface-review-axes.md` — open - **C.** Drift hotspot — validators, mappers, runtime models · **D.** Prompt pillar - **E.** Owned generators and scaffold-and-own · **E2.** Capability ledger (`requirement.*`, opt-in) - **F.** Drift-gate adoption — does it block? · **F2.** Over-generation and generated-but-bypassed (codegen waste) -- **G.** Runtime-contract anti-patterns · **G2.** `own*()` accessor discipline (a correctness defect, not advisory) +- **G.** Runtime-contract anti-patterns · **G2.** `own*()` accessor discipline (a correctness defect, not advisory) · **G3.** Guarded status transitions (every write to a status-like enum column is conditional on the current value; the fix goes through the port's ORM with generated constants, never raw SQL) - **H.** Authoring correctness / ADR conformance · **H2.** Wrong native type (`field.string` + `@dbColumnType: uuid`) - **I.** Vocabulary hygiene / modernization (advisory, never a failing finding) @@ -173,7 +179,9 @@ hidden candidate. **Stub trap:** demo-data routes have nothing to replace — cl the Phase 0 owned-generators check: recommend the package import. Importing any other generator from that subpath is correct and is not a finding. - Audit owned generators: (a) regenerate clean? (b) drifted from reference templates — - intentional (good) vs stale/accidental (missed upstream fix)? (c) hand-rolling a walk + intentional (good) vs stale/accidental (missed upstream fix)? Decide it from the copy's + history, not from the listing's `own` count: `DIFFERS` reads the same for both, and lines + the reference has since removed count as "own" (Phase 0, owned-generators check). (c) hand-rolling a walk that a declarative `scope` + `outputPattern` could replace? (d) bespoke shape better as a `templateGenerator` than a forked generator? - **Authoring ladder:** built-in fits → import it as shipped · close → **eject it and @@ -274,7 +282,7 @@ Each roadmap item: LOC retired, prerequisite, **parity-gate before deleting hand | Field | Content | |---|---| -| `id` | stable kebab id (e.g. `handwritten-crud-route`, `manual-zod-validator`) | +| `id` | stable kebab id (e.g. `handwritten-crud-route`, `manual-zod-validator`, `enum-literal-spelling`, `unguarded-status-transition`, `owned-generator-stale`) | | `title` | "you hand-wrote X that metadata can generate / model" | | `pillar` | `codegen` / `runtime` / `drift` / `prompt` | | `surface` | `entity` / `route` / `validator` / `repository` / `dto` / `hooks` / `prompt` / `migration` | diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/calibration.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/calibration.md index 4271e57ee..c605cd7a0 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/calibration.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/calibration.md @@ -10,6 +10,9 @@ - Planned, not shipped - Cross-port version-number skew - Stale upstream prose +- Include-only templates +- Owned generator copies with lines of their own +- Enum-member literals at a checked position - **Filter-operator route codegen — the baseline grammar ships in all five ports.** The `?filter[field][op]=value` grammar (all 9 operators `eq/ne/gt/gte/lt/lte/in/like/isNull`, @@ -32,3 +35,6 @@ - **Planned, not shipped** — `api.*` / `operation.*` / `binding.*` (FR-024) and MCP exposure of declared prompts/tools are not yet in the registry; their absence is not an adopter defect. - **Cross-port version-NUMBER skew is by design** — TS/C#/Python `0.x` vs Java/Kotlin `7.x` Maven is correct; never flag the *number lines* differing. But that is exactly why you can't eyeball cross-language drift: compare **`metamodelVersion`** (Phase 0 cross-language consistency item), not the package numbers. A `metamodelVersion` MISMATCH across ports *is* a finding; so is a port lagging its ecosystem's latest release. Also flag *intra-port* drift (mixed versions within one port, or a runtime package in `devDependencies`). - **Stale upstream prose** — "hand-write the Spring controller" (Java/Kotlin) is out of date; trust `meta gen --list`, not stale prose. +- **Include-only templates are not duplicates.** A prompt template whose body is only `{{> partial }}` includes is already the shared-partial fix the template-duplication check recommends. Identical stubs are the shape to want, never a byte-identical cluster to report (`references/prompt-anti-patterns.md`, template-text duplication). +- **An owned generator copy with lines of its own is not thereby customized.** The listing prints `DIFFERS` for a deliberate customization and for a copy stale by version, and counts lines the reference later removed as "of your own". Check the copy's history before calling either (Phase 0, owned-generators check). +- **An enum-member literal at a position the type checker verifies is not drift.** A value narrowed by the generated union or enum type breaks the build when the member is renamed. Signature 13 scores only a literal in a position nothing checks: a string-typed parameter, raw SQL, a JSON payload, an event name (`references/drift-signatures.md`). diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md index 71672d2a8..5741cf9de 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -58,7 +58,11 @@ classify it (using the classification scheme in `SKILL.md`) and route the cutove - **`field.date` / `field.time` / `field.timestamp`** (`@autoSet`) — hunt hand-stamped `createdAt` / `updatedAt` assignments and ad-hoc temporal parsing; `@autoSet` stamps them. - **`field.enum`** (`@values`) — hunt hand-written TS unions, language `enum`s, or DB - `CHECK ... IN (...)` lists that restate a modeled enum's members. + `CHECK ... IN (...)` lists that restate a modeled enum's members. Two more hunts on the same + capability: a member or event name typed as a hand string beside the generated constant + (`drift-signatures.md`, signature 13), and a status-like enum column written without a + condition on its current value (`surface-review-axes.md`, G3: every write conditional, through + the port's ORM with generated constants, never raw SQL). - **`field.uuid`** — hunt UUIDs typed as bare strings / hand-validated. - **`field.object`** (`@objectRef`, `@storage`) — hunt hand-flattened owned columns or hand-rolled jsonb (de)serialization the `@storage` mode (`flattened`/`jsonb`/`subdocument`) diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md index 5148b26db..492c015b6 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md @@ -13,6 +13,7 @@ - 10. N declarations of one field across objects - 11. A metadata-derived value spelled a second time by hand - 12. An open JSON bag whose readers agree on a shape +- 13. An enum member or event name typed by hand beside the generated constant Per finding: `file:line` → what → generated-equivalent exists? → recommendation. @@ -55,3 +56,12 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend **Do NOT flag:** a bag whose readers genuinely pass it through (logged, echoed, stored, forwarded to a third party) without reading a key; a third-party or LLM raw response whose shape is not the adopter's to declare; an **array of scalars**, which is `isArray: true` on the base subtype and never an object; a bag the metadata's own comment justifies as open with a reason. **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — nested map values are validated on every write path on every port as of 1.0.1 ([#362](https://github.com/metaobjectsdev/metaobjects/issues/362) — before that, Java skipped the cascade on TPH write paths and C# skipped it everywhere, silently accepting invalid values rather than erroring). **Check the adopter's pinned version before recommending this rung to a Java-TPH or C# consumer: on 1.0.0 the hole is open and they need their own boundary check.** Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor. + +13. **An enum member or event name typed as a hand string beside the generated constant.** Signature 11 covers physical names; this is the same rule for a model's enum values and for the event or message names an enum member spells. A literal in non-generated code that EQUALS a member of a generated enum (or the name of a declared event) is a second spelling, and it is the commonest drift an estate that already uses the constants carries: the file imports `CouncilStatusEnum` and uses `CouncilStatusEnum.enum.complete` on one line, then writes `status: "error"` on the next. The two spellings agree today and nothing ties them together. **Hunt VALUE-first, from the declaration outward:** list every enum's members from the metadata (`@values` on each `field.enum`, plus the member names of any enum-backed event or message type), then grep the non-generated tree, config and fixtures included, for each member as a quoted literal. Distinctive members (`"edited_approved"`, `"awaiting_review"`) score on the hit; generic ones (`"error"`, `"done"`, `"active"`) only by reading the site, because the same word may belong to something the metadata does not own. **A file that already imports the enum's generated constant is the strongest tell**: its author knew the constant existed, so a bare literal of another member of that enum in the same file is almost never intentional. Count those first. + + **Apply signature 11's discriminator, then say which side of it the site is on.** Rename the member in metadata and regenerate. Where the site is RESOLVED, the regeneration breaks it before anything runs and it is not a finding: a value narrowed by the generated union (`export type = "a" | "b"` on TypeScript, a `Literal[...]` annotation on Python, a generated `enum` type on Java, Kotlin and C#) at a position the compiler or type checker actually checks. Where the site is COMPARED, it survives the regeneration and fails later, on data: a literal in a string-typed position (a `Record`, a `string` parameter, a loosely typed object spread into a `.set(...)`, a `dict`), in raw SQL (`WHERE status = 'error'`), in a JSON payload or fixture, as a broker topic or an SSE / websocket event name, in a `switch` over a `string` that the type checker cannot exhaust, or in a comparison against a column read back as `string`. **Verify the position before scoring it, never the shape of the literal:** the TypeScript case that earned the exemption (332 narrowed literals, 0 findings) and the case that earned this signature (`status: "error"` beside `CouncilStatusEnum.enum.complete`) look identical in a grep, and only the type at the site separates them. If you cannot tell which side a site is on, read the declared type of the target; report it as unverified rather than skipping it. + + **Do not flag:** a literal narrowed by the generated union or enum type at a checked position; a value the metadata does not own (an application-level state machine's label that merely coincides with a member, a page route, copy); a literal inside a generated file; and a test asserting the wire value of a member on purpose (a test that pins `"complete"` is a contract test and says so). A literal inside a `source.rdb` `@sql` body is metadata and is exempt, as in signature 11. + + **Remedy:** reference the generated constant, which is the one rule for every port: the Zod enum's member (for example `CouncilStatusEnum.enum.complete`) or the generated union type on TypeScript, the generated `Literal` alias or enum on Python, the generated enum constant on Java, Kotlin and C#. In raw SQL the value moves to a bound parameter fed from the constant (never a literal inside the SQL text), and the table and column in that statement are signature 11's names artifact. Report the site count as `/ sites` exactly as signature 11 does, and carry a deferral forward with its count. This signature needs no names artifact; it needs the generated enum type. If the project generates none, the un-wired `entity` generator is the first finding. + diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/java.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/java.md index 7a20c3849..1b34fd085 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/java.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/java.md @@ -66,6 +66,8 @@ plugin `` — that is the source of truth for which generators ru | `// keep in sync with` / `// mirrors the` | second-source-of-truth comment — always a finding | | `ObjectMapper.readValue(` outside a `*Parser.java` file | check if a responding `template.prompt` (`@responseRef`) exists — output-parser codegen ships in Java | | `LIMIT ?` / `OFFSET ?` assembled by hand | generated CRUD routes handle pagination; OMDB `getObjects` accepts `QueryOptions` | +| a member of a generated enum typed as a hand string (`"ERROR"` in JDBC SQL, a JSON body, a topic or event name, a `switch` over a `String`) beside the generated enum constant | **signature 13.** Value-first from each enum's `@values`. A value typed as the generated enum is resolved and exempt; a `String` position, SQL text or a payload is compared and scores | +| a status-like column written with a JDBC `UPDATE ... SET status`, a repository `save` of an entity whose status was set freely, or a Spring Data query with no `AND status = :from` | **axis G3.** Guard it through OMDB, the generated repository or a JPA modifying query over entity attributes, with the generated enum constant, and check the updated-row count. Never raw SQL | | a table/column string in a repository implementation — JDBC SQL, jOOQ `table("authors")`, a Spring Data query | second spelling of a declared physical name — reference `Names` (`AuthorNames.SOURCE_PRIMARY_TABLE` / `_COLUMN`; opt-in `SpringNamesGenerator`): an un-wired generator is the finding first | --- @@ -91,8 +93,20 @@ row is a **finding**: point the `pom.xml` entry back at the stock class and remo (`mvn metaobjects:eject -Dundo=true` removes every identical copy and prints the `pom.xml` edit). `DIFFERS` with `0 of your own` rules out added lines, not removed ones: confirm from `git log -p` on the copy that nothing changed after the eject, else report it as **possibly -customized: check its history**. A copy with lines of its own is a customization; check -only how far behind it is. Count lines for the Phase 0 total: lines in copies proven +customized: check its history**. + +**`DIFFERS` is printed for BOTH a deliberate customization and a copy gone stale by version**, +so the listing cannot tell them apart, and a copy with lines of its own is NOT thereby a +customization: `M of your own` counts every line absent from today's reference, so after the +reference moved on, a line it removed or rewrote reads as "yours" and is an older upstream +line. Run the history check on any `DIFFERS` row, `M` above 0 included: lines the copy's later +commits added are the project's own; lines already in the eject commit are upstream's and +count as behind. Report two figures, ` lines behind the reference` and ` lines of the +project's own (confirmed from history)`. A copy whose own lines history does not confirm is +**possibly customized or stale: check its history**; one that is stale by version is a finding +(`owned-generator-stale`): re-eject at the current version or return to the stock entry. + +Count lines for the Phase 0 total: lines in copies proven unmodified, over all lines under `codegen/` that eject wrote or the project wrote. Java also has **both** authoring paths, so a project needing a shape the diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md index 4f5678520..af230bbb5 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md @@ -59,4 +59,4 @@ format-built argument at the project's own LLM request call sites. `voRequest` / `voResponse` jsonb columns must be authored `field.object` — the loader must not mutate the tree; vendor SDK client + pricing are BYO (ADR-0024). - No `meta verify --templates` gate; no declared `@maxChars` / `@maxTokens` budget. -- **Template-text duplication.** Hash every external template file (`md5sum` the template dir) and diff within groups sharing a `payloadRef`. A byte-identical cluster, or N≥3 templates sharing an identical skeleton with only slot lines differing, is ONE prompt maintained N times — a wording fix must be applied N times and WILL be missed. Recommend a shared `{{> group/partial }}` include (the render engine inlines partials recursively) or one template + payload-carried variant fields. Do NOT flag: the per-node `payloadRef` / `@format` repetition across `template.prompt` nodes (loader-forced, not a copy); templates meant to diverge independently — require N≥3 and structural identity before flagging. +- **Template-text duplication.** Hash every external template file (`md5sum` the template dir; skip include-only files, below) and diff within groups sharing a `payloadRef`. A byte-identical cluster, or N≥3 templates sharing an identical skeleton with only slot lines differing, is ONE prompt maintained N times — a wording fix must be applied N times and WILL be missed. Recommend a shared `{{> group/partial }}` include (the render engine inlines partials recursively) or one template + payload-carried variant fields. Do NOT flag: the per-node `payloadRef` / `@format` repetition across `template.prompt` nodes (loader-forced, not a copy); templates meant to diverge independently — require N≥3 and structural identity before flagging; **an include-only template**. A template whose body is nothing but partial includes (every non-blank line is a `{{> group/partial }}` and there is no prose of its own) is already the fix this section recommends: the wording lives once in the partial, and the stub is the per-prompt entry point the loader requires. Several such stubs being byte-identical, or sharing the same include list, is the recommended shape, not a cluster. Drop include-only files from the hash groups BEFORE comparing, and apply the cluster test to what remains. A template that mixes includes with a skeleton of its own prose still counts, and its prose is what is compared. When every member of a cluster is include-only, report it as the gold standard, the way a repository that imports `Names` is called out as good. diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md index 4a5e29277..58c7f3feb 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md @@ -13,6 +13,7 @@ The full hunt for each axis of Phase 1b (Partial / Deep path). Each axis runs in - F2. Over-generation — unnecessary generated files - G. Runtime-contract anti-patterns - G2. `own*()` accessor discipline +- G3. Guarded status transitions - H. Authoring correctness / ADR conformance - H2. Wrong native type - I. Vocabulary hygiene / modernization (advisory) @@ -200,6 +201,37 @@ The full hunt for each axis of Phase 1b (Partial / Deep path). Each axis runs in deliberately-own attribute `@dbColumnType` (a physical column-type override, never inherited). Any own read that carries a comment naming one of these cases is fine; an uncommented own read of an effective property is the defect. +- [ ] **G3. Guarded status transitions (a correctness check, `unguarded-status-transition`).** + For each model whose enum column is status-like (a `field.enum`, or a `field.string` named + `status` / `state` / `phase` / `stage`, whose members read as a lifecycle: pending, running, + complete, error), list **every write to that column** in non-generated code: an ORM + `update(...).set({ status })`, a `UPDATE ... SET status`, an entity property assignment that is + then saved, an upsert. For each write ask one question: **is it conditional on the current + value?** A guarded write names the state it moves from (`WHERE id = ? AND status = `, a + compare-and-set whose affected-row count is checked, an optimistic-lock version, a database + constraint that rejects the move). An unguarded write (`SET status = 'complete' WHERE id = ?`) + lets two workers, a retry or a late callback move a row backwards or out of a terminal state + (`complete` overwritten by `error`), and nothing reports it. An insert's initial value is not a + transition. A write that is the only possible transition and has one writer is lower + confidence, not exempt: say why it is safe to leave. Score `guarded writes / all writes` per + status column and list the unguarded ones; also state whether the allowed `from` states are + written down anywhere, because a rule held only in scattered `WHERE` clauses is a second + source of truth. + **The remedy never uses raw SQL.** Raw SQL spells the table, the column and the enum member by + hand, and those names drift. At a minimum the guarded write references the generated constants + for the table, the field and the enum member; ideally it goes through the port's ORM, which + carries them as typed handles, with the constraints the model already generates (the enum's + `CHECK`) behind it. Per port the guard is a predicate on the current value inside the ORM + update: Drizzle / Kysely `.where(and(eq(t.id, id), eq(t.status, )))` on TypeScript; + SQLAlchemy `update(T).where(T.id == id, T.status == )` or the `ObjectManager` on Python; + OMDB or the generated repository, or a JPA modifying query over entity attributes, on Java; + Exposed `T.update({ (T.id eq id) and (T.status eq ) })` on Kotlin; EF Core + `ExecuteUpdateAsync` after a `Where(x => x.Status == )` on C#. Each checks the + affected-row count. `` is the generated enum constant, never a literal (signature 13 in + `drift-signatures.md`). **If the allowed transitions need a declared form, that is a question + for the human, never a new attribute** (ADR-0023): the sketch proposes the ORM guard and + records the transition table as an open decision. Pillar `runtime`, surface `repository`. + - [ ] **H. Authoring-correctness / ADR-conformance (deep).** Invented/unregistered `@`-attrs or post-bootstrap registration (ADR-0023 — custom attrs belong in a registered provider or `attr.properties`); retired source-v2 forms (`source.dbTable` / `@name` / diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/SKILL.md index 5225faad9..a50a9e32c 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/SKILL.md @@ -25,7 +25,7 @@ Actual cutovers run through the existing skills mapped per finding tier (§ Brid | `references/calibration.md` | Before raising any finding: it lists the port gaps and non-defects that are never the adopter's fault. | | `references/surface-review-axes.md` | Working any axis (A–I) of Phase 1b. | | `references/capability-checklist.md` | On every axis: the exhaustive list of what the metamodel can express, one hunt per capability. | -| `references/drift-signatures.md` | Hunting hand-written second sources of truth (validators, mappers, comments, views, JSON bags). | +| `references/drift-signatures.md` | Hunting hand-written second sources of truth (validators, mappers, comments, views, JSON bags, physical-name literals, enum-member and event-name literals). | | `references/prompt-anti-patterns.md` | Axis D: the project builds text sent to an LLM or parses a reply. | | `references/vocabulary-hygiene.md` | Axis I: retired or deprecated authoring forms, custom vocabulary, vocabulary opportunities. | | `references/requirements.md` | Axis E2: the project declares `requirement.*` nodes. | @@ -65,7 +65,13 @@ Actual cutovers run through the existing skills mapped per finding tier (§ Brid not removed or reordered ones: confirm those from the file's history (`git log -p -- codegen/generators/.ts` shows no edit after the eject) first, and report a copy you cannot confirm as "possibly customized: check its history", not as a - copy to drop. For `entity`, + copy to drop. **A copy with lines of its own is not proof of a customization either.** + `meta eject --list` (and each port's listing) prints `DIFFERS` for BOTH a deliberate + customization and a copy gone stale by version, and its `M of your own` count includes + every line the reference has since removed or rewritten, which are older upstream lines. + Run the history check on every `DIFFERS` row, `M` above 0 included, and report the two + figures separately: lines behind the reference, and lines of the project's own that the + history confirms. A copy stale by version is a finding (`owned-generator-stale`). For `entity`, `routes` and `routes-hono` that holds only when every `codegen/runtime/` file the copy reaches is also marked identical: an edited adapter is a customization, and such a copy is not a finding. Those runtime rows appear only with `--format text` (or as the @@ -134,7 +140,7 @@ Axes (the full hunt for each is in `references/surface-review-axes.md` — open - **C.** Drift hotspot — validators, mappers, runtime models · **D.** Prompt pillar - **E.** Owned generators and scaffold-and-own · **E2.** Capability ledger (`requirement.*`, opt-in) - **F.** Drift-gate adoption — does it block? · **F2.** Over-generation and generated-but-bypassed (codegen waste) -- **G.** Runtime-contract anti-patterns · **G2.** `own*()` accessor discipline (a correctness defect, not advisory) +- **G.** Runtime-contract anti-patterns · **G2.** `own*()` accessor discipline (a correctness defect, not advisory) · **G3.** Guarded status transitions (every write to a status-like enum column is conditional on the current value; the fix goes through the port's ORM with generated constants, never raw SQL) - **H.** Authoring correctness / ADR conformance · **H2.** Wrong native type (`field.string` + `@dbColumnType: uuid`) - **I.** Vocabulary hygiene / modernization (advisory, never a failing finding) @@ -173,7 +179,9 @@ hidden candidate. **Stub trap:** demo-data routes have nothing to replace — cl the Phase 0 owned-generators check: recommend the package import. Importing any other generator from that subpath is correct and is not a finding. - Audit owned generators: (a) regenerate clean? (b) drifted from reference templates — - intentional (good) vs stale/accidental (missed upstream fix)? (c) hand-rolling a walk + intentional (good) vs stale/accidental (missed upstream fix)? Decide it from the copy's + history, not from the listing's `own` count: `DIFFERS` reads the same for both, and lines + the reference has since removed count as "own" (Phase 0, owned-generators check). (c) hand-rolling a walk that a declarative `scope` + `outputPattern` could replace? (d) bespoke shape better as a `templateGenerator` than a forked generator? - **Authoring ladder:** built-in fits → import it as shipped · close → **eject it and @@ -274,7 +282,7 @@ Each roadmap item: LOC retired, prerequisite, **parity-gate before deleting hand | Field | Content | |---|---| -| `id` | stable kebab id (e.g. `handwritten-crud-route`, `manual-zod-validator`) | +| `id` | stable kebab id (e.g. `handwritten-crud-route`, `manual-zod-validator`, `enum-literal-spelling`, `unguarded-status-transition`, `owned-generator-stale`) | | `title` | "you hand-wrote X that metadata can generate / model" | | `pillar` | `codegen` / `runtime` / `drift` / `prompt` | | `surface` | `entity` / `route` / `validator` / `repository` / `dto` / `hooks` / `prompt` / `migration` | diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/calibration.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/calibration.md index 4271e57ee..c605cd7a0 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/calibration.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/calibration.md @@ -10,6 +10,9 @@ - Planned, not shipped - Cross-port version-number skew - Stale upstream prose +- Include-only templates +- Owned generator copies with lines of their own +- Enum-member literals at a checked position - **Filter-operator route codegen — the baseline grammar ships in all five ports.** The `?filter[field][op]=value` grammar (all 9 operators `eq/ne/gt/gte/lt/lte/in/like/isNull`, @@ -32,3 +35,6 @@ - **Planned, not shipped** — `api.*` / `operation.*` / `binding.*` (FR-024) and MCP exposure of declared prompts/tools are not yet in the registry; their absence is not an adopter defect. - **Cross-port version-NUMBER skew is by design** — TS/C#/Python `0.x` vs Java/Kotlin `7.x` Maven is correct; never flag the *number lines* differing. But that is exactly why you can't eyeball cross-language drift: compare **`metamodelVersion`** (Phase 0 cross-language consistency item), not the package numbers. A `metamodelVersion` MISMATCH across ports *is* a finding; so is a port lagging its ecosystem's latest release. Also flag *intra-port* drift (mixed versions within one port, or a runtime package in `devDependencies`). - **Stale upstream prose** — "hand-write the Spring controller" (Java/Kotlin) is out of date; trust `meta gen --list`, not stale prose. +- **Include-only templates are not duplicates.** A prompt template whose body is only `{{> partial }}` includes is already the shared-partial fix the template-duplication check recommends. Identical stubs are the shape to want, never a byte-identical cluster to report (`references/prompt-anti-patterns.md`, template-text duplication). +- **An owned generator copy with lines of its own is not thereby customized.** The listing prints `DIFFERS` for a deliberate customization and for a copy stale by version, and counts lines the reference later removed as "of your own". Check the copy's history before calling either (Phase 0, owned-generators check). +- **An enum-member literal at a position the type checker verifies is not drift.** A value narrowed by the generated union or enum type breaks the build when the member is renamed. Signature 13 scores only a literal in a position nothing checks: a string-typed parameter, raw SQL, a JSON payload, an event name (`references/drift-signatures.md`). diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md index 71672d2a8..5741cf9de 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -58,7 +58,11 @@ classify it (using the classification scheme in `SKILL.md`) and route the cutove - **`field.date` / `field.time` / `field.timestamp`** (`@autoSet`) — hunt hand-stamped `createdAt` / `updatedAt` assignments and ad-hoc temporal parsing; `@autoSet` stamps them. - **`field.enum`** (`@values`) — hunt hand-written TS unions, language `enum`s, or DB - `CHECK ... IN (...)` lists that restate a modeled enum's members. + `CHECK ... IN (...)` lists that restate a modeled enum's members. Two more hunts on the same + capability: a member or event name typed as a hand string beside the generated constant + (`drift-signatures.md`, signature 13), and a status-like enum column written without a + condition on its current value (`surface-review-axes.md`, G3: every write conditional, through + the port's ORM with generated constants, never raw SQL). - **`field.uuid`** — hunt UUIDs typed as bare strings / hand-validated. - **`field.object`** (`@objectRef`, `@storage`) — hunt hand-flattened owned columns or hand-rolled jsonb (de)serialization the `@storage` mode (`flattened`/`jsonb`/`subdocument`) diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md index 5148b26db..492c015b6 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md @@ -13,6 +13,7 @@ - 10. N declarations of one field across objects - 11. A metadata-derived value spelled a second time by hand - 12. An open JSON bag whose readers agree on a shape +- 13. An enum member or event name typed by hand beside the generated constant Per finding: `file:line` → what → generated-equivalent exists? → recommendation. @@ -55,3 +56,12 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend **Do NOT flag:** a bag whose readers genuinely pass it through (logged, echoed, stored, forwarded to a third party) without reading a key; a third-party or LLM raw response whose shape is not the adopter's to declare; an **array of scalars**, which is `isArray: true` on the base subtype and never an object; a bag the metadata's own comment justifies as open with a reason. **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — nested map values are validated on every write path on every port as of 1.0.1 ([#362](https://github.com/metaobjectsdev/metaobjects/issues/362) — before that, Java skipped the cascade on TPH write paths and C# skipped it everywhere, silently accepting invalid values rather than erroring). **Check the adopter's pinned version before recommending this rung to a Java-TPH or C# consumer: on 1.0.0 the hole is open and they need their own boundary check.** Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor. + +13. **An enum member or event name typed as a hand string beside the generated constant.** Signature 11 covers physical names; this is the same rule for a model's enum values and for the event or message names an enum member spells. A literal in non-generated code that EQUALS a member of a generated enum (or the name of a declared event) is a second spelling, and it is the commonest drift an estate that already uses the constants carries: the file imports `CouncilStatusEnum` and uses `CouncilStatusEnum.enum.complete` on one line, then writes `status: "error"` on the next. The two spellings agree today and nothing ties them together. **Hunt VALUE-first, from the declaration outward:** list every enum's members from the metadata (`@values` on each `field.enum`, plus the member names of any enum-backed event or message type), then grep the non-generated tree, config and fixtures included, for each member as a quoted literal. Distinctive members (`"edited_approved"`, `"awaiting_review"`) score on the hit; generic ones (`"error"`, `"done"`, `"active"`) only by reading the site, because the same word may belong to something the metadata does not own. **A file that already imports the enum's generated constant is the strongest tell**: its author knew the constant existed, so a bare literal of another member of that enum in the same file is almost never intentional. Count those first. + + **Apply signature 11's discriminator, then say which side of it the site is on.** Rename the member in metadata and regenerate. Where the site is RESOLVED, the regeneration breaks it before anything runs and it is not a finding: a value narrowed by the generated union (`export type = "a" | "b"` on TypeScript, a `Literal[...]` annotation on Python, a generated `enum` type on Java, Kotlin and C#) at a position the compiler or type checker actually checks. Where the site is COMPARED, it survives the regeneration and fails later, on data: a literal in a string-typed position (a `Record`, a `string` parameter, a loosely typed object spread into a `.set(...)`, a `dict`), in raw SQL (`WHERE status = 'error'`), in a JSON payload or fixture, as a broker topic or an SSE / websocket event name, in a `switch` over a `string` that the type checker cannot exhaust, or in a comparison against a column read back as `string`. **Verify the position before scoring it, never the shape of the literal:** the TypeScript case that earned the exemption (332 narrowed literals, 0 findings) and the case that earned this signature (`status: "error"` beside `CouncilStatusEnum.enum.complete`) look identical in a grep, and only the type at the site separates them. If you cannot tell which side a site is on, read the declared type of the target; report it as unverified rather than skipping it. + + **Do not flag:** a literal narrowed by the generated union or enum type at a checked position; a value the metadata does not own (an application-level state machine's label that merely coincides with a member, a page route, copy); a literal inside a generated file; and a test asserting the wire value of a member on purpose (a test that pins `"complete"` is a contract test and says so). A literal inside a `source.rdb` `@sql` body is metadata and is exempt, as in signature 11. + + **Remedy:** reference the generated constant, which is the one rule for every port: the Zod enum's member (for example `CouncilStatusEnum.enum.complete`) or the generated union type on TypeScript, the generated `Literal` alias or enum on Python, the generated enum constant on Java, Kotlin and C#. In raw SQL the value moves to a bound parameter fed from the constant (never a literal inside the SQL text), and the table and column in that statement are signature 11's names artifact. Report the site count as `/ sites` exactly as signature 11 does, and carry a deferral forward with its count. This signature needs no names artifact; it needs the generated enum type. If the project generates none, the un-wired `entity` generator is the first finding. + diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md index 4f5678520..af230bbb5 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md @@ -59,4 +59,4 @@ format-built argument at the project's own LLM request call sites. `voRequest` / `voResponse` jsonb columns must be authored `field.object` — the loader must not mutate the tree; vendor SDK client + pricing are BYO (ADR-0024). - No `meta verify --templates` gate; no declared `@maxChars` / `@maxTokens` budget. -- **Template-text duplication.** Hash every external template file (`md5sum` the template dir) and diff within groups sharing a `payloadRef`. A byte-identical cluster, or N≥3 templates sharing an identical skeleton with only slot lines differing, is ONE prompt maintained N times — a wording fix must be applied N times and WILL be missed. Recommend a shared `{{> group/partial }}` include (the render engine inlines partials recursively) or one template + payload-carried variant fields. Do NOT flag: the per-node `payloadRef` / `@format` repetition across `template.prompt` nodes (loader-forced, not a copy); templates meant to diverge independently — require N≥3 and structural identity before flagging. +- **Template-text duplication.** Hash every external template file (`md5sum` the template dir; skip include-only files, below) and diff within groups sharing a `payloadRef`. A byte-identical cluster, or N≥3 templates sharing an identical skeleton with only slot lines differing, is ONE prompt maintained N times — a wording fix must be applied N times and WILL be missed. Recommend a shared `{{> group/partial }}` include (the render engine inlines partials recursively) or one template + payload-carried variant fields. Do NOT flag: the per-node `payloadRef` / `@format` repetition across `template.prompt` nodes (loader-forced, not a copy); templates meant to diverge independently — require N≥3 and structural identity before flagging; **an include-only template**. A template whose body is nothing but partial includes (every non-blank line is a `{{> group/partial }}` and there is no prose of its own) is already the fix this section recommends: the wording lives once in the partial, and the stub is the per-prompt entry point the loader requires. Several such stubs being byte-identical, or sharing the same include list, is the recommended shape, not a cluster. Drop include-only files from the hash groups BEFORE comparing, and apply the cluster test to what remains. A template that mixes includes with a skeleton of its own prose still counts, and its prose is what is compared. When every member of a cluster is include-only, report it as the gold standard, the way a repository that imports `Names` is called out as good. diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/python.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/python.md index 08974a711..9b61d0f53 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/python.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/python.md @@ -68,6 +68,8 @@ regardless of server language — see the migration reference. | `def get_all_` / `def create_` / `def update_` / `def delete_` in non-generated files | hand-rolled CRUD — compare to the generated router | | `# keep in sync with` / `# mirrors the` | second-source-of-truth comment — always a finding | | `try: ... except KeyError` / `?? ''` around format strings in prompt code | silent-degradation hack around a prompt payload — flag it | +| a member of a generated enum typed as a hand string beside its generated `Literal` / enum (`status="error"` in a `dict`, an f-string SQL, a JSON body, an event name) | **signature 13.** Value-first from each enum's `@values`. A value at a position the type checker verifies against the generated `Literal[...]` is resolved and exempt; a `dict`, a `str` parameter or SQL text is compared and scores | +| a status-like column written with `update(T).values(status=...)` / `UPDATE ... SET status` and no condition on the current value | **axis G3.** Guard it through SQLAlchemy (`update(T).where(T.id == id, T.status == )`) or the `ObjectManager`, with the generated constant, and check `rowcount`. Never raw SQL | | a table/column string in the repository implementation (SQLAlchemy Core, asyncpg/psycopg SQL) | second spelling of a declared physical name — reference `_names.py` (`AUTHOR_SOURCE_PRIMARY_TABLE` / `AUTHOR__COLUMN`, emitted when a target lists `names`); no typed handle exists on this port, so that is never the reason to waive it | --- @@ -95,8 +97,19 @@ a generator that reads a `codegen/runtime/` module (`routes`: `filter_parser`, copy (`metaobjects eject --undo` removes every identical copy and prints the config edit). `DIFFERS` with `0 of your own` rules out added lines, not removed ones: confirm from `git log -p` on the copy that nothing changed after the eject, else report it as **possibly -customized: check its history**. A copy with lines of its own, or an edited companion, is a -customization; check only how far behind it is. Count lines for the Phase 0 total: lines in +customized: check its history**. + +**`DIFFERS` is printed for BOTH a deliberate customization and a copy gone stale by version**, +so the listing cannot tell them apart, and a copy with lines of its own is NOT thereby a +customization: `M of your own` counts every line absent from today's reference, so after the +reference moved on, a line it removed or rewrote reads as "yours" and is an older upstream +line. Run the history check on any `DIFFERS` row, `M` above 0 included: lines the copy's later +commits added are the project's own; lines already in the eject commit are upstream's and +count as behind. Report two figures, ` lines behind the reference` and ` lines of the +project's own (confirmed from history)`. A copy whose own lines history does not confirm is +**possibly customized or stale: check its history**; one that is stale by version is a finding +(`owned-generator-stale`): re-eject at the current version or return to the stock entry. +Count lines for the Phase 0 total: lines in copies proven unmodified, over all lines under `codegen/generators/` and `codegen/runtime/`. **A finding of the form "the built-ins do not emit the shape this project needs", or a diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md index 4a5e29277..58c7f3feb 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md @@ -13,6 +13,7 @@ The full hunt for each axis of Phase 1b (Partial / Deep path). Each axis runs in - F2. Over-generation — unnecessary generated files - G. Runtime-contract anti-patterns - G2. `own*()` accessor discipline +- G3. Guarded status transitions - H. Authoring correctness / ADR conformance - H2. Wrong native type - I. Vocabulary hygiene / modernization (advisory) @@ -200,6 +201,37 @@ The full hunt for each axis of Phase 1b (Partial / Deep path). Each axis runs in deliberately-own attribute `@dbColumnType` (a physical column-type override, never inherited). Any own read that carries a comment naming one of these cases is fine; an uncommented own read of an effective property is the defect. +- [ ] **G3. Guarded status transitions (a correctness check, `unguarded-status-transition`).** + For each model whose enum column is status-like (a `field.enum`, or a `field.string` named + `status` / `state` / `phase` / `stage`, whose members read as a lifecycle: pending, running, + complete, error), list **every write to that column** in non-generated code: an ORM + `update(...).set({ status })`, a `UPDATE ... SET status`, an entity property assignment that is + then saved, an upsert. For each write ask one question: **is it conditional on the current + value?** A guarded write names the state it moves from (`WHERE id = ? AND status = `, a + compare-and-set whose affected-row count is checked, an optimistic-lock version, a database + constraint that rejects the move). An unguarded write (`SET status = 'complete' WHERE id = ?`) + lets two workers, a retry or a late callback move a row backwards or out of a terminal state + (`complete` overwritten by `error`), and nothing reports it. An insert's initial value is not a + transition. A write that is the only possible transition and has one writer is lower + confidence, not exempt: say why it is safe to leave. Score `guarded writes / all writes` per + status column and list the unguarded ones; also state whether the allowed `from` states are + written down anywhere, because a rule held only in scattered `WHERE` clauses is a second + source of truth. + **The remedy never uses raw SQL.** Raw SQL spells the table, the column and the enum member by + hand, and those names drift. At a minimum the guarded write references the generated constants + for the table, the field and the enum member; ideally it goes through the port's ORM, which + carries them as typed handles, with the constraints the model already generates (the enum's + `CHECK`) behind it. Per port the guard is a predicate on the current value inside the ORM + update: Drizzle / Kysely `.where(and(eq(t.id, id), eq(t.status, )))` on TypeScript; + SQLAlchemy `update(T).where(T.id == id, T.status == )` or the `ObjectManager` on Python; + OMDB or the generated repository, or a JPA modifying query over entity attributes, on Java; + Exposed `T.update({ (T.id eq id) and (T.status eq ) })` on Kotlin; EF Core + `ExecuteUpdateAsync` after a `Where(x => x.Status == )` on C#. Each checks the + affected-row count. `` is the generated enum constant, never a literal (signature 13 in + `drift-signatures.md`). **If the allowed transitions need a declared form, that is a question + for the human, never a new attribute** (ADR-0023): the sketch proposes the ORM guard and + records the transition table as an open decision. Pillar `runtime`, surface `repository`. + - [ ] **H. Authoring-correctness / ADR-conformance (deep).** Invented/unregistered `@`-attrs or post-bootstrap registration (ADR-0023 — custom attrs belong in a registered provider or `attr.properties`); retired source-v2 forms (`source.dbTable` / `@name` / diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md index 5225faad9..a50a9e32c 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md @@ -25,7 +25,7 @@ Actual cutovers run through the existing skills mapped per finding tier (§ Brid | `references/calibration.md` | Before raising any finding: it lists the port gaps and non-defects that are never the adopter's fault. | | `references/surface-review-axes.md` | Working any axis (A–I) of Phase 1b. | | `references/capability-checklist.md` | On every axis: the exhaustive list of what the metamodel can express, one hunt per capability. | -| `references/drift-signatures.md` | Hunting hand-written second sources of truth (validators, mappers, comments, views, JSON bags). | +| `references/drift-signatures.md` | Hunting hand-written second sources of truth (validators, mappers, comments, views, JSON bags, physical-name literals, enum-member and event-name literals). | | `references/prompt-anti-patterns.md` | Axis D: the project builds text sent to an LLM or parses a reply. | | `references/vocabulary-hygiene.md` | Axis I: retired or deprecated authoring forms, custom vocabulary, vocabulary opportunities. | | `references/requirements.md` | Axis E2: the project declares `requirement.*` nodes. | @@ -65,7 +65,13 @@ Actual cutovers run through the existing skills mapped per finding tier (§ Brid not removed or reordered ones: confirm those from the file's history (`git log -p -- codegen/generators/.ts` shows no edit after the eject) first, and report a copy you cannot confirm as "possibly customized: check its history", not as a - copy to drop. For `entity`, + copy to drop. **A copy with lines of its own is not proof of a customization either.** + `meta eject --list` (and each port's listing) prints `DIFFERS` for BOTH a deliberate + customization and a copy gone stale by version, and its `M of your own` count includes + every line the reference has since removed or rewritten, which are older upstream lines. + Run the history check on every `DIFFERS` row, `M` above 0 included, and report the two + figures separately: lines behind the reference, and lines of the project's own that the + history confirms. A copy stale by version is a finding (`owned-generator-stale`). For `entity`, `routes` and `routes-hono` that holds only when every `codegen/runtime/` file the copy reaches is also marked identical: an edited adapter is a customization, and such a copy is not a finding. Those runtime rows appear only with `--format text` (or as the @@ -134,7 +140,7 @@ Axes (the full hunt for each is in `references/surface-review-axes.md` — open - **C.** Drift hotspot — validators, mappers, runtime models · **D.** Prompt pillar - **E.** Owned generators and scaffold-and-own · **E2.** Capability ledger (`requirement.*`, opt-in) - **F.** Drift-gate adoption — does it block? · **F2.** Over-generation and generated-but-bypassed (codegen waste) -- **G.** Runtime-contract anti-patterns · **G2.** `own*()` accessor discipline (a correctness defect, not advisory) +- **G.** Runtime-contract anti-patterns · **G2.** `own*()` accessor discipline (a correctness defect, not advisory) · **G3.** Guarded status transitions (every write to a status-like enum column is conditional on the current value; the fix goes through the port's ORM with generated constants, never raw SQL) - **H.** Authoring correctness / ADR conformance · **H2.** Wrong native type (`field.string` + `@dbColumnType: uuid`) - **I.** Vocabulary hygiene / modernization (advisory, never a failing finding) @@ -173,7 +179,9 @@ hidden candidate. **Stub trap:** demo-data routes have nothing to replace — cl the Phase 0 owned-generators check: recommend the package import. Importing any other generator from that subpath is correct and is not a finding. - Audit owned generators: (a) regenerate clean? (b) drifted from reference templates — - intentional (good) vs stale/accidental (missed upstream fix)? (c) hand-rolling a walk + intentional (good) vs stale/accidental (missed upstream fix)? Decide it from the copy's + history, not from the listing's `own` count: `DIFFERS` reads the same for both, and lines + the reference has since removed count as "own" (Phase 0, owned-generators check). (c) hand-rolling a walk that a declarative `scope` + `outputPattern` could replace? (d) bespoke shape better as a `templateGenerator` than a forked generator? - **Authoring ladder:** built-in fits → import it as shipped · close → **eject it and @@ -274,7 +282,7 @@ Each roadmap item: LOC retired, prerequisite, **parity-gate before deleting hand | Field | Content | |---|---| -| `id` | stable kebab id (e.g. `handwritten-crud-route`, `manual-zod-validator`) | +| `id` | stable kebab id (e.g. `handwritten-crud-route`, `manual-zod-validator`, `enum-literal-spelling`, `unguarded-status-transition`, `owned-generator-stale`) | | `title` | "you hand-wrote X that metadata can generate / model" | | `pillar` | `codegen` / `runtime` / `drift` / `prompt` | | `surface` | `entity` / `route` / `validator` / `repository` / `dto` / `hooks` / `prompt` / `migration` | diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/calibration.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/calibration.md index 4271e57ee..c605cd7a0 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/calibration.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/calibration.md @@ -10,6 +10,9 @@ - Planned, not shipped - Cross-port version-number skew - Stale upstream prose +- Include-only templates +- Owned generator copies with lines of their own +- Enum-member literals at a checked position - **Filter-operator route codegen — the baseline grammar ships in all five ports.** The `?filter[field][op]=value` grammar (all 9 operators `eq/ne/gt/gte/lt/lte/in/like/isNull`, @@ -32,3 +35,6 @@ - **Planned, not shipped** — `api.*` / `operation.*` / `binding.*` (FR-024) and MCP exposure of declared prompts/tools are not yet in the registry; their absence is not an adopter defect. - **Cross-port version-NUMBER skew is by design** — TS/C#/Python `0.x` vs Java/Kotlin `7.x` Maven is correct; never flag the *number lines* differing. But that is exactly why you can't eyeball cross-language drift: compare **`metamodelVersion`** (Phase 0 cross-language consistency item), not the package numbers. A `metamodelVersion` MISMATCH across ports *is* a finding; so is a port lagging its ecosystem's latest release. Also flag *intra-port* drift (mixed versions within one port, or a runtime package in `devDependencies`). - **Stale upstream prose** — "hand-write the Spring controller" (Java/Kotlin) is out of date; trust `meta gen --list`, not stale prose. +- **Include-only templates are not duplicates.** A prompt template whose body is only `{{> partial }}` includes is already the shared-partial fix the template-duplication check recommends. Identical stubs are the shape to want, never a byte-identical cluster to report (`references/prompt-anti-patterns.md`, template-text duplication). +- **An owned generator copy with lines of its own is not thereby customized.** The listing prints `DIFFERS` for a deliberate customization and for a copy stale by version, and counts lines the reference later removed as "of your own". Check the copy's history before calling either (Phase 0, owned-generators check). +- **An enum-member literal at a position the type checker verifies is not drift.** A value narrowed by the generated union or enum type breaks the build when the member is renamed. Signature 13 scores only a literal in a position nothing checks: a string-typed parameter, raw SQL, a JSON payload, an event name (`references/drift-signatures.md`). diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md index 71672d2a8..5741cf9de 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -58,7 +58,11 @@ classify it (using the classification scheme in `SKILL.md`) and route the cutove - **`field.date` / `field.time` / `field.timestamp`** (`@autoSet`) — hunt hand-stamped `createdAt` / `updatedAt` assignments and ad-hoc temporal parsing; `@autoSet` stamps them. - **`field.enum`** (`@values`) — hunt hand-written TS unions, language `enum`s, or DB - `CHECK ... IN (...)` lists that restate a modeled enum's members. + `CHECK ... IN (...)` lists that restate a modeled enum's members. Two more hunts on the same + capability: a member or event name typed as a hand string beside the generated constant + (`drift-signatures.md`, signature 13), and a status-like enum column written without a + condition on its current value (`surface-review-axes.md`, G3: every write conditional, through + the port's ORM with generated constants, never raw SQL). - **`field.uuid`** — hunt UUIDs typed as bare strings / hand-validated. - **`field.object`** (`@objectRef`, `@storage`) — hunt hand-flattened owned columns or hand-rolled jsonb (de)serialization the `@storage` mode (`flattened`/`jsonb`/`subdocument`) diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md index 5148b26db..492c015b6 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md @@ -13,6 +13,7 @@ - 10. N declarations of one field across objects - 11. A metadata-derived value spelled a second time by hand - 12. An open JSON bag whose readers agree on a shape +- 13. An enum member or event name typed by hand beside the generated constant Per finding: `file:line` → what → generated-equivalent exists? → recommendation. @@ -55,3 +56,12 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend **Do NOT flag:** a bag whose readers genuinely pass it through (logged, echoed, stored, forwarded to a third party) without reading a key; a third-party or LLM raw response whose shape is not the adopter's to declare; an **array of scalars**, which is `isArray: true` on the base subtype and never an object; a bag the metadata's own comment justifies as open with a reason. **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — nested map values are validated on every write path on every port as of 1.0.1 ([#362](https://github.com/metaobjectsdev/metaobjects/issues/362) — before that, Java skipped the cascade on TPH write paths and C# skipped it everywhere, silently accepting invalid values rather than erroring). **Check the adopter's pinned version before recommending this rung to a Java-TPH or C# consumer: on 1.0.0 the hole is open and they need their own boundary check.** Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor. + +13. **An enum member or event name typed as a hand string beside the generated constant.** Signature 11 covers physical names; this is the same rule for a model's enum values and for the event or message names an enum member spells. A literal in non-generated code that EQUALS a member of a generated enum (or the name of a declared event) is a second spelling, and it is the commonest drift an estate that already uses the constants carries: the file imports `CouncilStatusEnum` and uses `CouncilStatusEnum.enum.complete` on one line, then writes `status: "error"` on the next. The two spellings agree today and nothing ties them together. **Hunt VALUE-first, from the declaration outward:** list every enum's members from the metadata (`@values` on each `field.enum`, plus the member names of any enum-backed event or message type), then grep the non-generated tree, config and fixtures included, for each member as a quoted literal. Distinctive members (`"edited_approved"`, `"awaiting_review"`) score on the hit; generic ones (`"error"`, `"done"`, `"active"`) only by reading the site, because the same word may belong to something the metadata does not own. **A file that already imports the enum's generated constant is the strongest tell**: its author knew the constant existed, so a bare literal of another member of that enum in the same file is almost never intentional. Count those first. + + **Apply signature 11's discriminator, then say which side of it the site is on.** Rename the member in metadata and regenerate. Where the site is RESOLVED, the regeneration breaks it before anything runs and it is not a finding: a value narrowed by the generated union (`export type = "a" | "b"` on TypeScript, a `Literal[...]` annotation on Python, a generated `enum` type on Java, Kotlin and C#) at a position the compiler or type checker actually checks. Where the site is COMPARED, it survives the regeneration and fails later, on data: a literal in a string-typed position (a `Record`, a `string` parameter, a loosely typed object spread into a `.set(...)`, a `dict`), in raw SQL (`WHERE status = 'error'`), in a JSON payload or fixture, as a broker topic or an SSE / websocket event name, in a `switch` over a `string` that the type checker cannot exhaust, or in a comparison against a column read back as `string`. **Verify the position before scoring it, never the shape of the literal:** the TypeScript case that earned the exemption (332 narrowed literals, 0 findings) and the case that earned this signature (`status: "error"` beside `CouncilStatusEnum.enum.complete`) look identical in a grep, and only the type at the site separates them. If you cannot tell which side a site is on, read the declared type of the target; report it as unverified rather than skipping it. + + **Do not flag:** a literal narrowed by the generated union or enum type at a checked position; a value the metadata does not own (an application-level state machine's label that merely coincides with a member, a page route, copy); a literal inside a generated file; and a test asserting the wire value of a member on purpose (a test that pins `"complete"` is a contract test and says so). A literal inside a `source.rdb` `@sql` body is metadata and is exempt, as in signature 11. + + **Remedy:** reference the generated constant, which is the one rule for every port: the Zod enum's member (for example `CouncilStatusEnum.enum.complete`) or the generated union type on TypeScript, the generated `Literal` alias or enum on Python, the generated enum constant on Java, Kotlin and C#. In raw SQL the value moves to a bound parameter fed from the constant (never a literal inside the SQL text), and the table and column in that statement are signature 11's names artifact. Report the site count as `/ sites` exactly as signature 11 does, and carry a deferral forward with its count. This signature needs no names artifact; it needs the generated enum type. If the project generates none, the un-wired `entity` generator is the first finding. + diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md index 4f5678520..af230bbb5 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md @@ -59,4 +59,4 @@ format-built argument at the project's own LLM request call sites. `voRequest` / `voResponse` jsonb columns must be authored `field.object` — the loader must not mutate the tree; vendor SDK client + pricing are BYO (ADR-0024). - No `meta verify --templates` gate; no declared `@maxChars` / `@maxTokens` budget. -- **Template-text duplication.** Hash every external template file (`md5sum` the template dir) and diff within groups sharing a `payloadRef`. A byte-identical cluster, or N≥3 templates sharing an identical skeleton with only slot lines differing, is ONE prompt maintained N times — a wording fix must be applied N times and WILL be missed. Recommend a shared `{{> group/partial }}` include (the render engine inlines partials recursively) or one template + payload-carried variant fields. Do NOT flag: the per-node `payloadRef` / `@format` repetition across `template.prompt` nodes (loader-forced, not a copy); templates meant to diverge independently — require N≥3 and structural identity before flagging. +- **Template-text duplication.** Hash every external template file (`md5sum` the template dir; skip include-only files, below) and diff within groups sharing a `payloadRef`. A byte-identical cluster, or N≥3 templates sharing an identical skeleton with only slot lines differing, is ONE prompt maintained N times — a wording fix must be applied N times and WILL be missed. Recommend a shared `{{> group/partial }}` include (the render engine inlines partials recursively) or one template + payload-carried variant fields. Do NOT flag: the per-node `payloadRef` / `@format` repetition across `template.prompt` nodes (loader-forced, not a copy); templates meant to diverge independently — require N≥3 and structural identity before flagging; **an include-only template**. A template whose body is nothing but partial includes (every non-blank line is a `{{> group/partial }}` and there is no prose of its own) is already the fix this section recommends: the wording lives once in the partial, and the stub is the per-prompt entry point the loader requires. Several such stubs being byte-identical, or sharing the same include list, is the recommended shape, not a cluster. Drop include-only files from the hash groups BEFORE comparing, and apply the cluster test to what remains. A template that mixes includes with a skeleton of its own prose still counts, and its prose is what is compared. When every member of a cluster is include-only, report it as the gold standard, the way a repository that imports `Names` is called out as good. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md index 4a5e29277..58c7f3feb 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md @@ -13,6 +13,7 @@ The full hunt for each axis of Phase 1b (Partial / Deep path). Each axis runs in - F2. Over-generation — unnecessary generated files - G. Runtime-contract anti-patterns - G2. `own*()` accessor discipline +- G3. Guarded status transitions - H. Authoring correctness / ADR conformance - H2. Wrong native type - I. Vocabulary hygiene / modernization (advisory) @@ -200,6 +201,37 @@ The full hunt for each axis of Phase 1b (Partial / Deep path). Each axis runs in deliberately-own attribute `@dbColumnType` (a physical column-type override, never inherited). Any own read that carries a comment naming one of these cases is fine; an uncommented own read of an effective property is the defect. +- [ ] **G3. Guarded status transitions (a correctness check, `unguarded-status-transition`).** + For each model whose enum column is status-like (a `field.enum`, or a `field.string` named + `status` / `state` / `phase` / `stage`, whose members read as a lifecycle: pending, running, + complete, error), list **every write to that column** in non-generated code: an ORM + `update(...).set({ status })`, a `UPDATE ... SET status`, an entity property assignment that is + then saved, an upsert. For each write ask one question: **is it conditional on the current + value?** A guarded write names the state it moves from (`WHERE id = ? AND status = `, a + compare-and-set whose affected-row count is checked, an optimistic-lock version, a database + constraint that rejects the move). An unguarded write (`SET status = 'complete' WHERE id = ?`) + lets two workers, a retry or a late callback move a row backwards or out of a terminal state + (`complete` overwritten by `error`), and nothing reports it. An insert's initial value is not a + transition. A write that is the only possible transition and has one writer is lower + confidence, not exempt: say why it is safe to leave. Score `guarded writes / all writes` per + status column and list the unguarded ones; also state whether the allowed `from` states are + written down anywhere, because a rule held only in scattered `WHERE` clauses is a second + source of truth. + **The remedy never uses raw SQL.** Raw SQL spells the table, the column and the enum member by + hand, and those names drift. At a minimum the guarded write references the generated constants + for the table, the field and the enum member; ideally it goes through the port's ORM, which + carries them as typed handles, with the constraints the model already generates (the enum's + `CHECK`) behind it. Per port the guard is a predicate on the current value inside the ORM + update: Drizzle / Kysely `.where(and(eq(t.id, id), eq(t.status, )))` on TypeScript; + SQLAlchemy `update(T).where(T.id == id, T.status == )` or the `ObjectManager` on Python; + OMDB or the generated repository, or a JPA modifying query over entity attributes, on Java; + Exposed `T.update({ (T.id eq id) and (T.status eq ) })` on Kotlin; EF Core + `ExecuteUpdateAsync` after a `Where(x => x.Status == )` on C#. Each checks the + affected-row count. `` is the generated enum constant, never a literal (signature 13 in + `drift-signatures.md`). **If the allowed transitions need a declared form, that is a question + for the human, never a new attribute** (ADR-0023): the sketch proposes the ORM guard and + records the transition table as an open decision. Pillar `runtime`, surface `repository`. + - [ ] **H. Authoring-correctness / ADR-conformance (deep).** Invented/unregistered `@`-attrs or post-bootstrap registration (ADR-0023 — custom attrs belong in a registered provider or `attr.properties`); retired source-v2 forms (`source.dbTable` / `@name` / diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/typescript.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/typescript.md index d9041026b..cada45c52 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/typescript.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/typescript.md @@ -70,6 +70,8 @@ here — the table is its second pass, not its definition. | the resource path in an API call or mount — `app.get("/subscribers")`, the `/subscribers` inside `fetch("/api/subscribers")`, a TanStack `queryKey` path | second spelling of `$path` on the entity module and its `.meta.ts` twin. The `/api` prefix is NOT part of the finding: the constants surface deliberately carries no prefix member — it is the provider's `baseUrl`, a deployment fact — so the prefix has no constant by design and is never a gap either | | a field name passed as text where the parameter's type is a bare `string` — `sort: "created_at"`, `filter["email"]`, a column-id string in a grid config | the per-field constants object carries `name`. Check the parameter type first: where the generated hook types it as a union of field names it is resolved and this row does not apply, exactly as a typed property access does not | | a form label or a validation limit/message re-typed in hand JSX or a hand validator (`maxLength={255}`, `"Email is required"`) | derived from `@title` and the field's validator children into `label` / `rules`; the hand copy is a second source of truth | +| a member of a generated enum typed as a hand string where the file already imports its constant (`status: "error"` beside `CouncilStatusEnum.enum.complete`), in a string-typed position, raw SQL, a JSON payload or an event name | **signature 13.** Value-first: grep the non-generated tree for each enum's `@values`. Check the declared type at the site first: a position narrowed by the generated `export type ` union is resolved and exempt; a `Record`, a `string` parameter or a `sql` fragment is compared and scores | +| a status-like column written with `.set({ status })` and no condition on the current value, or a `sql` `UPDATE ... SET status` | **axis G3.** Guard it through Drizzle / Kysely: `.where(and(eq(t.id, id), eq(t.status, )))` with the generated constant, and check the affected-row count. Never raw SQL | | **NOT a finding — the rule's consequences, not a list:** anything RESOLVED (a typed handle, a typed property access, a typed filter key, an enum member narrowed by the generated `export type ` union — measured: 332 such literals on one estate, 0 real) and anything the metadata does not OWN (a page route or link, `` / `` — 11 apparent `$path` hits on another estate, 0 real; the deployment prefix above) | rewriting a resolved reference to a string lookup is a regression; converting a page route couples the site's URL structure to the API's. Emitted `options: [...] as const` populates a dropdown, not replaces checked literals. A shape that is neither resolved nor unowned and has a constant → score it, whatever it is | --- @@ -118,8 +120,17 @@ are the `--format text` labels. In `--format json` or `toon` the same row carrie `diff -u node_modules/@metaobjectsdev/codegen-ts/src/reference/.ts codegen/generators/.ts` showing only upstream changes. Confirmed, it is the same finding. Not confirmed, report it as **possibly customized: check its history**, never as a copy to drop. -- A copy with lines of its own is an intentional customization. Check only how far behind - it is. +- A copy with lines of its own (`M > 0`) is **not** thereby an intentional customization. The + listing prints `DIFFERS` for both a deliberate customization and a copy gone stale by + version, and `M line(s) of your own` counts every line absent from today's reference: after + the reference moved on, a line it removed or rewrote reads as "yours" and is an older + upstream line. Run the same history check on any `DIFFERS` row, `M > 0` included + (`git log -p -- codegen/generators/.ts`): lines the copy's later commits added are the + project's own; lines already in the eject commit are upstream's, and count as behind. Report + ` lines behind the reference` and ` lines of the project's own (confirmed from + history)` as two figures. A copy whose own lines history does not confirm is **possibly + customized or stale: check its history**; a copy that is stale by version is a finding + (`owned-generator-stale`): re-eject at the current version or return to the package import. Count lines for the Phase 0 total: lines in copies proven unmodified, with the `codegen/runtime/` files they reach, over all lines the project owns under `codegen/` diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/SKILL.md index 5225faad9..a50a9e32c 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/SKILL.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/SKILL.md @@ -25,7 +25,7 @@ Actual cutovers run through the existing skills mapped per finding tier (§ Brid | `references/calibration.md` | Before raising any finding: it lists the port gaps and non-defects that are never the adopter's fault. | | `references/surface-review-axes.md` | Working any axis (A–I) of Phase 1b. | | `references/capability-checklist.md` | On every axis: the exhaustive list of what the metamodel can express, one hunt per capability. | -| `references/drift-signatures.md` | Hunting hand-written second sources of truth (validators, mappers, comments, views, JSON bags). | +| `references/drift-signatures.md` | Hunting hand-written second sources of truth (validators, mappers, comments, views, JSON bags, physical-name literals, enum-member and event-name literals). | | `references/prompt-anti-patterns.md` | Axis D: the project builds text sent to an LLM or parses a reply. | | `references/vocabulary-hygiene.md` | Axis I: retired or deprecated authoring forms, custom vocabulary, vocabulary opportunities. | | `references/requirements.md` | Axis E2: the project declares `requirement.*` nodes. | @@ -65,7 +65,13 @@ Actual cutovers run through the existing skills mapped per finding tier (§ Brid not removed or reordered ones: confirm those from the file's history (`git log -p -- codegen/generators/.ts` shows no edit after the eject) first, and report a copy you cannot confirm as "possibly customized: check its history", not as a - copy to drop. For `entity`, + copy to drop. **A copy with lines of its own is not proof of a customization either.** + `meta eject --list` (and each port's listing) prints `DIFFERS` for BOTH a deliberate + customization and a copy gone stale by version, and its `M of your own` count includes + every line the reference has since removed or rewritten, which are older upstream lines. + Run the history check on every `DIFFERS` row, `M` above 0 included, and report the two + figures separately: lines behind the reference, and lines of the project's own that the + history confirms. A copy stale by version is a finding (`owned-generator-stale`). For `entity`, `routes` and `routes-hono` that holds only when every `codegen/runtime/` file the copy reaches is also marked identical: an edited adapter is a customization, and such a copy is not a finding. Those runtime rows appear only with `--format text` (or as the @@ -134,7 +140,7 @@ Axes (the full hunt for each is in `references/surface-review-axes.md` — open - **C.** Drift hotspot — validators, mappers, runtime models · **D.** Prompt pillar - **E.** Owned generators and scaffold-and-own · **E2.** Capability ledger (`requirement.*`, opt-in) - **F.** Drift-gate adoption — does it block? · **F2.** Over-generation and generated-but-bypassed (codegen waste) -- **G.** Runtime-contract anti-patterns · **G2.** `own*()` accessor discipline (a correctness defect, not advisory) +- **G.** Runtime-contract anti-patterns · **G2.** `own*()` accessor discipline (a correctness defect, not advisory) · **G3.** Guarded status transitions (every write to a status-like enum column is conditional on the current value; the fix goes through the port's ORM with generated constants, never raw SQL) - **H.** Authoring correctness / ADR conformance · **H2.** Wrong native type (`field.string` + `@dbColumnType: uuid`) - **I.** Vocabulary hygiene / modernization (advisory, never a failing finding) @@ -173,7 +179,9 @@ hidden candidate. **Stub trap:** demo-data routes have nothing to replace — cl the Phase 0 owned-generators check: recommend the package import. Importing any other generator from that subpath is correct and is not a finding. - Audit owned generators: (a) regenerate clean? (b) drifted from reference templates — - intentional (good) vs stale/accidental (missed upstream fix)? (c) hand-rolling a walk + intentional (good) vs stale/accidental (missed upstream fix)? Decide it from the copy's + history, not from the listing's `own` count: `DIFFERS` reads the same for both, and lines + the reference has since removed count as "own" (Phase 0, owned-generators check). (c) hand-rolling a walk that a declarative `scope` + `outputPattern` could replace? (d) bespoke shape better as a `templateGenerator` than a forked generator? - **Authoring ladder:** built-in fits → import it as shipped · close → **eject it and @@ -274,7 +282,7 @@ Each roadmap item: LOC retired, prerequisite, **parity-gate before deleting hand | Field | Content | |---|---| -| `id` | stable kebab id (e.g. `handwritten-crud-route`, `manual-zod-validator`) | +| `id` | stable kebab id (e.g. `handwritten-crud-route`, `manual-zod-validator`, `enum-literal-spelling`, `unguarded-status-transition`, `owned-generator-stale`) | | `title` | "you hand-wrote X that metadata can generate / model" | | `pillar` | `codegen` / `runtime` / `drift` / `prompt` | | `surface` | `entity` / `route` / `validator` / `repository` / `dto` / `hooks` / `prompt` / `migration` | diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/calibration.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/calibration.md index 4271e57ee..c605cd7a0 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/calibration.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/calibration.md @@ -10,6 +10,9 @@ - Planned, not shipped - Cross-port version-number skew - Stale upstream prose +- Include-only templates +- Owned generator copies with lines of their own +- Enum-member literals at a checked position - **Filter-operator route codegen — the baseline grammar ships in all five ports.** The `?filter[field][op]=value` grammar (all 9 operators `eq/ne/gt/gte/lt/lte/in/like/isNull`, @@ -32,3 +35,6 @@ - **Planned, not shipped** — `api.*` / `operation.*` / `binding.*` (FR-024) and MCP exposure of declared prompts/tools are not yet in the registry; their absence is not an adopter defect. - **Cross-port version-NUMBER skew is by design** — TS/C#/Python `0.x` vs Java/Kotlin `7.x` Maven is correct; never flag the *number lines* differing. But that is exactly why you can't eyeball cross-language drift: compare **`metamodelVersion`** (Phase 0 cross-language consistency item), not the package numbers. A `metamodelVersion` MISMATCH across ports *is* a finding; so is a port lagging its ecosystem's latest release. Also flag *intra-port* drift (mixed versions within one port, or a runtime package in `devDependencies`). - **Stale upstream prose** — "hand-write the Spring controller" (Java/Kotlin) is out of date; trust `meta gen --list`, not stale prose. +- **Include-only templates are not duplicates.** A prompt template whose body is only `{{> partial }}` includes is already the shared-partial fix the template-duplication check recommends. Identical stubs are the shape to want, never a byte-identical cluster to report (`references/prompt-anti-patterns.md`, template-text duplication). +- **An owned generator copy with lines of its own is not thereby customized.** The listing prints `DIFFERS` for a deliberate customization and for a copy stale by version, and counts lines the reference later removed as "of your own". Check the copy's history before calling either (Phase 0, owned-generators check). +- **An enum-member literal at a position the type checker verifies is not drift.** A value narrowed by the generated union or enum type breaks the build when the member is renamed. Signature 13 scores only a literal in a position nothing checks: a string-typed parameter, raw SQL, a JSON payload, an event name (`references/drift-signatures.md`). diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md index 71672d2a8..5741cf9de 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -58,7 +58,11 @@ classify it (using the classification scheme in `SKILL.md`) and route the cutove - **`field.date` / `field.time` / `field.timestamp`** (`@autoSet`) — hunt hand-stamped `createdAt` / `updatedAt` assignments and ad-hoc temporal parsing; `@autoSet` stamps them. - **`field.enum`** (`@values`) — hunt hand-written TS unions, language `enum`s, or DB - `CHECK ... IN (...)` lists that restate a modeled enum's members. + `CHECK ... IN (...)` lists that restate a modeled enum's members. Two more hunts on the same + capability: a member or event name typed as a hand string beside the generated constant + (`drift-signatures.md`, signature 13), and a status-like enum column written without a + condition on its current value (`surface-review-axes.md`, G3: every write conditional, through + the port's ORM with generated constants, never raw SQL). - **`field.uuid`** — hunt UUIDs typed as bare strings / hand-validated. - **`field.object`** (`@objectRef`, `@storage`) — hunt hand-flattened owned columns or hand-rolled jsonb (de)serialization the `@storage` mode (`flattened`/`jsonb`/`subdocument`) diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md index 5148b26db..492c015b6 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md @@ -13,6 +13,7 @@ - 10. N declarations of one field across objects - 11. A metadata-derived value spelled a second time by hand - 12. An open JSON bag whose readers agree on a shape +- 13. An enum member or event name typed by hand beside the generated constant Per finding: `file:line` → what → generated-equivalent exists? → recommendation. @@ -55,3 +56,12 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend **Do NOT flag:** a bag whose readers genuinely pass it through (logged, echoed, stored, forwarded to a third party) without reading a key; a third-party or LLM raw response whose shape is not the adopter's to declare; an **array of scalars**, which is `isArray: true` on the base subtype and never an object; a bag the metadata's own comment justifies as open with a reason. **Remedy — a ladder, chosen by the WRITER's declared type, and every rung gives the consumer a TYPED HANDLE rather than a string to get and set.** `list[X]` ⇒ the base subtype + `isArray: true`. `dict[str, X]` with a known `X` ⇒ **`field.map` + `@objectRef`** (a value object) or `@valueType` (a scalar) — registered vocabulary that emits `jsonb("col").$type>()` and `z.record(z.string(), X)`. **Check the CONSUMER before recommending this rung: `field.map` codegen now completes on all five ports** (Java types it `java.util.Map`; C# emits the `Dictionary` property and its EF jsonb storage mapping), **but no persistence- or api-contract-conformance fixture exercises `field.map` on ANY port, and the runtime persistence tier is uneven** — only Python's `ObjectManager` encodes a map, while `runtime-ts`, Java's OMDB and the Kotlin Exposed lane carry none. So where GENERATED CODE is the consumer, check the port before recommending this rung — nested map values are validated on every write path on every port as of 1.0.1 ([#362](https://github.com/metaobjectsdev/metaobjects/issues/362) — before that, Java skipped the cascade on TPH write paths and C# skipped it everywhere, silently accepting invalid values rather than erroring). **Check the adopter's pinned version before recommending this rung to a Java-TPH or C# consumer: on 1.0.0 the hole is open and they need their own boundary check.** Prefer a value object where a PORT RUNTIME must read the column back; a genuinely dynamic key set stays a bag. A serialized DTO / dataclass / `@Serializable` class ⇒ declare it as an `object.value` and point the field at it with `field.object` + `@objectRef` + `@storage: jsonb`. Only `dict[str, Any]` whose readers pin no key stays an open bag. **What the adopter GETS, per port** — this is the point of the conversion and worth stating in the finding: TypeScript emits the Drizzle column as `.$type()` plus the VO's own Zod schema and inferred type; Python annotates the field with the referenced VO's Pydantic model (and its `Create` on the wire, so a nested violation still validates); Kotlin emits typed Exposed jsonb codecs backed by a shared Jackson `MetaJsonbMapper`. In none of them does the consumer touch a JSON string — that is what the open bag costs them today, since `unknown` forces every reader to cast. The column stays jsonb, so this is a codegen/contract change and not necessarily a migration; what changes is that `unknown` becomes a generated type, the casts delete, and the keys become metadata-owned — which also promotes their literals into drift signature 11's scope, where the `->>'key'` sites were previously exempt because the metadata owned nothing. **Verify the column type is genuinely unchanged before promising no migration**, and parity-gate each converted read against the raw-SQL one before deleting it. Convert one column at a time: the shape is a judgment per bag, and a wrong guess declares a contract the writers do not honor. + +13. **An enum member or event name typed as a hand string beside the generated constant.** Signature 11 covers physical names; this is the same rule for a model's enum values and for the event or message names an enum member spells. A literal in non-generated code that EQUALS a member of a generated enum (or the name of a declared event) is a second spelling, and it is the commonest drift an estate that already uses the constants carries: the file imports `CouncilStatusEnum` and uses `CouncilStatusEnum.enum.complete` on one line, then writes `status: "error"` on the next. The two spellings agree today and nothing ties them together. **Hunt VALUE-first, from the declaration outward:** list every enum's members from the metadata (`@values` on each `field.enum`, plus the member names of any enum-backed event or message type), then grep the non-generated tree, config and fixtures included, for each member as a quoted literal. Distinctive members (`"edited_approved"`, `"awaiting_review"`) score on the hit; generic ones (`"error"`, `"done"`, `"active"`) only by reading the site, because the same word may belong to something the metadata does not own. **A file that already imports the enum's generated constant is the strongest tell**: its author knew the constant existed, so a bare literal of another member of that enum in the same file is almost never intentional. Count those first. + + **Apply signature 11's discriminator, then say which side of it the site is on.** Rename the member in metadata and regenerate. Where the site is RESOLVED, the regeneration breaks it before anything runs and it is not a finding: a value narrowed by the generated union (`export type = "a" | "b"` on TypeScript, a `Literal[...]` annotation on Python, a generated `enum` type on Java, Kotlin and C#) at a position the compiler or type checker actually checks. Where the site is COMPARED, it survives the regeneration and fails later, on data: a literal in a string-typed position (a `Record`, a `string` parameter, a loosely typed object spread into a `.set(...)`, a `dict`), in raw SQL (`WHERE status = 'error'`), in a JSON payload or fixture, as a broker topic or an SSE / websocket event name, in a `switch` over a `string` that the type checker cannot exhaust, or in a comparison against a column read back as `string`. **Verify the position before scoring it, never the shape of the literal:** the TypeScript case that earned the exemption (332 narrowed literals, 0 findings) and the case that earned this signature (`status: "error"` beside `CouncilStatusEnum.enum.complete`) look identical in a grep, and only the type at the site separates them. If you cannot tell which side a site is on, read the declared type of the target; report it as unverified rather than skipping it. + + **Do not flag:** a literal narrowed by the generated union or enum type at a checked position; a value the metadata does not own (an application-level state machine's label that merely coincides with a member, a page route, copy); a literal inside a generated file; and a test asserting the wire value of a member on purpose (a test that pins `"complete"` is a contract test and says so). A literal inside a `source.rdb` `@sql` body is metadata and is exempt, as in signature 11. + + **Remedy:** reference the generated constant, which is the one rule for every port: the Zod enum's member (for example `CouncilStatusEnum.enum.complete`) or the generated union type on TypeScript, the generated `Literal` alias or enum on Python, the generated enum constant on Java, Kotlin and C#. In raw SQL the value moves to a bound parameter fed from the constant (never a literal inside the SQL text), and the table and column in that statement are signature 11's names artifact. Report the site count as `/ sites` exactly as signature 11 does, and carry a deferral forward with its count. This signature needs no names artifact; it needs the generated enum type. If the project generates none, the un-wired `entity` generator is the first finding. + diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md index 4f5678520..af230bbb5 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/prompt-anti-patterns.md @@ -59,4 +59,4 @@ format-built argument at the project's own LLM request call sites. `voRequest` / `voResponse` jsonb columns must be authored `field.object` — the loader must not mutate the tree; vendor SDK client + pricing are BYO (ADR-0024). - No `meta verify --templates` gate; no declared `@maxChars` / `@maxTokens` budget. -- **Template-text duplication.** Hash every external template file (`md5sum` the template dir) and diff within groups sharing a `payloadRef`. A byte-identical cluster, or N≥3 templates sharing an identical skeleton with only slot lines differing, is ONE prompt maintained N times — a wording fix must be applied N times and WILL be missed. Recommend a shared `{{> group/partial }}` include (the render engine inlines partials recursively) or one template + payload-carried variant fields. Do NOT flag: the per-node `payloadRef` / `@format` repetition across `template.prompt` nodes (loader-forced, not a copy); templates meant to diverge independently — require N≥3 and structural identity before flagging. +- **Template-text duplication.** Hash every external template file (`md5sum` the template dir; skip include-only files, below) and diff within groups sharing a `payloadRef`. A byte-identical cluster, or N≥3 templates sharing an identical skeleton with only slot lines differing, is ONE prompt maintained N times — a wording fix must be applied N times and WILL be missed. Recommend a shared `{{> group/partial }}` include (the render engine inlines partials recursively) or one template + payload-carried variant fields. Do NOT flag: the per-node `payloadRef` / `@format` repetition across `template.prompt` nodes (loader-forced, not a copy); templates meant to diverge independently — require N≥3 and structural identity before flagging; **an include-only template**. A template whose body is nothing but partial includes (every non-blank line is a `{{> group/partial }}` and there is no prose of its own) is already the fix this section recommends: the wording lives once in the partial, and the stub is the per-prompt entry point the loader requires. Several such stubs being byte-identical, or sharing the same include list, is the recommended shape, not a cluster. Drop include-only files from the hash groups BEFORE comparing, and apply the cluster test to what remains. A template that mixes includes with a skeleton of its own prose still counts, and its prose is what is compared. When every member of a cluster is include-only, report it as the gold standard, the way a repository that imports `Names` is called out as good. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md index 4a5e29277..58c7f3feb 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/surface-review-axes.md @@ -13,6 +13,7 @@ The full hunt for each axis of Phase 1b (Partial / Deep path). Each axis runs in - F2. Over-generation — unnecessary generated files - G. Runtime-contract anti-patterns - G2. `own*()` accessor discipline +- G3. Guarded status transitions - H. Authoring correctness / ADR conformance - H2. Wrong native type - I. Vocabulary hygiene / modernization (advisory) @@ -200,6 +201,37 @@ The full hunt for each axis of Phase 1b (Partial / Deep path). Each axis runs in deliberately-own attribute `@dbColumnType` (a physical column-type override, never inherited). Any own read that carries a comment naming one of these cases is fine; an uncommented own read of an effective property is the defect. +- [ ] **G3. Guarded status transitions (a correctness check, `unguarded-status-transition`).** + For each model whose enum column is status-like (a `field.enum`, or a `field.string` named + `status` / `state` / `phase` / `stage`, whose members read as a lifecycle: pending, running, + complete, error), list **every write to that column** in non-generated code: an ORM + `update(...).set({ status })`, a `UPDATE ... SET status`, an entity property assignment that is + then saved, an upsert. For each write ask one question: **is it conditional on the current + value?** A guarded write names the state it moves from (`WHERE id = ? AND status = `, a + compare-and-set whose affected-row count is checked, an optimistic-lock version, a database + constraint that rejects the move). An unguarded write (`SET status = 'complete' WHERE id = ?`) + lets two workers, a retry or a late callback move a row backwards or out of a terminal state + (`complete` overwritten by `error`), and nothing reports it. An insert's initial value is not a + transition. A write that is the only possible transition and has one writer is lower + confidence, not exempt: say why it is safe to leave. Score `guarded writes / all writes` per + status column and list the unguarded ones; also state whether the allowed `from` states are + written down anywhere, because a rule held only in scattered `WHERE` clauses is a second + source of truth. + **The remedy never uses raw SQL.** Raw SQL spells the table, the column and the enum member by + hand, and those names drift. At a minimum the guarded write references the generated constants + for the table, the field and the enum member; ideally it goes through the port's ORM, which + carries them as typed handles, with the constraints the model already generates (the enum's + `CHECK`) behind it. Per port the guard is a predicate on the current value inside the ORM + update: Drizzle / Kysely `.where(and(eq(t.id, id), eq(t.status, )))` on TypeScript; + SQLAlchemy `update(T).where(T.id == id, T.status == )` or the `ObjectManager` on Python; + OMDB or the generated repository, or a JPA modifying query over entity attributes, on Java; + Exposed `T.update({ (T.id eq id) and (T.status eq ) })` on Kotlin; EF Core + `ExecuteUpdateAsync` after a `Where(x => x.Status == )` on C#. Each checks the + affected-row count. `` is the generated enum constant, never a literal (signature 13 in + `drift-signatures.md`). **If the allowed transitions need a declared form, that is a question + for the human, never a new attribute** (ADR-0023): the sketch proposes the ORM guard and + records the transition table as an open decision. Pillar `runtime`, surface `repository`. + - [ ] **H. Authoring-correctness / ADR-conformance (deep).** Invented/unregistered `@`-attrs or post-bootstrap registration (ADR-0023 — custom attrs belong in a registered provider or `attr.properties`); retired source-v2 forms (`source.dbTable` / `@name` / diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/typescript.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/typescript.md index d9041026b..cada45c52 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/typescript.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/typescript.md @@ -70,6 +70,8 @@ here — the table is its second pass, not its definition. | the resource path in an API call or mount — `app.get("/subscribers")`, the `/subscribers` inside `fetch("/api/subscribers")`, a TanStack `queryKey` path | second spelling of `$path` on the entity module and its `.meta.ts` twin. The `/api` prefix is NOT part of the finding: the constants surface deliberately carries no prefix member — it is the provider's `baseUrl`, a deployment fact — so the prefix has no constant by design and is never a gap either | | a field name passed as text where the parameter's type is a bare `string` — `sort: "created_at"`, `filter["email"]`, a column-id string in a grid config | the per-field constants object carries `name`. Check the parameter type first: where the generated hook types it as a union of field names it is resolved and this row does not apply, exactly as a typed property access does not | | a form label or a validation limit/message re-typed in hand JSX or a hand validator (`maxLength={255}`, `"Email is required"`) | derived from `@title` and the field's validator children into `label` / `rules`; the hand copy is a second source of truth | +| a member of a generated enum typed as a hand string where the file already imports its constant (`status: "error"` beside `CouncilStatusEnum.enum.complete`), in a string-typed position, raw SQL, a JSON payload or an event name | **signature 13.** Value-first: grep the non-generated tree for each enum's `@values`. Check the declared type at the site first: a position narrowed by the generated `export type ` union is resolved and exempt; a `Record`, a `string` parameter or a `sql` fragment is compared and scores | +| a status-like column written with `.set({ status })` and no condition on the current value, or a `sql` `UPDATE ... SET status` | **axis G3.** Guard it through Drizzle / Kysely: `.where(and(eq(t.id, id), eq(t.status, )))` with the generated constant, and check the affected-row count. Never raw SQL | | **NOT a finding — the rule's consequences, not a list:** anything RESOLVED (a typed handle, a typed property access, a typed filter key, an enum member narrowed by the generated `export type ` union — measured: 332 such literals on one estate, 0 real) and anything the metadata does not OWN (a page route or link, `` / `` — 11 apparent `$path` hits on another estate, 0 real; the deployment prefix above) | rewriting a resolved reference to a string lookup is a regression; converting a page route couples the site's URL structure to the API's. Emitted `options: [...] as const` populates a dropdown, not replaces checked literals. A shape that is neither resolved nor unowned and has a constant → score it, whatever it is | --- @@ -118,8 +120,17 @@ are the `--format text` labels. In `--format json` or `toon` the same row carrie `diff -u node_modules/@metaobjectsdev/codegen-ts/src/reference/.ts codegen/generators/.ts` showing only upstream changes. Confirmed, it is the same finding. Not confirmed, report it as **possibly customized: check its history**, never as a copy to drop. -- A copy with lines of its own is an intentional customization. Check only how far behind - it is. +- A copy with lines of its own (`M > 0`) is **not** thereby an intentional customization. The + listing prints `DIFFERS` for both a deliberate customization and a copy gone stale by + version, and `M line(s) of your own` counts every line absent from today's reference: after + the reference moved on, a line it removed or rewrote reads as "yours" and is an older + upstream line. Run the same history check on any `DIFFERS` row, `M > 0` included + (`git log -p -- codegen/generators/.ts`): lines the copy's later commits added are the + project's own; lines already in the eject commit are upstream's, and count as behind. Report + ` lines behind the reference` and ` lines of the project's own (confirmed from + history)` as two figures. A copy whose own lines history does not confirm is **possibly + customized or stale: check its history**; a copy that is stale by version is a finding + (`owned-generator-stale`): re-eject at the current version or return to the package import. Count lines for the Phase 0 total: lines in copies proven unmodified, with the `codegen/runtime/` files they reach, over all lines the project owns under `codegen/`