You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
docs(audit-skill): drift signature 11 was scoped to SQL; the constants surface is wider
Signature 11 hunted "a physical table / column / schema name spelled as a literal"
and nothing else, so it found the names-artifact half of the defect and missed the
rest of what the emitters already generate. On TypeScript the entity module and its
browser-safe `.meta.ts` twin carry `$entity` / `$table` / `$path` / `$apiPrefix` plus
a per-field object (name, label, view, htmlType, rules), and that file's own docblock
tells the reader to use them INSTEAD of magic strings. An audit scoped to SQL cannot
see a hand-typed API path or a re-typed label, and an auditor who reads only the
signature concludes the estate is clean.
Split into (i) physical names, every port, and (ii) the rest of the constants
surface, TypeScript today — the other four ports answer (ii) with a generated typed
handle, which is already the gold standard.
The calibration is the load-bearing part, and each carve-out below is measured rather
than reasoned:
- The test is whether anything TYPE-CHECKS the literal, not whether it looks like a
name. An estate carries 332 bare enum-member strings (`status === "edited_approved"`);
every one is checked against the generated `export type <Entity><Field>` union, so
renaming the member breaks the build at every site instead of drifting silently.
Flagging them would have produced 332 findings that are all false. Enum members,
typed filter objects and ORM handles are checked; paths, labels and messages are not.
- A client-side page route is not an API path. On one estate all 11 apparent `$path`
hits were `<a href>` / `<Route path>` navigation URLs the app chose, declared nowhere
in the metadata; the true count was 0. Match the fetch or the mount, never the
string's shape — converting one couples the site's URL structure to the API's.
- A fact declared in codegen CONFIG rather than metadata (a bespoke mount path, a
hand-chosen envelope key) has no constant to reference. That is a generator gap,
reported as one; never invent a constant the emitters do not emit.
Also corrects a stale comment in entity-constants.ts that sourced the label from
`@label`, which no provider registers on any view subtype — #353 moved it to `@title`
on the surface's view, then `@title` on the field, then the humanized field name.
Skill and reference files only; no emitter behavior changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTcEKXTQMYt84fAjuw5A2M
Copy file name to clipboardExpand all lines: agent-context/skills/metaobjects-audit/SKILL.md
+9-1Lines changed: 9 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -341,7 +341,15 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend
341
341
- **Not expressible → carry it in `@sql` or `@unmanaged`, never a hand-edited migration (#208, ADR-0043).** When a NAMED irreducible construct blocks origin authoring — recursive CTE, window function / `OVER`, `UNION` / `INTERSECT` / `EXCEPT`, lateral join — the body still belongs in the metadata: carry the hand-written SQL in the `source.rdb` **`@sql`** escape — a read-only-`@kind` body the tool REGISTERS, fingerprints, and drift-checks (adopt a pre-existing view with `meta migrate --allow adopt-view`); `@sql` forbids `origin.*` children (two sources of truth). A DB object whose DDL is owned **entirely elsewhere** (Flyway / a hand-migration) → mark its source **`@unmanaged: true`** (legal on any `@kind` incl. `table`); `meta migrate` never creates/drops/drift-checks it and `verify --db` reports it as external. `@sql` and `@unmanaged` are mutually exclusive. **Only a view left *undeclared*** — neither modeled, nor `@sql`, nor `@unmanaged` — is truly *unmanaged*, invisible to `meta verify --db`, so this audit is the only gate that sees it. "It's an aggregation" is NOT an irreducibility justification (plain count/sum/avg/min/max rollups are `origin.aggregate`); nor is a `DISTINCT ON` pick-one-row (`origin.first`) or a non-aggregate expression column (`origin.computed`).
342
342
9.**A closed variant-set hand-modeled per instance** — N sibling modules / classes / config blocks, one per channel / provider / target, sharing a payload + config shape and diverging only by transport. Grep for sibling-file families and switch-on-a-string dispatch; verify the set is closed and recurring (never a one-off). → axis I "New-vocabulary OPPORTUNITY" (VOCAB CANDIDATE, advisory).
343
343
10.**N declarations of one FIELD across objects (same-name-field census)** — the field-level sibling of signature 6. Census field names across `object.*` nodes (`grep -rn 'name: <field>'` the metadata dir); a name recurring in ≥2 objects where a canonical owner exists — one whose name the field embeds (`<owner><Field>`: `wizardId` → `Wizard.id`, `orderTotal` → `Order.total`) or whose type+constraints it matches — is provenance loss → `extends: Owner.field` (dotted child targets, ADR-0029). **VERIFY by diffing the copies' attrs: a `@maxLength` / `@required` / validator divergence across them is drift already shipping — cite it.** Evidence multiplier: `extends` already used elsewhere in the repo raises confidence. Do NOT flag: generic names on unrelated concepts (`id` / `name` / `status` with no owner-embedding name and no matching constraints); required per-node attrs a loader forces (e.g. `payloadRef` / `format` on sibling `template.prompt` nodes — a product constraint, not a copy).
344
-
11. **A physical table / column / schema name spelled as a literal outside its metadata declaration** — in a hand-written repository, raw SQL, a migration script, a log line, or a hand-maintained body-to-column map. Every port emits a per-object names artifact (`<Entity>Names` / `<entity_snake>_names.py`) from the declaration, so a literal is a second spelling of the same fact; and because the physical column is free-form, deriving it from the field name is a guess that fails silently. **No verify subverb sees this** — `--codegen` diffs generated files, `--db` compares schema to metadata — so this audit is the only gate. Remedy: reference the constant — but **check the artifact is emitted at all before scoring the literals, because on THREE of five ports an existing project emits none**. C# and Python have a real default suite and get it by upgrading; TypeScript's `generators: [...]` and the JVM's `<generators>` are each the COMPLETE list, so `meta init` scaffolding `namesFile()` covers only a project initialized at 1.0 and upgrading the package never edits a config written earlier. Where no artifact exists, the un-wired generator is the FIRST finding and the first remedy (`namesFile()` on TS after `meta eject names`; `SpringNamesGenerator` / `KotlinNamesGenerator` in the pom) — score the literals under it rather than as N independent findings, since one config line fixes the cause and generated code stops embedding the names too. **Do NOT flag:** a typed ORM handle in its place (a Drizzle column object, an Exposed `Column`, an EF property — replacing one with a string is a regression); a physical name inside a `source.rdb` hand-written SQL body (a metadata document cannot import a constant, which is why that body is fingerprinted); a flattened value-object composite column (it belongs to no single field of either object); a write-through entity's replica view name (the artifact holds the PRIMARY source's); a relationship-synthesized foreign-key column (derived, never declared). A hand-written repository that imports `<Entity>Names` is the gold standard — call it out as good.
344
+
11.**A metadata-derived string spelled a second time by hand** — the physical name case is the commonest, not the only one, and hunting only that one under-reports the signature. **Do not scope this search to SQL.** Two distinct generated surfaces answer it, and a project can be missing either:
345
+
346
+
**(i) Physical names — every port.** A table / view / stored-proc / column / schema name spelled as a literal outside its metadata declaration: in a hand-written repository, raw SQL, a migration script, a log line, or a hand-maintained body-to-column map. The per-object names artifact (`<Entity>.names.ts` / `<entity_snake>_names.py` / `<Entity>Names`) carries these on all five ports. Every port emits a per-object names artifact (`<Entity>Names` / `<entity_snake>_names.py`) from the declaration, so a literal is a second spelling of the same fact; and because the physical column is free-form, deriving it from the field name is a guess that fails silently.
347
+
348
+
**(ii) The rest of the constants surface — TypeScript today.** The generated entity module and its browser-safe `<Entity>.meta.ts` twin carry `$entity` (the logical name), `$table`, **`$path` (the resource route path)**, `$apiPrefix`, and a per-field object keyed by field name carrying `name`, `label`, `view`, `htmlType`, the RHF-shaped `rules` derived from validator children, and a `field.enum`'s member symbols. The generated file's own docblock says to use them *instead of magic strings*. **The test is whether anything TYPE-CHECKS the literal, not whether it looks like a name.** Score a site only where nothing does: a hand-written **API resource path** (`fetch("/api/subscribers")`, `app.get("/subscribers")`), a **form label** re-typed in hand JSX, a **re-typed validation limit or message**, or a **field name passed as loose text** to something that takes a bare `string`. Where the generated artifact is a union type or a typed handle, the compiler is already the gate and the literal is CORRECT — measured: an estate's 332 bare enum-member strings (`status === "edited_approved"`) are each checked against the generated `export type <Entity><Field> = "a" | "b"` union, so renaming the member in metadata breaks the build at every site rather than drifting silently. That is the opposite of the physical-name case, and flagging it would have produced 332 findings that are all false. Enum members, typed filter objects and ORM handles are checked; paths, labels and messages are not. On the other four ports the equivalent of (ii) is the generated typed handle (a Pydantic model attribute, an EF property, an Exposed `Column`) — already the gold standard, see the do-not-flag list.
349
+
350
+
**Score only where a constant actually exists.** A fact declared in the project's CODEGEN CONFIG rather than in metadata — a bespoke route mount path, a hand-chosen JSON envelope key — is genuinely spelled twice when a client re-types it, but no generated constant holds it. That is a GENERATOR GAP, reported as one, never a literal-site finding: never invent a constant that the emitters do not emit.
351
+
352
+
**No verify subverb sees this** — `--codegen` diffs generated files, `--db` compares schema to metadata — so this audit is the only gate. Remedy: reference the constant — but **check the artifact is emitted at all before scoring the literals, because on THREE of five ports an existing project emits none**. C# and Python have a real default suite and get it by upgrading; TypeScript's `generators: [...]` and the JVM's `<generators>` are each the COMPLETE list, so `meta init` scaffolding `namesFile()` covers only a project initialized at 1.0 and upgrading the package never edits a config written earlier. Where no artifact exists, the un-wired generator is the FIRST finding and the first remedy (`namesFile()` on TS after `meta eject names`; `SpringNamesGenerator` / `KotlinNamesGenerator` in the pom) — score the literals under it rather than as N independent findings, since one config line fixes the cause and generated code stops embedding the names too. **Do NOT flag:** a typed ORM handle in its place (a Drizzle column object, an Exposed `Column`, an EF property — replacing one with a string is a regression); a physical name inside a `source.rdb` hand-written SQL body (a metadata document cannot import a constant, which is why that body is fingerprinted); a flattened value-object composite column (it belongs to no single field of either object); a write-through entity's replica view name (the artifact holds the PRIMARY source's); a relationship-synthesized foreign-key column (derived, never declared). A hand-written repository that imports `<Entity>Names` is the gold standard — call it out as good. And for (ii) specifically: a TYPED property access (`row.email`, `form.input.email`) is already the correct spelling — rewriting it to `Subscriber.email.name` swaps a compiler-checked reference for a string lookup and is a regression, the same rule as the ORM handle above. The (ii) finding is only for a string LITERAL that has to equal a metadata-derived value and that NO type checks: an API fetch URL or server route mount, a field name passed as loose text, a re-typed label, a re-typed validation limit or message. An enum member is NOT one — it is checked by the generated union type. And a CLIENT-SIDE PAGE ROUTE is not an API path: `<Route path="/programs">` or `<a href="/programs">` is a navigation URL the app author chose, declared nowhere in the metadata, and free to diverge from the resource path it happens to resemble — measured on an estate where all 11 apparent `$path` hits were page links and the true count was 0. Converting one couples the site's URL structure to the API's and is a regression. Match on the FETCH or the MOUNT, never on the string's shape.
345
353
346
354
**Report TWO numbers for this signature, always, and never just the first: (a) is the artifact EMITTED, and (b) how many of the literal sites actually IMPORT it — the adoption ratio, as `<adopted>/<total> sites`.** Emission is the cheap half and it is the half that gets done: measured on an adopter whose audit proved the artifact emitted correctly, ran the generator to show it, and then adopted it at **0 of 53 sites** (37 table + 16 column). Wiring the generator changes the scorecard; it changes nothing a reader of the code experiences, because the second spelling is still the one every query uses. An audit that reports only (a) makes the estate look upgraded while every literal it found is still there — so a project that emits the artifact and imports it nowhere scores WORSE than one that has not started, not better, because it now carries a third spelling that claims to be the source of truth.
Copy file name to clipboardExpand all lines: agent-context/skills/metaobjects-audit/references/typescript.md
+5Lines changed: 5 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -55,6 +55,11 @@ value-sets against the constructs that replace them.
55
55
| camelCase↔snake_case mapping tables | generated views handle this; diff field by field |
56
56
|`@generated` file with hand edits | three-way merge preserves them; review at codegen; never delete |
57
57
| a table/column string in a `sql` fragment, a Kysely identifier, or a migration/seed script outside the generated dir | second spelling of a declared physical name — reference `<Entity>.names.ts` (`ProgramNames.fields.<f>.column`): `generators: [...]` is the COMPLETE list and there is no default suite, so a project scaffolded before 1.0 emits NO names artifact and the missing `namesFile()` is the finding first; a Drizzle column object in its place is correct, not a finding |
58
+
| an API resource path typed as a string — `fetch("/api/subscribers")`, `app.get("/subscribers")`, a TanStack `queryKey` path | second spelling of the generated `$path` / `$apiPrefix` on the entity module and its `<Entity>.meta.ts` twin — signature 11 half (ii). Most-missed row in this table, because the names hunt gets scoped to SQL |
59
+
|**NOT a finding:** a client-side page route or link (`<Route path="/programs">`, `<a href="/programs">`) | a navigation URL the app chose, declared nowhere in metadata and free to diverge from the API path it resembles. Measured on one estate: 11 apparent hits, 0 real. Match the fetch or the mount, not the string's shape |
60
+
| a field name passed as TEXT — `sort: "created_at"`, `filter["email"]`, a column-id string in a grid config | the per-field constants object carries `name`; a typed property access is NOT this row and must be left alone |
61
+
|**NOT a finding:** an enum member as a bare string (`status === "archived"`) | the generated `export type <Entity><Field> = "a" \| "b"` union already checks it — renaming the member in metadata breaks the build at every site. Measured on one estate: 332 such literals, 0 real findings. Emitted `options: [...]` on `.meta.ts` is for populating a dropdown, not for replacing checked literals |
62
+
| 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 |
0 commit comments