@metaobjectsdev/metadata 0.24.5 → 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/attr-schema-validate.js +15 -3
  2. package/dist/attr-schema-validate.js.map +1 -1
  3. package/dist/core/attr/attr-definition.embedded.js +1 -1
  4. package/dist/core/attr/attr-definition.embedded.js.map +1 -1
  5. package/dist/core/field/field-definition.embedded.js +1 -1
  6. package/dist/core/field/field-definition.embedded.js.map +1 -1
  7. package/dist/core/object/meta-object.d.ts +2 -0
  8. package/dist/core/object/meta-object.d.ts.map +1 -1
  9. package/dist/core/object/meta-object.js +20 -0
  10. package/dist/core/object/meta-object.js.map +1 -1
  11. package/dist/core/object/object-definition.embedded.js +1 -1
  12. package/dist/core/object/object-definition.embedded.js.map +1 -1
  13. package/dist/core/relationship/relationship-definition.embedded.js +1 -1
  14. package/dist/core/relationship/relationship-definition.embedded.js.map +1 -1
  15. package/dist/core/validator/validator-definition.embedded.js +1 -1
  16. package/dist/core/validator/validator-definition.embedded.js.map +1 -1
  17. package/dist/errors.d.ts +1 -1
  18. package/dist/errors.d.ts.map +1 -1
  19. package/dist/errors.js +11 -1
  20. package/dist/errors.js.map +1 -1
  21. package/dist/index.d.ts +4 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +6 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/naming.d.ts +107 -0
  26. package/dist/naming.d.ts.map +1 -1
  27. package/dist/naming.js +155 -14
  28. package/dist/naming.js.map +1 -1
  29. package/dist/parser-core.d.ts.map +1 -1
  30. package/dist/parser-core.js +130 -11
  31. package/dist/parser-core.js.map +1 -1
  32. package/dist/persistence/origin/origin-definition.embedded.js +1 -1
  33. package/dist/persistence/origin/origin-definition.embedded.js.map +1 -1
  34. package/dist/persistence/source/source-definition.embedded.js +1 -1
  35. package/dist/persistence/source/source-definition.embedded.js.map +1 -1
  36. package/dist/presentation/layout/layout-definition.embedded.js +1 -1
  37. package/dist/presentation/layout/layout-definition.embedded.js.map +1 -1
  38. package/dist/presentation/view/view-definition.embedded.js +1 -1
  39. package/dist/presentation/view/view-definition.embedded.js.map +1 -1
  40. package/dist/registry-coverage.d.ts +0 -6
  41. package/dist/registry-coverage.d.ts.map +1 -1
  42. package/dist/registry-coverage.js +25 -1
  43. package/dist/registry-coverage.js.map +1 -1
  44. package/dist/registry-manifest.d.ts +13 -6
  45. package/dist/registry-manifest.d.ts.map +1 -1
  46. package/dist/registry-manifest.js +6 -5
  47. package/dist/registry-manifest.js.map +1 -1
  48. package/dist/retired-vocabulary.d.ts +23 -0
  49. package/dist/retired-vocabulary.d.ts.map +1 -1
  50. package/dist/retired-vocabulary.js +99 -1
  51. package/dist/retired-vocabulary.js.map +1 -1
  52. package/dist/shared/meta-data.d.ts.map +1 -1
  53. package/dist/shared/meta-data.js +23 -1
  54. package/dist/shared/meta-data.js.map +1 -1
  55. package/dist/template/template-definition.embedded.js +1 -1
  56. package/dist/template/template-definition.embedded.js.map +1 -1
  57. package/dist/vocabulary-catalog.d.ts +49 -0
  58. package/dist/vocabulary-catalog.d.ts.map +1 -0
  59. package/dist/vocabulary-catalog.js +80 -0
  60. package/dist/vocabulary-catalog.js.map +1 -0
  61. package/package.json +1 -1
  62. package/src/attr-schema-validate.ts +15 -3
  63. package/src/core/attr/attr-definition.embedded.ts +1 -1
  64. package/src/core/field/field-definition.embedded.ts +1 -1
  65. package/src/core/object/meta-object.ts +22 -0
  66. package/src/core/object/object-definition.embedded.ts +1 -1
  67. package/src/core/relationship/relationship-definition.embedded.ts +1 -1
  68. package/src/core/validator/validator-definition.embedded.ts +1 -1
  69. package/src/errors.ts +11 -1
  70. package/src/index.ts +8 -1
  71. package/src/naming.ts +164 -18
  72. package/src/parser-core.ts +142 -12
  73. package/src/persistence/origin/origin-definition.embedded.ts +1 -1
  74. package/src/persistence/source/source-definition.embedded.ts +1 -1
  75. package/src/presentation/layout/layout-definition.embedded.ts +1 -1
  76. package/src/presentation/view/view-definition.embedded.ts +1 -1
  77. package/src/registry-coverage.ts +25 -3
  78. package/src/registry-manifest.ts +9 -8
  79. package/src/retired-vocabulary.ts +107 -1
  80. package/src/shared/meta-data.ts +23 -3
  81. package/src/template/template-definition.embedded.ts +1 -1
  82. package/src/vocabulary-catalog.ts +133 -0
package/src/errors.ts CHANGED
@@ -6,7 +6,6 @@ import type { ErrorSource, LoaderError, NodeContext } from "./source.js";
6
6
  // NOTE: The following codes are forward-declared (no emitting site in the current
7
7
  // TS parser/loader — the condition is not yet detected):
8
8
  // - ERR_DUPLICATE_NAME: parser silently reuses existing same-name nodes (find-or-create).
9
- // - ERR_MISSING_SUBTYPE: missing subType is resolved to the registry default, never an error.
10
9
  // - ERR_INVALID_SUBTYPE_CHILD: no child-rule validation pass exists yet.
11
10
  // Cross-language conformance consumers should not expect these codes from the TS adapter.
12
11
  //
@@ -20,6 +19,17 @@ export const ERROR_CODES = [
20
19
  "ERR_TOP_LEVEL_NOT_OBJECT",
21
20
  "ERR_UNKNOWN_TYPE",
22
21
  "ERR_UNKNOWN_SUBTYPE",
22
+ // A document AUTHORS a `<type>.base` node. Every registered `base` subtype is an
23
+ // abstract registry anchor — the shared root concrete subtypes inherit from, with no
24
+ // runtime semantics of its own (spec/metamodel/object.json says so in as many words:
25
+ // "not authored directly"). The JVM enforced this by accident, because its impl
26
+ // classes are `public abstract`; TypeScript, C# and Python accepted it, so the same
27
+ // document loaded on three ports and failed on two.
28
+ //
29
+ // Scoped to an EXPLICITLY authored subtype. `base` is also the parser's own fallback
30
+ // for an OMITTED subtype whose registry default is unregistered, and that path is
31
+ // untouched — refusing it would break every node that relies on the default.
32
+ "ERR_ABSTRACT_SUBTYPE_AUTHORED",
23
33
  "ERR_MISSING_SUBTYPE",
24
34
  "ERR_DUPLICATE_NAME",
25
35
  "ERR_UNRESOLVED_SUPER",
package/src/index.ts CHANGED
@@ -178,6 +178,11 @@ export { validateConstraints } from "./constraint-validate.js";
178
178
 
179
179
  // Registry conformance manifest (SP-G) — the canonical logical-vocabulary serializer.
180
180
  export { buildRegistryManifest, emitRegistryManifest, classifyPerTypeAttr, METAMODEL_VERSION } from "./registry-manifest.js";
181
+ // #357 — the AUTHORING-facing twin of buildRegistryManifest: every registered
182
+ // (type, subType) this port accepts, with the cross-port carve-outs MARKED rather
183
+ // than dropped. `meta types` reads this; the five-port byte gate reads the manifest.
184
+ export { buildVocabularyCatalog } from "./vocabulary-catalog.js";
185
+ export type { VocabularyCatalog, VocabularyType } from "./vocabulary-catalog.js";
181
186
  export type { AttrClassification } from "./registry-manifest.js";
182
187
  export { ExclusionReason } from "./registry-manifest-exclusions.js";
183
188
 
@@ -282,7 +287,8 @@ export type { AttrSchemaValidationResult } from "./attr-schema-validate.js";
282
287
  export {
283
288
  toSnakeCase, toKebabCase, pluralize,
284
289
  applyColumnNamingStrategy, DEFAULT_COLUMN_NAMING_STRATEGY,
285
- resolveTableName, resolveColumnName, resolveTableSchema,
290
+ resolveTableName, resolveColumnName, resolveTableSchema, resolveIndexName,
291
+ primaryRdbSource, sourceAddressKey,
286
292
  buildNameMap,
287
293
  stripPackage,
288
294
  } from "./naming.js";
@@ -297,6 +303,7 @@ export {
297
303
  retiredAttrValue,
298
304
  retiredSubType,
299
305
  retirementHint,
306
+ retirementSuggestions,
300
307
  } from "./retired-vocabulary.js";
301
308
  export type { RetirementNote, RetiredEntry, VocabularyRewrite } from "./retired-vocabulary.js";
302
309
  // Its sibling: pairs of LIVE attributes that may not sit on one node. Same two consumers,
package/src/naming.ts CHANGED
@@ -7,13 +7,15 @@ import {
7
7
  SOURCE_ROLE_PRIMARY,
8
8
  } from "./persistence/source/source-constants.js";
9
9
  import type { MetaSource } from "./persistence/source/meta-source.js";
10
- // isMetaSource, not `instanceof`: unlike the loader-internal validators, these two
11
- // are EXPORTED helpers that run on a caller-supplied node — migrate-ts calls both
12
- // on nodes the CLI's loader built. Under a split @metaobjectsdev/metadata tree the
13
- // class check would be false for a real primary source, and resolveTableName would
14
- // silently fall through to the entity-name fallback: a DIFFERENT table name, which
15
- // migrate then emits as a rename against a live database.
10
+ // isMetaSource, not `instanceof`: unlike the loader-internal validators, the source
11
+ // lookups below are EXPORTED helpers that run on a caller-supplied node — migrate-ts
12
+ // calls them on nodes the CLI's loader built. Under a split @metaobjectsdev/metadata
13
+ // tree the class check would be false for a real primary source, and resolveTableName
14
+ // would silently fall through to the entity-name fallback: a DIFFERENT table name,
15
+ // which migrate then emits as a rename against a live database. (The same split would
16
+ // also make the divergence refusal see no primaries at all, and say nothing.)
16
17
  import { isMetaSource } from "./shared/node-guards.js";
18
+ import { MetaModelError } from "./errors.js";
17
19
 
18
20
  /**
19
21
  * Strip the package prefix from a metadata-qualified name
@@ -66,21 +68,109 @@ export function pluralize(s: string): string {
66
68
  return s + "s";
67
69
  }
68
70
 
71
+ /**
72
+ * THE `@role: primary` source lookup for this package — and the one place the
73
+ * primary-source DIVERGENCE refusal lives, so that every caller inherits it.
74
+ *
75
+ * Effective children (own + inherited via the super chain) so a TPH SUBTYPE —
76
+ * which declares no source of its own and inherits the discriminator base's
77
+ * single table (FR-017) — resolves to that base source rather than to nothing.
78
+ * For an entity declaring its own source, own shadows inherited, so the result
79
+ * is unchanged. Returns `undefined` when `entity` declares no primary source at
80
+ * all: #248, participation in persistence derives from a declared source, never
81
+ * from the object subtype, so an `object.value` and a sourceless
82
+ * `object.projection` both land here rather than being special-cased.
83
+ *
84
+ * `isMetaSource`, not `instanceof`: under two physical copies of this package a
85
+ * class check is false for a real primary source, and the failure is SILENT —
86
+ * the object reads as "not backed by any store".
87
+ *
88
+ * ## The refusal
89
+ *
90
+ * An object whose `@role: primary` sources resolve to MORE THAN ONE physical
91
+ * ADDRESS has no single answer to give, so this throws rather than picking one.
92
+ * The address is `(kind, schema, physical name)` — see `sourceAddressKey`. It was
93
+ * once the bare physical name, which was a WEAKER key than any consumer's: migrate
94
+ * keys every table it compares by schema-or-default + name, Postgres DDL is
95
+ * `CREATE TABLE "s"."t"`, and since 0.25.0 generated code binds the schema half too
96
+ * (`pgSchema(...).table(...)`, `[Table(..., Schema = ...)]`). Two primaries agreeing
97
+ * on `@table` and disagreeing on `@schema` therefore passed here and were refused by
98
+ * the names artifact, so `meta gen` failed on a model every other door admitted.
99
+ *
100
+ * What decided it was not the asymmetry but WHICH source the weaker key returned:
101
+ * `primaries[0]` is the INHERITED source in TS/C#/Python (`children()` puts the
102
+ * super's entries first) and the OWN source on the JVM, so such a model silently
103
+ * bound one schema in three ports and the other in two — one document, two verdicts
104
+ * depending on which toolchain read it.
105
+ * The shape loads with ZERO errors: `validateSourceRoles` enforces "exactly one
106
+ * primary" over `ownChildren()` only, and `_effectiveChildren` shadows an own
107
+ * child over a super child only on a (type, name) match — so two `source.rdb`
108
+ * nodes with DIFFERENT explicit names at two levels of an `extends` chain never
109
+ * collide, and both land on the child's effective `children()`.
110
+ *
111
+ * It lives HERE rather than in any one consumer because every consumer binds
112
+ * ONE name unconditionally. It used to live only in codegen-ts's
113
+ * `resolveObjectNames`, which runs only when the `names` generator is in the
114
+ * run — so with `namesFile()` unwired `meta migrate` emitted DDL against the
115
+ * PARENT's table (via `resolveTableName` → `buildExpectedSchema`) and
116
+ * `ObjectManager` read and wrote it, silently, on every run. A refusal that
117
+ * depends on which consumer asked is not a refusal. Mirrors Python's
118
+ * `source_resolution.primary_rdb_source`, whose codegen, api-docs and runtime
119
+ * callers all inherit it for free.
120
+ *
121
+ * DIRECTION-BLIND: it compares every primary against every other, so it does
122
+ * not matter which of them is writable nor which was declared first. Comparing
123
+ * against the first primary WRITABLE source can only see a divergence when one
124
+ * of the two is read-only — and, since `children()` places inherited entries
125
+ * first, only when the read-only one is the inherited one.
126
+ *
127
+ * Two primaries AGREEING on an address is not a divergence and stays legal: the
128
+ * invariant is that an object has ONE address, not that it declares one source.
129
+ * A read-only primary beside a non-primary REPLICA does not reach it either — a
130
+ * replica is not `role === "primary"`.
131
+ */
132
+ export function sourceAddressKey(source: MetaSource): string {
133
+ // The RESOLVED address, and the same three parts the names artifact compares — so the
134
+ // shared authority and the generated artifact cannot answer "is this one object?"
135
+ // differently. Rendered rather than structural because every port has to produce a
136
+ // byte-identical message from it.
137
+ //
138
+ // RAW, deliberately: an absent `@schema` is NOT folded into a dialect default. On
139
+ // Postgres absent and "public" address the same relation, but on SQLite/D1 they do
140
+ // not — `migrate-ts`'s expected-schema builder throws on ANY declared schema, "public"
141
+ // included, while an absent one is fine. `resolveTableSchema` already documents that
142
+ // deciding what "undefined" means belongs to the caller's dialect, not to this layer,
143
+ // so normalizing here would import one dialect's rule into a dialect-free tier.
144
+ const schema = source.attr(SOURCE_ATTR_SCHEMA);
145
+ const qualifier = typeof schema === "string" && schema !== "" ? `"${schema}".` : "";
146
+ return `${qualifier}"${source.physicalName}" (${source.effectiveKind})`;
147
+ }
148
+
149
+ export function primaryRdbSource(entity: MetaData): MetaSource | undefined {
150
+ const primaries = entity.children().filter(
151
+ (c): c is MetaSource => isMetaSource(c) && c.role === SOURCE_ROLE_PRIMARY,
152
+ );
153
+ if (primaries.length === 0) return undefined;
154
+ const distinct = [...new Set(primaries.map(sourceAddressKey))].sort();
155
+ if (distinct.length > 1) {
156
+ // Sorted, so the message is identical in every port regardless of children() order.
157
+ const joined = distinct.join(" vs ");
158
+ throw new MetaModelError(
159
+ `${entity.name}: role=primary sources disagree on the object's physical address — ` +
160
+ `${joined}. Every consumer binds ONE address. Give them a matching @kind, @schema ` +
161
+ `and physical name, or drop the extra role=primary declaration.`,
162
+ );
163
+ }
164
+ return primaries[0];
165
+ }
166
+
69
167
  export function resolveTableName(entity: MetaData): string {
70
168
  // FR-016: primary source's `physicalName` implements the four-step rule
71
169
  // (kind-matching alias → legacy @table → source.name → entity-name fallback),
72
170
  // so this helper now just delegates. Writability (table vs view/storedProc/
73
171
  // tableFunction) only affects write-routing — for SELECT-side name resolution,
74
172
  // a read-only primary source is the right answer.
75
- //
76
- // Effective children (own + inherited via the super chain) so a TPH SUBTYPE
77
- // — which declares no source of its own and inherits the discriminator base's
78
- // single table (FR-017) — resolves to that base table rather than the
79
- // entity-name fallback. For an entity declaring its own source, own shadows
80
- // inherited, so the result is unchanged.
81
- const source = entity.children().find(
82
- (c): c is MetaSource => isMetaSource(c) && c.role === SOURCE_ROLE_PRIMARY,
83
- );
173
+ const source = primaryRdbSource(entity);
84
174
  if (source !== undefined) return source.physicalName;
85
175
  return pluralize(toSnakeCase(entity.name));
86
176
  }
@@ -95,6 +185,61 @@ export function resolveColumnName(
95
185
  return applyColumnNamingStrategy(field.name, strategy);
96
186
  }
97
187
 
188
+ /**
189
+ * An index's DATABASE name — for an `identity.secondary` (a unique alternate key) or an
190
+ * `index.lookup` (a non-unique retrieval index).
191
+ *
192
+ * These nodes carry no `@column`-style physical spelling: the database name IS the
193
+ * metamodel `name`. That is precisely why the answer must live in a function rather than
194
+ * at each call site. It was spelled independently in three places — the Drizzle emitter,
195
+ * migrate's expected-schema (twice) and the Kotlin Exposed emitter — and agreed only by
196
+ * coincidence; `fdb4118f1` is what that coincidence lapsing looks like, with codegen
197
+ * declaring `idx_<table>_<col>` while the index in the database was `identity.name`.
198
+ *
199
+ * Two rules the single door now owns, neither of which any call site had:
200
+ *
201
+ * - **Package qualifier stripped — and the reason first given for this was WRONG.** It was
202
+ * justified here as compensating for the JVM loader package-qualifying a nested index
203
+ * name (`acme::demo::by_name`), which `KotlinExposedTableGenerator` mirrored with
204
+ * `shortName ?: name`. Measured against the real JVM loader, that does not happen: a
205
+ * nested `identity.secondary` or `index.lookup` is named flat, including when inherited
206
+ * across packages via `extends`. Only a ROOT-level node takes the file's package, and an
207
+ * unnamed `view` child gets a synthesised FQN — the likeliest source of the belief. The
208
+ * JVM's local strip had been a no-op for its whole life while reading as the site that
209
+ * owned the rule. Pinned now on that side by `IndexNamingTest`.
210
+ *
211
+ * The strip stays, on the honest reason rather than the invented one: it is a no-op on
212
+ * every name either loader produces, which is what a rule that holds without a per-port
213
+ * branch looks like, and it costs one `lastIndexOf`. A normalisation nobody has to
214
+ * remember is worth more than a claim nobody checked.
215
+ * - **An empty name is REFUSED**, and the gap it closes is exactly one node type wide.
216
+ * An `identity.secondary` with an empty name is already refused by the LOADER in strict
217
+ * and lax mode alike (identity nodes carry an FR-024 name check so a dotted `extends`
218
+ * ref can address them). An `index.lookup` is not addressable that way and carries no
219
+ * such check, so `{"index.lookup": {"name": ""}}` loads with zero errors in both modes
220
+ * and reaches the emitters, which produce `index("")`: SQL no engine accepts, from a
221
+ * model that passed every gate. Refusing at the shared door closes it for codegen and
222
+ * migrate at once, without touching the byte-gated registry `rules` prose a loader-side
223
+ * fix would need. Measured, not assumed — `resolve-index-name.test.ts` asserts both
224
+ * arms, because "the loader already handles it" is the belief that would delete this.
225
+ */
226
+ export function resolveIndexName(
227
+ // The narrow structural shape rather than `MetaData`: this reads three properties, and
228
+ // every caller that has a real node satisfies it, while the Drizzle emitter's local
229
+ // duck-typed index node does not need a cast to pass one. A cast here would be the
230
+ // usual way a `never` slips past the compiler into a runtime property read.
231
+ node: { readonly name: string; readonly type: string; readonly subType: string },
232
+ ): string {
233
+ const short = stripPackage(node.name);
234
+ if (short === "") {
235
+ throw new MetaModelError(
236
+ `${node.type}.${node.subType} declares an empty name; an index's database name IS ` +
237
+ `its metamodel name, so there is nothing to emit. Give it a name.`,
238
+ );
239
+ }
240
+ return short;
241
+ }
242
+
98
243
  /**
99
244
  * Returns the DB schema declared on an entity's primary source child, or undefined
100
245
  * when no @schema attr is set or no source child exists. @schema is paradigm-agnostic
@@ -104,9 +249,10 @@ export function resolveColumnName(
104
249
  */
105
250
  export function resolveTableSchema(entity: MetaData): string | undefined {
106
251
  // ADR-0039: resolving — a concrete entity may inherit its source.rdb via extends.
107
- const source = entity.children().find(
108
- (c): c is MetaSource => isMetaSource(c) && c.role === SOURCE_ROLE_PRIMARY,
109
- );
252
+ // primaryRdbSource, not a second hand-rolled scan: a lookup written twice is a
253
+ // lookup that can disagree with itself, and only one of the two copies would
254
+ // carry the divergence refusal.
255
+ const source = primaryRdbSource(entity);
110
256
  if (!source) return undefined;
111
257
  // ADR-0039: resolving — an inherited source's @schema lives on the super node.
112
258
  const schema = source.attr(SOURCE_ATTR_SCHEMA);
@@ -30,7 +30,7 @@ import { MetaAttr } from "./core/attr/meta-attr.js";
30
30
  import { canonicalSerialize, inferAttrSubType } from "./serializer-json.js";
31
31
  import { ParseError, type ErrorCode } from "./errors.js";
32
32
  // #337 — see retired-vocabulary.ts. Diagnostic only; no load outcome changes.
33
- import { retiredSubType, retirementHint } from "./retired-vocabulary.js";
33
+ import { retiredSubType, retirementHint, retirementSuggestions } from "./retired-vocabulary.js";
34
34
  import { resolvedSource, type ErrorSource, type LoaderWarning, type Contributor } from "./source.js";
35
35
  import { semanticDiff } from "./semantic-diff.js";
36
36
  import {
@@ -206,6 +206,64 @@ function guardRelativeRefInCanonical(
206
206
  }
207
207
 
208
208
  // ---------------------------------------------------------------------------
209
+ // The one message for an authored `<type>.base`, so both doors say the same thing.
210
+ //
211
+ // Every registered `base` subtype is an ABSTRACT REGISTRY ANCHOR: the shared root that
212
+ // concrete subtypes inherit their attrs and child rules from. It has no runtime semantics
213
+ // and no concrete representation — `spec/metamodel/object.json` says so in as many words
214
+ // ("Has no runtime semantics of its own; not authored directly"), and every `base` entry's
215
+ // description opens with "Abstract".
216
+ //
217
+ // The JVM enforced this by accident (its impl classes are `public abstract`, so
218
+ // instantiation fails); TypeScript, C# and Python accepted it. The same document therefore
219
+ // loaded on three ports and failed to load on two — the cross-port conformance gap the
220
+ // corpora exist to catch, and it survived because every `*.base` subtype sits in the
221
+ // registry corpus's own `untestedSubTypes` list.
222
+ /**
223
+ * Is `<type>.base` an ABSTRACT ANCHOR for this type — i.e. does the type register at least one
224
+ * OTHER subtype for it to anchor?
225
+ *
226
+ * Registry-driven, not name-driven, and the distinction is load-bearing. All ten core anchors
227
+ * have concrete siblings (`object.base` beside entity/value/projection, `source.base` beside
228
+ * rdb, …), so the rule catches every one. A third-party provider may register `base` as a
229
+ * type's ONLY member — the SDK's forge `convention`/`glossary`/`failure` do — and there it is
230
+ * not anchoring anything: refusing it would leave the type unauthorable, which is a
231
+ * capability removal the contract never asked for. The manifest describes the CORE registry;
232
+ * this predicate is how that scope is expressed without hardcoding the core's type list.
233
+ */
234
+ function isAbstractAnchorFor(type: string, registry: TypeRegistry): boolean {
235
+ return registry.allSubTypesOf(type).some((sub) => sub !== SUBTYPE_BASE);
236
+ }
237
+
238
+ function abstractSubtypeMessage(type: string): string {
239
+ return (
240
+ `"${type}.${SUBTYPE_BASE}" may not be authored — every "${SUBTYPE_BASE}" subtype is an ` +
241
+ `abstract registry anchor that concrete subtypes inherit from, with no runtime ` +
242
+ `semantics of its own. Declare a concrete ${type} subtype instead.`
243
+ );
244
+ }
245
+
246
+ // The same rule reached by the OTHER spelling: a BARE wrapper key (`{"field": …}`, no fused
247
+ // subType) whose registry default resolves to the abstract anchor. The author did not type
248
+ // `.base`, so this is a MISSING subtype rather than an authored-anchor error — and
249
+ // ERR_MISSING_SUBTYPE is the shared code already chartered for it ("a node omits subType and
250
+ // the type has no default subType").
251
+ //
252
+ // Python has always emitted it here; TypeScript, C# and the JVM did not, so a bare key was a
253
+ // SECOND way one document got two verdicts: TS and C# resolved it to the anchor and loaded,
254
+ // the JVM resolved it identically and then failed to instantiate with a message naming a
255
+ // missing constructor. Closing the authored spelling alone would have left the rule half
256
+ // true, reachable by dropping four characters.
257
+ //
258
+ // Scoped to "the default IS the anchor", never to bare keys as such: a type that declares a
259
+ // CONCRETE default keeps resolving through it, which is the door a future default-subtype
260
+ // decision walks through (ADR-0054's closing section).
261
+ function missingSubtypeMessage(type: string): string {
262
+ return (
263
+ `type "${type}" has no default subType; write the full "${type}.<subType>"`
264
+ );
265
+ }
266
+
209
267
  // Internal helper — split a fused wrapper key into (type, subType).
210
268
  //
211
269
  // Canonical JSON always writes the full `type.subType`. An omitted subType
@@ -220,20 +278,31 @@ interface SplitKey {
220
278
  explicit: boolean;
221
279
  }
222
280
 
223
- function defaultSubTypeFor(type: string, registry: TypeRegistry): string {
224
- const subs = registry.allSubTypesOf(type);
225
- const candidate = subs.length > 0 ? subs[0]! : SUBTYPE_BASE;
226
- if (!registry.has(type, candidate) && registry.has(type, SUBTYPE_BASE)) {
227
- return SUBTYPE_BASE;
228
- }
229
- return candidate;
281
+ /**
282
+ * The subType a BARE wrapper key (`{"object": …}`) resolves to: the type's DECLARED default,
283
+ * or `undefined` when it declares none.
284
+ *
285
+ * `registry.defaultSubTypeOf` is the same accessor the YAML desugar consults, and the shared
286
+ * corpus already pins the contract (`fixtures/yaml-conformance/yaml-bare-default-subtypes`:
287
+ * bare `object:` becomes `object.entity`). This used to guess instead — `allSubTypesOf()[0]`,
288
+ * i.e. registration order, falling back to `base` — so the two layers answered the same
289
+ * question two different ways. Registration order put `base` first, so a bare key in JSON
290
+ * resolved to the abstract anchor: it loaded here and in C#, while the JVM resolved it
291
+ * identically and then failed to INSTANTIATE, because its impl classes are abstract.
292
+ *
293
+ * A name resolved twice by two different functions is a name that can disagree with itself —
294
+ * the defect class this file's own header exists to prevent, reached through the one door
295
+ * nobody had pointed at the registry.
296
+ */
297
+ function defaultSubTypeFor(type: string, registry: TypeRegistry): string | undefined {
298
+ return registry.defaultSubTypeOf(type);
230
299
  }
231
300
 
232
301
  function splitTypeKey(key: string, registry: TypeRegistry): SplitKey {
233
302
  const dotIdx = key.indexOf(TYPE_SUBTYPE_SEPARATOR);
234
303
  if (dotIdx < 0) {
235
304
  // Bare type, no fused subType — resolve via the registry default.
236
- return { type: key, subType: defaultSubTypeFor(key, registry), explicit: false };
305
+ return { type: key, subType: defaultSubTypeFor(key, registry) ?? "", explicit: false };
237
306
  }
238
307
  const type = key.slice(0, dotIdx);
239
308
  const subType = key.slice(dotIdx + TYPE_SUBTYPE_SEPARATOR.length);
@@ -419,7 +488,31 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
419
488
  }
420
489
 
421
490
  const rootDataObj = rootData as Record<string, unknown>;
422
- const { type: rootType, subType: rootSubType } = splitTypeKey(rootKey, opts.registry);
491
+ const { type: rootType, subType: rootSubType, explicit: rootExplicit } =
492
+ splitTypeKey(rootKey, opts.registry);
493
+
494
+ // A `<type>.base` node may not be AUTHORED — see abstractSubtypeMessage. This is the
495
+ // ROOT door; the child door is in the child loop below. One rule, both doors: a check
496
+ // on one of two entry points is a rule that is only half true.
497
+ // Registration first: an UNREGISTERED type has no default either, so without this a typo'd
498
+ // root key is diagnosed as a registered type that merely lacks a default. The registration
499
+ // check below owns that case and phrases it correctly.
500
+ if (opts.registry.allSubTypesOf(rootType).length > 0
501
+ && ((rootSubType === SUBTYPE_BASE && isAbstractAnchorFor(rootType, opts.registry))
502
+ || (!rootExplicit && rootSubType === ""))) {
503
+ _currentPath!.pushKey(rootKey);
504
+ const src = errSource();
505
+ _currentPath!.pop();
506
+ throw rootExplicit
507
+ ? new ParseError(abstractSubtypeMessage(rootType), {
508
+ code: "ERR_ABSTRACT_SUBTYPE_AUTHORED",
509
+ source: src,
510
+ })
511
+ : new ParseError(missingSubtypeMessage(rootType), {
512
+ code: "ERR_MISSING_SUBTYPE",
513
+ source: src,
514
+ });
515
+ }
423
516
 
424
517
  // Check root type is registered (always throw — can't skip the root)
425
518
  if (!opts.registry.has(rootType, rootSubType)) {
@@ -436,7 +529,13 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
436
529
  throw new ParseError(
437
530
  `Unknown root type "${rootType}.${rootSubType}" — ` +
438
531
  (retiredRoot !== undefined ? retirementHint(retiredRoot) : "not registered"),
439
- { code: rootTypeCode, source: src },
532
+ {
533
+ code: rootTypeCode,
534
+ source: src,
535
+ ...(retiredRoot !== undefined
536
+ ? { suggestions: retirementSuggestions(retiredRoot) }
537
+ : {}),
538
+ },
440
539
  );
441
540
  }
442
541
 
@@ -1259,11 +1358,36 @@ function processChildren(
1259
1358
  const childDataObj = childData as Record<string, unknown>;
1260
1359
  const { type: childType, subType: childSubTypeRaw, explicit } = splitTypeKey(childKey, registry);
1261
1360
 
1361
+ let childSubType = childSubTypeRaw;
1362
+ // A `<type>.base` may not be AUTHORED, and a bare key for a type with no declared default
1363
+ // is a MISSING subType. BEFORE the registered-type check below, matching the root door and
1364
+ // the JVM: run it after, and a bare key for a type that registers no `base` at all
1365
+ // (identity, index, requirement) falls into that check's else-arm first and is reported as
1366
+ // an UNKNOWN TYPE — a message asserting the type does not exist, about a type that does,
1367
+ // with its name mangled to `"identity."`. Order is the whole rule here.
1368
+ if ((childSubType === SUBTYPE_BASE && isAbstractAnchorFor(childType, registry))
1369
+ || (!explicit && childSubType === "")) {
1370
+ errors.push(
1371
+ explicit
1372
+ ? new ParseError(abstractSubtypeMessage(childType), {
1373
+ code: "ERR_ABSTRACT_SUBTYPE_AUTHORED",
1374
+ source: errSource(),
1375
+ })
1376
+ : new ParseError(missingSubtypeMessage(childType), {
1377
+ code: "ERR_MISSING_SUBTYPE",
1378
+ source: errSource(),
1379
+ }),
1380
+ );
1381
+ _currentPath?.pop(); // pop child wrapper key
1382
+ _currentPath?.pop(); // pop array index
1383
+ _currentYamlPosition = savedYamlPosition; // FR5b — restore parent's pos
1384
+ continue; // skip this child
1385
+ }
1386
+
1262
1387
  // --- Check if this child type is registered ---
1263
1388
  // An EXPLICIT unknown subType (fused into the key) is an error — never
1264
1389
  // silently downgraded to base. An OMITTED subType that resolves to an
1265
1390
  // unregistered default falls back to base.
1266
- let childSubType = childSubTypeRaw;
1267
1391
  if (!registry.has(childType, childSubType)) {
1268
1392
  if (!explicit && registry.has(childType, SUBTYPE_BASE)) {
1269
1393
  childSubType = SUBTYPE_BASE;
@@ -1288,6 +1412,12 @@ function processChildren(
1288
1412
  }
1289
1413
  }
1290
1414
 
1415
+ // A `<type>.base` child may not be AUTHORED — the CHILD door (the root door is above).
1416
+ // Gated on `explicit`, deliberately: `base` is also this parser's fallback for an
1417
+ // OMITTED subtype whose registry default is unregistered (the branch directly above),
1418
+ // and refusing that would break every node relying on the default. Placed before the
1419
+ // attr branch so `attr.base` is covered by the same rule — an authored untyped attr is
1420
+ // the same mistake as an authored untyped field.
1291
1421
  // --- Special handling for "attr" child nodes ---
1292
1422
  if (childType === TYPE_ATTR) {
1293
1423
  parseAttrChild(parent, childType, childSubType, childDataObj, registry, warnings, strict, source, childNodePath);
@@ -12,7 +12,7 @@ export const ORIGIN_DEFINITION: ProviderDefinition = {
12
12
  {
13
13
  "type": "origin",
14
14
  "subType": "base",
15
- "description": "Abstract base origin — the shared root subtype for field-level provenance. A field carrying any origin.* is derived ⇒ read-only wherever it lives. The base carries no attrs of its own; concrete subtypes add their provenance attrs."
15
+ "description": "Abstract base origin — the shared root subtype for field-level provenance. A field carrying any origin.* is derived ⇒ read-only wherever it lives. The base carries no attrs of its own; concrete subtypes add their provenance attrs. Not authored directly: a `origin.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED) — this subtype is a registry anchor concrete subtypes inherit from, never a node in a document."
16
16
  },
17
17
  {
18
18
  "type": "origin",
@@ -12,7 +12,7 @@ export const SOURCE_DEFINITION: ProviderDefinition = {
12
12
  {
13
13
  "type": "source",
14
14
  "subType": "base",
15
- "description": "Abstract base source — the shared root subtype for declaring where an object's data lives (Project E). The base carries no attrs of its own; the concrete paradigm subtype (rdb) carries the physical-storage attrs, which are contributed by the db domain provider."
15
+ "description": "Abstract base source — the shared root subtype for declaring where an object's data lives (Project E). The base carries no attrs of its own; the concrete paradigm subtype (rdb) carries the physical-storage attrs, which are contributed by the db domain provider. Not authored directly: a `source.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED) — this subtype is a registry anchor concrete subtypes inherit from, never a node in a document."
16
16
  },
17
17
  {
18
18
  "type": "source",
@@ -12,7 +12,7 @@ export const LAYOUT_DEFINITION: ProviderDefinition = {
12
12
  {
13
13
  "type": "layout",
14
14
  "subType": "base",
15
- "description": "Abstract base layout — the shared root subtype for object-level UI surfaces. A layout attaches a presentation concern (grids, forms, tabs, cards) to an object. The base carries no attrs of its own; concrete subtypes add their presentation attrs."
15
+ "description": "Abstract base layout — the shared root subtype for object-level UI surfaces. A layout attaches a presentation concern (grids, forms, tabs, cards) to an object. The base carries no attrs of its own; concrete subtypes add their presentation attrs. Not authored directly: a `layout.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED) — this subtype is a registry anchor concrete subtypes inherit from, never a node in a document."
16
16
  },
17
17
  {
18
18
  "type": "layout",
@@ -12,7 +12,7 @@ export const VIEW_DEFINITION: ProviderDefinition = {
12
12
  {
13
13
  "type": "view",
14
14
  "subType": "base",
15
- "description": "Abstract view base — the shared root subtype for field-level UI/render hints. A view declares how a field's value is rendered or edited; the base carries no attrs of its own."
15
+ "description": "Abstract view base — the shared root subtype for field-level UI/render hints. A view declares how a field's value is rendered or edited; the base carries no attrs of its own. Not authored directly: a `view.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED) — this subtype is a registry anchor concrete subtypes inherit from, never a node in a document."
16
16
  },
17
17
  {
18
18
  "type": "view",
@@ -23,6 +23,7 @@ import {
23
23
  RESERVED_KEYS,
24
24
  TYPE_SUBTYPE_SEPARATOR,
25
25
  } from "./shared/structural.js";
26
+ import { SUBTYPE_BASE } from "./shared/base-types.js";
26
27
 
27
28
  // ---------------------------------------------------------------------------
28
29
  // Manifest shape (a structural subset of fixtures/registry-conformance schema)
@@ -249,15 +250,35 @@ function compareStrings(a: string, b: string): number {
249
250
  * @param manifest the parsed registry manifest (the registered vocabulary)
250
251
  * @param roots fixture-corpus directory roots to scan for usage
251
252
  */
253
+ /**
254
+ * True for a `<type>.base` registry entry — an ABSTRACT ANCHOR, excluded from the coverage
255
+ * universe entirely.
256
+ *
257
+ * A `base` subtype is the shared root that concrete subtypes inherit their attrs and child
258
+ * rules from. It has no runtime semantics and no concrete representation, and authoring one
259
+ * is now a load error (ERR_ABSTRACT_SUBTYPE_AUTHORED), so no fixture can ever exercise it.
260
+ * Listing all ten as "untested vocabulary" described them as a backlog somebody could work
261
+ * off; they are the contract instead.
262
+ *
263
+ * Excluding them also keeps the attr accounting honest. The attrs a `base` entry declares
264
+ * are the ones its CONCRETE subtypes inherit — `@required` is declared on `field.base` and
265
+ * set on a `field.string` — so measuring them against fixtures that name `field.base` would
266
+ * report every shared field attr as untested the moment one negative fixture mentioned the
267
+ * subtype, which is what the corpus fixture for the new rule did.
268
+ */
269
+ function isAbstractAnchor(type: { readonly subType: string }): boolean {
270
+ return type.subType === SUBTYPE_BASE;
271
+ }
272
+
252
273
  export function computeCoverage(
253
274
  manifest: RegistryManifest,
254
275
  roots: readonly string[],
255
276
  ): CoverageReport {
256
277
  const usage = scanFixtureUsage(roots);
257
278
 
258
- const registeredKeys = manifest.types.map(
259
- (t) => `${t.type}${TYPE_SUBTYPE_SEPARATOR}${t.subType}`,
260
- );
279
+ const registeredKeys = manifest.types
280
+ .filter((t) => !isAbstractAnchor(t))
281
+ .map((t) => `${t.type}${TYPE_SUBTYPE_SEPARATOR}${t.subType}`);
261
282
 
262
283
  const untestedSubTypes = registeredKeys
263
284
  .filter((key) => !usage.subTypes.has(key))
@@ -268,6 +289,7 @@ export function computeCoverage(
268
289
  // Per EXERCISED subtype, which declared attrs no fixture sets.
269
290
  const untestedAttrsByExercisedSubType: UntestedAttrs[] = [];
270
291
  for (const t of manifest.types) {
292
+ if (isAbstractAnchor(t)) continue; // abstract anchor — see isAbstractAnchor
271
293
  const key = `${t.type}${TYPE_SUBTYPE_SEPARATOR}${t.subType}`;
272
294
  if (!usage.subTypes.has(key)) continue; // skip untested subtypes (all-untested by definition)
273
295
  if (t.attrs.length === 0) continue;
@@ -37,7 +37,7 @@ import {
37
37
  } from "./registry-manifest-exclusions.js";
38
38
 
39
39
  /** One attribute in the manifest — the logical, cross-port-identical facet. */
40
- interface ManifestAttr {
40
+ export interface ManifestAttr {
41
41
  name: string;
42
42
  /** The attr's SCALAR value-type subtype, or null for a polymorphic/untyped attr (e.g. @default). */
43
43
  valueType: string | null;
@@ -63,7 +63,7 @@ interface ManifestAttr {
63
63
  }
64
64
 
65
65
  /** One structural child rule of a type (FR-033 constraint graph). */
66
- interface ManifestChild {
66
+ export interface ManifestChild {
67
67
  /** The admitted child `type` (`"*"` = any). */
68
68
  childType: string;
69
69
  /** The admitted child subType — a single subtype, `"*"` (any), or a list of admitted subtypes. */
@@ -79,7 +79,7 @@ interface ManifestChild {
79
79
  }
80
80
 
81
81
  /** One registered (type, subType) in the manifest, with its docs + attrs + constraint graph. */
82
- interface ManifestType {
82
+ export interface ManifestType {
83
83
  type: string;
84
84
  subType: string;
85
85
  /** FR-033 — human/AI-facing description of the type/subType (required, non-empty). */
@@ -112,7 +112,7 @@ interface ManifestType {
112
112
  * constant read `"0.10"`). Bump with that script — never by hand — so the manifest and
113
113
  * all four port constants move together.
114
114
  */
115
- export const METAMODEL_VERSION = "0.13";
115
+ export const METAMODEL_VERSION = "1.0";
116
116
 
117
117
  /** The full canonical manifest. All collections are sorted for byte-stability. */
118
118
  interface RegistryManifest {
@@ -124,7 +124,7 @@ interface RegistryManifest {
124
124
  }
125
125
 
126
126
  /** ASCII-string compare so the sort is locale-independent and byte-stable across ports. */
127
- function compareStrings(a: string, b: string): number {
127
+ export function compareStrings(a: string, b: string): number {
128
128
  return a < b ? -1 : a > b ? 1 : 0;
129
129
  }
130
130
 
@@ -195,7 +195,7 @@ function toManifestChild(rule: ChildRule): ManifestChild {
195
195
  }
196
196
 
197
197
  /** Sort the constraint graph by (childType, childSubTypeKey, childName) — ASCII. */
198
- function sortedChildren(rules: readonly ChildRule[]): ManifestChild[] {
198
+ export function sortedChildren(rules: readonly ChildRule[]): ManifestChild[] {
199
199
  return rules
200
200
  .map(toManifestChild)
201
201
  .sort(
@@ -207,7 +207,7 @@ function sortedChildren(rules: readonly ChildRule[]): ManifestChild[] {
207
207
  }
208
208
 
209
209
  /** Sort attrs by name (ascending, ASCII). */
210
- function sortedAttrs(attrs: readonly AttrSchema[]): ManifestAttr[] {
210
+ export function sortedAttrs(attrs: readonly AttrSchema[]): ManifestAttr[] {
211
211
  return attrs
212
212
  .map(toManifestAttr)
213
213
  .sort((a, b) => compareStrings(a.name, b.name));
@@ -260,7 +260,8 @@ function sortedPerTypeAttrs(
260
260
  * judge a carve-out "dead" — that is a cross-port property, asserted by the
261
261
  * shared byte-canonical, not here.
262
262
  */
263
- const INCLUDED = "included" as const;
263
+ /** The classifier's "this is logical cross-port vocabulary" verdict. */
264
+ export const INCLUDED = "included" as const;
264
265
  export type AttrClassification = ExclusionReason | typeof INCLUDED;
265
266
 
266
267
  export function classifyPerTypeAttr(name: string): AttrClassification {