@metaobjectsdev/metadata 0.24.0 → 0.24.2

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 (64) hide show
  1. package/dist/attr-contradictions.d.ts +52 -0
  2. package/dist/attr-contradictions.d.ts.map +1 -0
  3. package/dist/attr-contradictions.js +100 -0
  4. package/dist/attr-contradictions.js.map +1 -0
  5. package/dist/core/identity/identity-definition.embedded.js +2 -2
  6. package/dist/core/identity/identity-definition.embedded.js.map +1 -1
  7. package/dist/core/index/index-definition.embedded.js +2 -2
  8. package/dist/core/index/index-definition.embedded.js.map +1 -1
  9. package/dist/core/requirement/meta-requirement.d.ts +9 -1
  10. package/dist/core/requirement/meta-requirement.d.ts.map +1 -1
  11. package/dist/core/requirement/meta-requirement.js +15 -2
  12. package/dist/core/requirement/meta-requirement.js.map +1 -1
  13. package/dist/core/requirement/requirement-constants.d.ts +30 -2
  14. package/dist/core/requirement/requirement-constants.d.ts.map +1 -1
  15. package/dist/core/requirement/requirement-constants.js +32 -1
  16. package/dist/core/requirement/requirement-constants.js.map +1 -1
  17. package/dist/core/requirement/requirement-definition.embedded.d.ts.map +1 -1
  18. package/dist/core/requirement/requirement-definition.embedded.js +22 -4
  19. package/dist/core/requirement/requirement-definition.embedded.js.map +1 -1
  20. package/dist/core/vocabulary-rewrite-yaml.d.ts +21 -0
  21. package/dist/core/vocabulary-rewrite-yaml.d.ts.map +1 -0
  22. package/dist/core/vocabulary-rewrite-yaml.js +348 -0
  23. package/dist/core/vocabulary-rewrite-yaml.js.map +1 -0
  24. package/dist/errors.d.ts +1 -1
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/errors.js +31 -2
  27. package/dist/errors.js.map +1 -1
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +3 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/loader/meta-data-loader.d.ts.map +1 -1
  33. package/dist/loader/meta-data-loader.js +8 -1
  34. package/dist/loader/meta-data-loader.js.map +1 -1
  35. package/dist/loader/validation-passes.d.ts +2 -0
  36. package/dist/loader/validation-passes.d.ts.map +1 -1
  37. package/dist/loader/validation-passes.js +328 -43
  38. package/dist/loader/validation-passes.js.map +1 -1
  39. package/dist/persistence/origin/origin-definition.embedded.js +5 -5
  40. package/dist/persistence/origin/origin-definition.embedded.js.map +1 -1
  41. package/dist/registry-manifest.d.ts +1 -1
  42. package/dist/registry-manifest.js +1 -1
  43. package/dist/retired-vocabulary.d.ts.map +1 -1
  44. package/dist/retired-vocabulary.js +54 -11
  45. package/dist/retired-vocabulary.js.map +1 -1
  46. package/dist/vocabulary-rewrite.d.ts.map +1 -1
  47. package/dist/vocabulary-rewrite.js +127 -15
  48. package/dist/vocabulary-rewrite.js.map +1 -1
  49. package/package.json +6 -1
  50. package/src/attr-contradictions.ts +141 -0
  51. package/src/core/identity/identity-definition.embedded.ts +2 -2
  52. package/src/core/index/index-definition.embedded.ts +2 -2
  53. package/src/core/requirement/meta-requirement.ts +18 -1
  54. package/src/core/requirement/requirement-constants.ts +34 -1
  55. package/src/core/requirement/requirement-definition.embedded.ts +22 -4
  56. package/src/core/vocabulary-rewrite-yaml.ts +375 -0
  57. package/src/errors.ts +31 -2
  58. package/src/index.ts +9 -0
  59. package/src/loader/meta-data-loader.ts +10 -1
  60. package/src/loader/validation-passes.ts +410 -47
  61. package/src/persistence/origin/origin-definition.embedded.ts +5 -5
  62. package/src/registry-manifest.ts +1 -1
  63. package/src/retired-vocabulary.ts +54 -11
  64. package/src/vocabulary-rewrite.ts +137 -15
@@ -0,0 +1,375 @@
1
+ // server/typescript/packages/metadata/src/core/vocabulary-rewrite-yaml.ts
2
+ //
3
+ // The YAML arm of the raw-document rewriter behind `meta upgrade`.
4
+ //
5
+ // WHY IT IS A SEPARATE MODULE. `vocabulary-rewrite.ts` is reachable from `src/index.ts`, so
6
+ // it may not import `yaml` — that package is Node-only and would land in the browser bundle.
7
+ // This file carries the `yaml` dependency and is reachable ONLY through its own package
8
+ // subpath, which `meta upgrade` dynamic-imports. Same split, and same reason, as
9
+ // `yaml-positions.ts` / `yaml-positions-walker.ts` (see that file's header).
10
+ //
11
+ // WHY IT IS PARSER-DRIVEN WHERE THE JSON ARM IS REGEX-DRIVEN. A hand-rolled YAML mode was
12
+ // tried once and shipped a file-corrupting bug: a multi-item block sequence lost every item
13
+ // but the first, because a hand-written value scanner stops at a newline, and the dominant
14
+ // in-repo flow style (`{ name: x, readOnly: true }`) was not matched at all — so the rename
15
+ // silently did nothing. Both failures are the same failure: YAML's value extent is not
16
+ // derivable by scanning. Here the PARSER reports it. `pair.value.range` covers a four-line
17
+ // block sequence and a one-line flow mapping alike, so neither case is a special case.
18
+ //
19
+ // STILL SURGICAL, NOT PARSE-AND-REPRINT. `doc.toString()` would reflow an adopter's file —
20
+ // line width, quote style, indentation of flow collections — and hand them a diff whose real
21
+ // changes are invisible inside it. So the parse is used only to LOCATE spans; every edit is a
22
+ // span replacement on the original text, and any region not deliberately changed comes back
23
+ // byte-identical. That is the same guarantee the JSON arm makes, by the same means.
24
+ //
25
+ // IT ALSO RESOLVES ATTRIBUTE CONTRADICTIONS (`../attr-contradictions.ts`), matched per NODE
26
+ // rather than per pair — the illegal thing is the PAIR of keys, so the unit is the mapping
27
+ // that holds one node's own keys. `eachNodeBody` below is that walk. Doing it by proximity
28
+ // instead was tried in the JSON arm and took a `fields` belonging to a sibling node.
29
+ //
30
+ // SIGIL-FREE, PER ADR-0006. YAML authoring writes bare attribute keys (`violation:`) and the
31
+ // desugar re-adds the `@` when lowering to canonical JSON. So a rename emits a BARE key here
32
+ // where the JSON arm emits `"@name"`. A leading `@` is still matched on input — an author who
33
+ // wrote one gets it fixed rather than skipped — but is never introduced.
34
+
35
+ import { LineCounter, isMap, isSeq, parseDocument, type Node, type Pair } from "yaml";
36
+ import { ATTR_CONTRADICTIONS, contradictionScopeMatches } from "../attr-contradictions.js";
37
+ import type { AttrContradiction } from "../attr-contradictions.js";
38
+ import {
39
+ RETIRED_VOCABULARY,
40
+ note,
41
+ scopeMatches,
42
+ type RetiredEntry,
43
+ } from "../retired-vocabulary.js";
44
+ import type { RewriteChange, RewriteRefusal, RewriteOpts, RewriteResult } from "../vocabulary-rewrite.js";
45
+
46
+ /**
47
+ * A rewrite result that can also report "I could not read this file".
48
+ *
49
+ * A YAML document that does not parse yields no changes and no refusals, which is
50
+ * indistinguishable from a clean one — and a fixer that reports a file it could not open as
51
+ * clean is the exact defect this arm was written to remove (#339). The flag makes the caller
52
+ * say so out loud.
53
+ */
54
+ export interface YamlRewriteResult extends RewriteResult {
55
+ readonly unparseable: boolean;
56
+ }
57
+
58
+ /** A canonical node key: `<type>.<subType>`. Identical to the JSON arm's scope shape. */
59
+ const TYPE_KEY = /^[a-z][A-Za-z0-9]*\.[A-Za-z0-9_*]+$/;
60
+
61
+ /** `0.24.0` → `[0,24,0]`, for an ordered comparison rather than a string one. */
62
+ function parts(v: string): number[] {
63
+ return v.split(".").map((n) => Number.parseInt(n, 10) || 0);
64
+ }
65
+
66
+ function atOrBefore(a: string, b: string): boolean {
67
+ const [x, y] = [parts(a), parts(b)];
68
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
69
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
70
+ if (d !== 0) return d < 0;
71
+ }
72
+ return true;
73
+ }
74
+
75
+ /** The plain string a mapping key carries, or undefined when it is not a plain scalar. */
76
+ function keyText(key: unknown): string | undefined {
77
+ const v = (key as { value?: unknown } | null)?.value;
78
+ return typeof v === "string" ? v : undefined;
79
+ }
80
+
81
+ /** Source offsets of a pair's key and of the end of its value. */
82
+ function pairSpan(pair: Pair): { keyStart: number; keyEnd: number; valueEnd: number } | undefined {
83
+ const k = pair.key as { range?: [number, number, number] } | null;
84
+ const v = pair.value as { range?: [number, number, number] } | null;
85
+ if (k?.range === undefined) return undefined;
86
+ return {
87
+ keyStart: k.range[0],
88
+ keyEnd: k.range[1],
89
+ // A valueless key (`verifiedBy:` with nothing after it) still has to be removable.
90
+ valueEnd: v?.range?.[1] ?? k.range[1],
91
+ };
92
+ }
93
+
94
+ /**
95
+ * The span to delete so that removing a pair leaves loadable YAML.
96
+ *
97
+ * Two shapes, and they need opposite treatment — which is precisely what the previous
98
+ * hand-rolled attempt got wrong by handling only one.
99
+ */
100
+ function dropSpan(
101
+ source: string,
102
+ span: { keyStart: number; valueEnd: number },
103
+ flow: boolean,
104
+ ): { start: number; end: number } | undefined {
105
+ let { keyStart: start } = span;
106
+ let end = span.valueEnd;
107
+
108
+ if (flow) {
109
+ // `{ a: 1, readOnly: true }` — take a trailing comma if there is one, else a preceding
110
+ // one, so the mapping ends up with neither a dangling nor a doubled separator.
111
+ //
112
+ // The probe must not commit: scanning forward over the spaces and THEN finding `}`
113
+ // rather than `,` would leave `end` past the space that separates the survivor from the
114
+ // brace, silently reformatting `{ name: x }` into `{ name: x}`.
115
+ let probe = end;
116
+ while (probe < source.length && /[ \t]/.test(source[probe] ?? "")) probe++;
117
+ if (source[probe] === ",") {
118
+ end = probe + 1;
119
+ while (end < source.length && /[ \t]/.test(source[end] ?? "")) end++;
120
+ } else {
121
+ let back = start;
122
+ while (back > 0 && /\s/.test(source[back - 1] ?? "")) back--;
123
+ if (source[back - 1] === ",") start = back - 1;
124
+ }
125
+ return { start, end };
126
+ }
127
+
128
+ // Block mapping — the pair owns whole lines. Absorb its indentation and its line
129
+ // terminator, so removal leaves neither a ragged line nor a blank one.
130
+ while (start > 0 && /[ \t]/.test(source[start - 1] ?? "")) start--;
131
+
132
+ // A pair that is the first key of a block SEQUENCE item (`- verifiedBy: x`) shares its
133
+ // line with the `-`. Deleting it would strand the dash and silently change the sequence's
134
+ // shape, so this refuses rather than guesses — the caller reports it as needing a hand.
135
+ if (source[start - 1] === "-") return undefined;
136
+
137
+ // A multi-line value (a block sequence) already ends ON the newline that closes its last
138
+ // item, so the terminator is spent. Consuming another one here would delete the FOLLOWING
139
+ // key — which is the multi-item-sequence corruption this arm exists to avoid, arriving by
140
+ // a different route.
141
+ if (end === 0 || source[end - 1] !== "\n") {
142
+ while (end < source.length && /[ \t]/.test(source[end] ?? "")) end++;
143
+ // A trailing comment on the key's own line goes with the key it annotates.
144
+ if (source[end] === "#") while (end < source.length && source[end] !== "\n") end++;
145
+ if (source[end] === "\n") end++;
146
+ }
147
+ return { start, end };
148
+ }
149
+
150
+ /** Visit every mapping pair with the `<type>.<subType>` scope governing it. */
151
+ function eachPair(
152
+ node: unknown,
153
+ scope: string | undefined,
154
+ visit: (pair: Pair, scope: string | undefined, flow: boolean) => void,
155
+ ): void {
156
+ if (isMap(node)) {
157
+ const flow = node.flow === true;
158
+ for (const pair of node.items) {
159
+ const k = keyText(pair.key);
160
+ visit(pair, scope, flow);
161
+ // A type key scopes its own BODY, not itself — so the pair above is reported under the
162
+ // enclosing scope while its value descends under this one.
163
+ const inner = k !== undefined && TYPE_KEY.test(k) ? k : scope;
164
+ if (pair.value != null) eachPair(pair.value, inner, visit);
165
+ }
166
+ return;
167
+ }
168
+ if (isSeq(node)) {
169
+ for (const item of node.items) eachPair(item as Node, scope, visit);
170
+ }
171
+ }
172
+
173
+ /** A node's own key set: the mapping that a `<type>.<subType>:` key introduces. */
174
+ interface NodeBody {
175
+ readonly items: readonly Pair[];
176
+ readonly flow: boolean;
177
+ }
178
+
179
+ /**
180
+ * Visit every node BODY in the document.
181
+ *
182
+ * The unit is the body rather than the pair because a contradiction is a property of a
183
+ * SIBLING SET. `body.items` is exactly this node's own keys — a child node lives inside the
184
+ * value of a `children:` pair, so it is reached by recursion and never mistaken for a
185
+ * sibling.
186
+ */
187
+ function eachNodeBody(node: unknown, visit: (typeKey: string, body: NodeBody) => void): void {
188
+ if (isMap(node)) {
189
+ for (const pair of node.items) {
190
+ const k = keyText(pair.key);
191
+ if (k !== undefined && TYPE_KEY.test(k) && isMap(pair.value)) {
192
+ visit(k, { items: pair.value.items as Pair[], flow: pair.value.flow === true });
193
+ }
194
+ if (pair.value != null) eachNodeBody(pair.value, visit);
195
+ }
196
+ return;
197
+ }
198
+ if (isSeq(node)) {
199
+ for (const item of node.items) eachNodeBody(item as Node, visit);
200
+ }
201
+ }
202
+
203
+ /** An authored `@` must not hide a key from either table; the sigil is never introduced. */
204
+ function bareKey(pair: Pair): string | undefined {
205
+ const k = keyText(pair.key);
206
+ if (k === undefined) return undefined;
207
+ return k.startsWith("@") ? k.slice(1) : k;
208
+ }
209
+
210
+ /** True when `keep` holds one of the entry's `keepValues` (or the entry names none,
211
+ * in which case mere presence is the contradiction). Mirrors the JSON rewriter's
212
+ * `keepValueMatches` — one rule, two doors, and a divergence here is a file format
213
+ * silently migrating differently from the other. */
214
+ function keepValueMatches(pair: Pair, c: AttrContradiction): boolean {
215
+ if (c.keepValues === undefined) return true;
216
+ const v = (pair.value as { value?: unknown } | null)?.value;
217
+ return typeof v === "string" && c.keepValues.includes(v);
218
+ }
219
+
220
+ /** Does this pair carry a string that actually says something? */
221
+ function suppliesText(pair: Pair): boolean {
222
+ const v = (pair.value as { value?: unknown } | null)?.value;
223
+ return typeof v === "string" && v.trim().length > 0;
224
+ }
225
+
226
+ /**
227
+ * Rewrite retired vocabulary in one raw YAML metadata document.
228
+ *
229
+ * Pure: no filesystem, no loader, no registry. Mirrors `rewriteDocument`'s contract exactly —
230
+ * same result shape, same scoping rule, same refusal policy — so `meta upgrade` reports a
231
+ * YAML estate and a JSON estate identically.
232
+ */
233
+ export function rewriteYamlDocument(source: string, opts: RewriteOpts = {}): YamlRewriteResult {
234
+ const changes: RewriteChange[] = [];
235
+ const refusals: RewriteRefusal[] = [];
236
+ const edits: { start: number; end: number; text: string }[] = [];
237
+
238
+ const lineCounter = new LineCounter();
239
+ const doc = parseDocument(source, { lineCounter, keepSourceTokens: true });
240
+ // A document we cannot parse is a document we must not edit. Reporting nothing here is
241
+ // correct: `meta verify` owns malformed YAML, and guessing at spans in a broken file is
242
+ // how a fixer corrupts one.
243
+ if (doc.errors.length > 0 || doc.contents == null) {
244
+ return { text: source, changes, refusals, unparseable: true };
245
+ }
246
+
247
+ const lineOf = (offset: number): number => lineCounter.linePos(offset).line;
248
+ const inWindow = (e: RetiredEntry): boolean =>
249
+ opts.maxVersion === undefined || atOrBefore(e.since, opts.maxVersion);
250
+
251
+ // ── Attribute contradictions: two LIVE attrs that may not sit on one node ──
252
+ //
253
+ // THE TWO SIDES ARE ASKED DIFFERENT QUESTIONS, mirroring the loader's Rule 1a exactly
254
+ // (`validation-passes.ts`, `hasFieldsAttr` vs `hasExpr`) and the JSON arm's copy of it.
255
+ // The DROP side counts on PRESENCE — an empty `fields: []` beside `expr` is still a
256
+ // declaration of both, and is the case where the discard is total. The KEEP side counts
257
+ // only when it supplies a key, so a blank `expr: ""` beside `fields` is a plain column
258
+ // index the loader accepts and this must leave alone.
259
+ //
260
+ // IT SEES ONLY THIS NODE'S OWN KEYS. A node declaring `expr` while INHERITING `fields`
261
+ // through `extends` contradicts itself in the loaded model and not on the page; no
262
+ // raw-document rewriter can resolve a super-reference, so that stays the loader's refusal.
263
+ eachNodeBody(doc.contents, (typeKey, body) => {
264
+ for (const c of ATTR_CONTRADICTIONS) {
265
+ if (opts.maxVersion !== undefined && !atOrBefore(c.since, opts.maxVersion)) continue;
266
+ if (!contradictionScopeMatches(c, typeKey)) continue;
267
+ // `keep` must be present AND, when the entry names values, hold one of them —
268
+ // otherwise status and implementedBy would contradict on every status.
269
+ if (!body.items.some(
270
+ (p) => bareKey(p) === c.keep && suppliesText(p) && keepValueMatches(p, c),
271
+ )) continue;
272
+
273
+ for (const pair of body.items) {
274
+ if (bareKey(pair) !== c.drop) continue;
275
+ const span = pairSpan(pair);
276
+ if (span === undefined) continue;
277
+ const d = dropSpan(source, span, body.flow);
278
+ // Undeletable in place (a sequence item's leading key) — leave it, and let the
279
+ // loader keep refusing rather than reshape the author's sequence.
280
+ if (d === undefined) continue;
281
+ edits.push({ ...d, text: "" });
282
+ changes.push({
283
+ attr: c.drop,
284
+ from: c.drop,
285
+ to: `(removed — ${c.keep} keys this node)`,
286
+ line: lineOf(span.keyStart),
287
+ });
288
+ }
289
+ }
290
+ });
291
+
292
+ eachPair(doc.contents, undefined, (pair, scope, flow) => {
293
+ const key = keyText(pair.key);
294
+ if (key === undefined) return;
295
+ const span = pairSpan(pair);
296
+ if (span === undefined) return;
297
+
298
+ // A retired SUBTYPE has no attribute to rewrite — the node itself has to be re-modelled,
299
+ // which is the adopter's judgment. Reporting it is what keeps `meta upgrade` from exiting
300
+ // 0 on a document that still will not load.
301
+ if (TYPE_KEY.test(key)) {
302
+ for (const entry of RETIRED_VOCABULARY) {
303
+ if (entry.isSubTypeRetirement !== true || !inWindow(entry)) continue;
304
+ if (`${entry.type}.${entry.subType}` !== key) continue;
305
+ refusals.push({ ...note(entry), subject: key, line: lineOf(span.keyStart) });
306
+ }
307
+ return;
308
+ }
309
+
310
+ // Sigil-free authoring is the norm, but an authored `@` must not make a retirement
311
+ // invisible.
312
+ const bare = key.startsWith("@") ? key.slice(1) : key;
313
+ const raw = source.slice(span.keyEnd, span.valueEnd).replace(/^\s*:\s*/, "").trim();
314
+ const line = lineOf(span.keyStart);
315
+
316
+ for (const entry of RETIRED_VOCABULARY) {
317
+ if (entry.attr !== bare || !inWindow(entry)) continue;
318
+ if (scope === undefined || !scopeMatches(entry, scope)) continue;
319
+
320
+ // A VALUE-scoped retirement only fires on the retired values — the same attribute with
321
+ // a live value must come through untouched.
322
+ if (entry.attrValues !== undefined && !entry.attrValues.some((v) => raw === v || raw === `"${v}"` || raw === `'${v}'`)) {
323
+ continue;
324
+ }
325
+
326
+ const refuse = (): void => {
327
+ refusals.push({ ...note(entry), subject: `@${bare}`, ...(raw !== "" ? { value: raw } : {}), line });
328
+ };
329
+ const drop = (): void => {
330
+ const d = dropSpan(source, span, flow);
331
+ // Undeletable in place (a sequence item's leading key) — report it instead of
332
+ // producing YAML that parses as something else.
333
+ if (d === undefined) {
334
+ refuse();
335
+ return;
336
+ }
337
+ edits.push({ ...d, text: "" });
338
+ changes.push({ attr: bare, from: bare, to: "(removed)", line });
339
+ };
340
+
341
+ const rw = entry.rewrite;
342
+ if (rw === undefined) refuse();
343
+ else if (rw.kind === "renameAttr") {
344
+ // NOTE — the JSON rewriter refuses a rename onto a key the node already declares,
345
+ // because two `"@counterexample"` members in one object parse silently with the
346
+ // last one winning. YAML needs no such guard: a duplicate key is a hard PARSE
347
+ // ERROR, so the same document fails loudly on the next load rather than quietly
348
+ // losing the author's surviving sentence. Same rule, different blast radius —
349
+ // if `eachPair` ever gains sibling access, mirror the JSON guard here anyway.
350
+ //
351
+ // Preserve the author's quoting style; YAML keys are usually bare, but a quoted key
352
+ // must stay quoted or the surrounding style stops being self-consistent.
353
+ const rawKey = source.slice(span.keyStart, span.keyEnd);
354
+ const q = rawKey[0] === '"' || rawKey[0] === "'" ? rawKey[0] : "";
355
+ edits.push({ start: span.keyStart, end: span.keyEnd, text: `${q}${rw.to}${q}` });
356
+ changes.push({ attr: bare, from: bare, to: rw.to, line });
357
+ } else if (rw.kind === "dropAttr") drop();
358
+ else if (raw === String(rw.fromValue) || raw === `"${rw.fromValue}"` || raw === `'${rw.fromValue}'`) {
359
+ const valText = typeof rw.toValue === "string" ? String(rw.toValue) : JSON.stringify(rw.toValue);
360
+ edits.push({ start: span.keyStart, end: span.keyEnd, text: rw.toAttr });
361
+ edits.push({ start: span.keyEnd, end: span.valueEnd, text: `: ${valText}` });
362
+ changes.push({ attr: bare, from: `${bare}: ${raw}`, to: `${rw.toAttr}: ${valText}`, line });
363
+ } else if (rw.otherwise === "drop") drop();
364
+ else refuse();
365
+ }
366
+ });
367
+
368
+ // Applied right-to-left against the ORIGINAL text: rewriting incrementally would invalidate
369
+ // every later offset.
370
+ edits.sort((a, b) => b.start - a.start);
371
+ let text = source;
372
+ for (const e of edits) text = text.slice(0, e.start) + e.text + text.slice(e.end);
373
+
374
+ return { text, changes, refusals, unparseable: false };
375
+ }
package/src/errors.ts CHANGED
@@ -159,6 +159,23 @@ export const ERROR_CODES = [
159
159
  // SP-H Unit9 — @filterable: true on a field subtype with no filter-operator
160
160
  // band (e.g. field.object). Would silently generate an empty-ops filter.
161
161
  "ERR_FILTERABLE_UNSUPPORTED_SUBTYPE",
162
+ // #335 Half B — @sortable: true on an array field or a subtype with no
163
+ // filter-operator band (e.g. field.object). Would silently emit a sort
164
+ // entry over a column no dialect can ORDER BY.
165
+ "ERR_SORTABLE_UNSUPPORTED_SUBTYPE",
166
+ // #335 Half A — a whole-object @agg:collect (no @of; the carrying field.object
167
+ // rolls related rows up as its declared @objectRef value object) is malformed:
168
+ // carrier is not a field.object with @objectRef, @via absent, @distinct declared
169
+ // (refused — a no-op whenever the value object carries the primary key), an
170
+ // @orderBy key not on the @via TERMINAL entity, or a member's declared type
171
+ // disagreeing with the matched terminal field's. Distinct from ERR_INVALID_ORIGIN
172
+ // so a fixture can tell this arm from a loader that still requires @of.
173
+ "ERR_COLLECT_WHOLE_OBJECT",
174
+ // #335 Half A — a whole-object @agg:collect's value-object member has no
175
+ // matching field (by name) on the @via terminal entity. The lowering
176
+ // projects exactly the declared members; failing open here is how #270
177
+ // turned a curated value object into the full entity.
178
+ "ERR_COLLECT_MEMBER_UNRESOLVED",
162
179
  // ADR-0023 — a registration was attempted against a registry sealed after its
163
180
  // agreed metamodel-provider bootstrap. Codegen cannot invent metamodel attrs.
164
181
  "ERR_REGISTRY_SEALED",
@@ -174,9 +191,21 @@ export const ERROR_CODES = [
174
191
  // clash / required-child cycle / conflicting attr redefinition. The detail names
175
192
  // which of the six checks fired and the offending type(s).
176
193
  "ERR_INVALID_METAMODEL_CONSTRAINT",
177
- // index.lookup field-resolution: @fields is empty or names a field that does
178
- // not exist on the owning entity's effective (resolved via extends) field set.
194
+ // Index-key resolution for index.lookup AND identity.secondary (#342) the key is
195
+ // @fields XOR @expr: neither declared, BOTH declared (@expr is used INSTEAD of
196
+ // @fields), whichever is declared supplies no key, or a named field does not exist
197
+ // on the owning entity's effective (resolved via extends) field set.
179
198
  "ERR_INVALID_INDEX",
199
+ // FR-039 — a requirement.* with @status: retired declares @implementedBy. Refused
200
+ // rather than exempted: a retired capability has no implementation BY DEFINITION,
201
+ // so forbidding the attribute makes the dangling-reference class unreachable
202
+ // instead of silently tolerated (which is what 0.24.0 removed the old vocabulary
203
+ // over — 29 unresolvable refs across 14 entries reported as zero).
204
+ "ERR_REQUIREMENT_RETIRED_HAS_IMPLEMENTORS",
205
+ // FR-039 — @supersededBy on a requirement whose @status is not `retired`. The
206
+ // attribute names what REPLACED a withdrawn capability; on a live one there is
207
+ // nothing to have replaced it.
208
+ "ERR_REQUIREMENT_SUPERSEDED_BY_NOT_RETIRED",
180
209
  // #195 — origin.computed @expr: the expression tree's inferred root type does
181
210
  // not equal the carrying field's declared field.<subType>. A computed column's
182
211
  // type is DERIVED from its expression, never asserted (no @convert escape),
package/src/index.ts CHANGED
@@ -299,5 +299,14 @@ export {
299
299
  retirementHint,
300
300
  } from "./retired-vocabulary.js";
301
301
  export type { RetirementNote, RetiredEntry, VocabularyRewrite } from "./retired-vocabulary.js";
302
+ // Its sibling: pairs of LIVE attributes that may not sit on one node. Same two consumers,
303
+ // same reason — a retirement removes a name, a contradiction refuses a combination.
304
+ export {
305
+ ATTR_CONTRADICTIONS,
306
+ contradictionsFor,
307
+ contradictionScopeMatches,
308
+ contradictionHint,
309
+ } from "./attr-contradictions.js";
310
+ export type { AttrContradiction } from "./attr-contradictions.js";
302
311
  export { rewriteDocument } from "./vocabulary-rewrite.js";
303
312
  export type { RewriteResult, RewriteChange, RewriteRefusal, RewriteOpts } from "./vocabulary-rewrite.js";
@@ -18,7 +18,7 @@ import { ParseError } from "../errors.js";
18
18
  import type { LoaderWarning } from "../source.js";
19
19
  import { codeSource, resolvedSource } from "../source.js";
20
20
  import { parseJson } from "../parser-json.js";
21
- import { validateDataGridSortFields, validateFilterableHasIndex, validateFilterableHasSupportedOps, validateOriginPaths, validateDerivedFieldProvidability, validateDataGridFilterValues, validateFieldObjectStorage, validateFieldMap, validateTemplatePayloadRefs, validateFieldDefaults, validateRelationships, validateIndexLookupFields, validateProjectionFilter } from "./validation-passes.js";
21
+ import { validateDataGridSortFields, validateFilterableHasIndex, validateFilterableHasSupportedOps, validateSortableHasSupportedSubtype, validateOriginPaths, validateDerivedFieldProvidability, validateDataGridFilterValues, validateFieldObjectStorage, validateFieldMap, validateTemplatePayloadRefs, validateFieldDefaults, validateRelationships, validateIndexLookupFields, validateProjectionFilter, validateRetiredRequirementLinks } from "./validation-passes.js";
22
22
  import { runRegisteredValidation } from "./validation-registry.js";
23
23
  import { validateSourceRoles } from "../persistence/source/validate-source-roles.js";
24
24
  import { validateSourceEscapes } from "../persistence/source/validate-source-escapes.js";
@@ -578,6 +578,10 @@ export class MetaDataLoader {
578
578
  // (would silently generate a filter that rejects every request).
579
579
  errors.push(...validateFilterableHasSupportedOps(root));
580
580
 
581
+ // #335 Half B — @sortable on an array field or a subtype with no operator
582
+ // band → error (would silently emit a sort entry no dialect can execute).
583
+ errors.push(...validateSortableHasSupportedSubtype(root));
584
+
581
585
  // Sixth pass: origin path validation — validates passthrough.@from,
582
586
  // aggregate.@of, and .@via relationship chains.
583
587
  errors.push(...validateOriginPaths(root));
@@ -607,6 +611,11 @@ export class MetaDataLoader {
607
611
  // (ADR-0039: resolving accessor, so inherited fields via extends are visible).
608
612
  errors.push(...validateIndexLookupFields(root));
609
613
 
614
+ // FR-039 — a retired requirement carries no @implementedBy (refused, not
615
+ // exempted, so the dangling-ref class is unreachable) and @supersededBy is
616
+ // legal only on `retired`.
617
+ errors.push(...validateRetiredRequirementLinks(root));
618
+
610
619
  // Phase 2 — validation DERIVED FROM THE TYPE REGISTRY: each node's TypeDefinition
611
620
  // carries its reference descriptors + imperative validator, run as one recursive walk
612
621
  // over a built-once symbol table. A downstream provider's custom type validates itself