@metaobjectsdev/metadata 0.24.1 → 0.24.3

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 (54) 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/requirement/meta-requirement.d.ts +9 -1
  6. package/dist/core/requirement/meta-requirement.d.ts.map +1 -1
  7. package/dist/core/requirement/meta-requirement.js +15 -2
  8. package/dist/core/requirement/meta-requirement.js.map +1 -1
  9. package/dist/core/requirement/requirement-constants.d.ts +30 -2
  10. package/dist/core/requirement/requirement-constants.d.ts.map +1 -1
  11. package/dist/core/requirement/requirement-constants.js +32 -1
  12. package/dist/core/requirement/requirement-constants.js.map +1 -1
  13. package/dist/core/requirement/requirement-definition.embedded.d.ts.map +1 -1
  14. package/dist/core/requirement/requirement-definition.embedded.js +22 -4
  15. package/dist/core/requirement/requirement-definition.embedded.js.map +1 -1
  16. package/dist/core/vocabulary-rewrite-yaml.d.ts.map +1 -1
  17. package/dist/core/vocabulary-rewrite-yaml.js +103 -0
  18. package/dist/core/vocabulary-rewrite-yaml.js.map +1 -1
  19. package/dist/errors.d.ts +1 -1
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +10 -0
  22. package/dist/errors.js.map +1 -1
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/loader/meta-data-loader.d.ts.map +1 -1
  28. package/dist/loader/meta-data-loader.js +5 -1
  29. package/dist/loader/meta-data-loader.js.map +1 -1
  30. package/dist/loader/validation-passes.d.ts +1 -0
  31. package/dist/loader/validation-passes.d.ts.map +1 -1
  32. package/dist/loader/validation-passes.js +73 -6
  33. package/dist/loader/validation-passes.js.map +1 -1
  34. package/dist/registry-manifest.d.ts +1 -1
  35. package/dist/registry-manifest.js +1 -1
  36. package/dist/retired-vocabulary.d.ts.map +1 -1
  37. package/dist/retired-vocabulary.js +54 -11
  38. package/dist/retired-vocabulary.js.map +1 -1
  39. package/dist/vocabulary-rewrite.d.ts.map +1 -1
  40. package/dist/vocabulary-rewrite.js +115 -7
  41. package/dist/vocabulary-rewrite.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/attr-contradictions.ts +141 -0
  44. package/src/core/requirement/meta-requirement.ts +18 -1
  45. package/src/core/requirement/requirement-constants.ts +34 -1
  46. package/src/core/requirement/requirement-definition.embedded.ts +22 -4
  47. package/src/core/vocabulary-rewrite-yaml.ts +108 -0
  48. package/src/errors.ts +10 -0
  49. package/src/index.ts +9 -0
  50. package/src/loader/meta-data-loader.ts +6 -1
  51. package/src/loader/validation-passes.ts +95 -5
  52. package/src/registry-manifest.ts +1 -1
  53. package/src/retired-vocabulary.ts +54 -11
  54. package/src/vocabulary-rewrite.ts +125 -7
@@ -22,12 +22,19 @@
22
22
  // span replacement on the original text, and any region not deliberately changed comes back
23
23
  // byte-identical. That is the same guarantee the JSON arm makes, by the same means.
24
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
+ //
25
30
  // SIGIL-FREE, PER ADR-0006. YAML authoring writes bare attribute keys (`violation:`) and the
26
31
  // desugar re-adds the `@` when lowering to canonical JSON. So a rename emits a BARE key here
27
32
  // where the JSON arm emits `"@name"`. A leading `@` is still matched on input — an author who
28
33
  // wrote one gets it fixed rather than skipped — but is never introduced.
29
34
 
30
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";
31
38
  import {
32
39
  RETIRED_VOCABULARY,
33
40
  note,
@@ -163,6 +170,59 @@ function eachPair(
163
170
  }
164
171
  }
165
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
+
166
226
  /**
167
227
  * Rewrite retired vocabulary in one raw YAML metadata document.
168
228
  *
@@ -188,6 +248,47 @@ export function rewriteYamlDocument(source: string, opts: RewriteOpts = {}): Yam
188
248
  const inWindow = (e: RetiredEntry): boolean =>
189
249
  opts.maxVersion === undefined || atOrBefore(e.since, opts.maxVersion);
190
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
+
191
292
  eachPair(doc.contents, undefined, (pair, scope, flow) => {
192
293
  const key = keyText(pair.key);
193
294
  if (key === undefined) return;
@@ -240,6 +341,13 @@ export function rewriteYamlDocument(source: string, opts: RewriteOpts = {}): Yam
240
341
  const rw = entry.rewrite;
241
342
  if (rw === undefined) refuse();
242
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
+ //
243
351
  // Preserve the author's quoting style; YAML keys are usually bare, but a quoted key
244
352
  // must stay quoted or the surrounding style stops being self-consistent.
245
353
  const rawKey = source.slice(span.keyStart, span.keyEnd);
package/src/errors.ts CHANGED
@@ -196,6 +196,16 @@ export const ERROR_CODES = [
196
196
  // @fields), whichever is declared supplies no key, or a named field does not exist
197
197
  // on the owning entity's effective (resolved via extends) field set.
198
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",
199
209
  // #195 — origin.computed @expr: the expression tree's inferred root type does
200
210
  // not equal the carrying field's declared field.<subType>. A computed column's
201
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, validateSortableHasSupportedSubtype, 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";
@@ -611,6 +611,11 @@ export class MetaDataLoader {
611
611
  // (ADR-0039: resolving accessor, so inherited fields via extends are visible).
612
612
  errors.push(...validateIndexLookupFields(root));
613
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
+
614
619
  // Phase 2 — validation DERIVED FROM THE TYPE REGISTRY: each node's TypeDefinition
615
620
  // carries its reference descriptors + imperative validator, run as one recursive walk
616
621
  // over a built-once symbol table. A downstream provider's custom type validates itself
@@ -13,6 +13,7 @@
13
13
  import type { MetaData } from "../shared/meta-data.js";
14
14
  import type { MetaObject } from "../core/object/meta-object.js";
15
15
  import type { MetaReferenceIdentity } from "../core/identity/meta-identity.js";
16
+ import { contradictionHint, contradictionsFor } from "../attr-contradictions.js";
16
17
  import { ParseError, type ErrorCode } from "../errors.js";
17
18
  import { resolveObjectRef, didYouMeanHint } from "../naming-refs.js";
18
19
  import { PACKAGE_SEPARATOR, CHILD_REF_SEPARATOR } from "../shared/structural.js";
@@ -135,6 +136,13 @@ import {
135
136
  opsForSubType,
136
137
  opsForField,
137
138
  } from "../core/query/query-constants.js";
139
+ import {
140
+ REQUIREMENT,
141
+ REQUIREMENT_ATTR_STATUS,
142
+ REQUIREMENT_ATTR_IMPLEMENTED_BY,
143
+ REQUIREMENT_ATTR_SUPERSEDED_BY,
144
+ REQUIREMENT_STATUS_RETIRED,
145
+ } from "../core/requirement/requirement-constants.js";
138
146
 
139
147
  // ---------------------------------------------------------------------------
140
148
  // Layout dataGrid @defaultSortField validation
@@ -2165,15 +2173,25 @@ export function validateIndexLookupFields(root: MetaData): ParseError[] {
2165
2173
  const hasExpr = typeof exprRaw === "string" && exprRaw.trim().length > 0;
2166
2174
 
2167
2175
  // Rule 1a: exactly one of @fields / @expr may be DECLARED.
2176
+ //
2177
+ // The closing sentence comes from `attr-contradictions.ts`, which is also what
2178
+ // `meta upgrade` rewrites from — so the fix an adopter is TOLD about and the edit
2179
+ // the tool MAKES are one statement, and a change to either moves both. #337 is the
2180
+ // reason it names the command at all: an adopter shown only that their metadata is
2181
+ // invalid concludes the tool has a bug, because nothing points at the way out.
2168
2182
  if (hasFieldsAttr && hasExpr) {
2183
+ const contradiction = contradictionsFor(label).find(
2184
+ (c) => c.drop === INDEX_ATTR_FIELDS && c.keep === IDENTITY_ATTR_EXPR,
2185
+ );
2169
2186
  errors.push(
2170
2187
  new ParseError(
2188
+ // The site states WHICH node broke the rule; the table states the RULE and the
2189
+ // fix. Splitting it that way is what keeps one copy of each.
2171
2190
  `${label} "${node.name}" on "${obj.name}" declares BOTH ` +
2172
- `@${INDEX_ATTR_FIELDS} and @${IDENTITY_ATTR_EXPR}; they are the two ` +
2173
- `mutually exclusive ways to key an index. @${IDENTITY_ATTR_EXPR} is used ` +
2174
- `INSTEAD of @${INDEX_ATTR_FIELDS} — drop one. ` +
2175
- `(Declaring both previously loaded but silently discarded ` +
2176
- `@${INDEX_ATTR_FIELDS}.)`,
2191
+ `@${INDEX_ATTR_FIELDS} and @${IDENTITY_ATTR_EXPR}: ` +
2192
+ (contradiction === undefined
2193
+ ? `@${IDENTITY_ATTR_EXPR} is used INSTEAD of @${INDEX_ATTR_FIELDS} — drop one.`
2194
+ : contradictionHint(contradiction)),
2177
2195
  { code: "ERR_INVALID_INDEX", source: node.source },
2178
2196
  ),
2179
2197
  );
@@ -2392,3 +2410,75 @@ function checkProjectionFilterRefs(
2392
2410
  }
2393
2411
  }
2394
2412
  }
2413
+
2414
+ // ---------------------------------------------------------------------------
2415
+ // FR-039 — a retired requirement's link vocabulary
2416
+ //
2417
+ // Two rules, and the first is the whole structural point of the FR.
2418
+ //
2419
+ // 1. `@status: retired` may NOT carry `@implementedBy`. Not exempt from the
2420
+ // dangling-reference check — REFUSED. 0.24.0 removed the previous retired
2421
+ // vocabulary because `verify` was SILENT on dangling refs for it, hiding 29
2422
+ // unresolvable references across 14 entries in one estate while reporting
2423
+ // zero. That silence was a deliberate EXEMPTION, so the fix was diagnosed as
2424
+ // "delete the vocabulary". Forbidding the attribute makes the bug class
2425
+ // UNREACHABLE instead: a retired capability has no implementation by
2426
+ // definition, so its references cannot dangle because they cannot exist.
2427
+ //
2428
+ // 2. `@supersededBy` is legal ONLY on `retired`. It names what REPLACED a
2429
+ // withdrawn capability; on a live one there is nothing to have replaced it.
2430
+ // Resolution of the reference itself is `verify`'s job (it owns every other
2431
+ // @implementedBy-style resolution) — the loader owns the shape.
2432
+ //
2433
+ // Runs in every port. `requirement.*` gate logic otherwise lives only in the TS
2434
+ // CLI, which is exactly why THIS rule belongs in the loader: a Java or Python
2435
+ // estate would otherwise be free to author the shape the rule exists to prevent.
2436
+ //
2437
+ // ADR-0039: children()/attr() throughout — never own* — so a requirement that
2438
+ // inherits its status through `extends` is judged on its EFFECTIVE status.
2439
+ // ---------------------------------------------------------------------------
2440
+
2441
+ export function validateRetiredRequirementLinks(root: MetaData): ParseError[] {
2442
+ const errors: ParseError[] = [];
2443
+
2444
+ const walk = (node: MetaData): void => {
2445
+ for (const child of node.children()) {
2446
+ if (child.type === REQUIREMENT) {
2447
+ const status = child.attr(REQUIREMENT_ATTR_STATUS);
2448
+ const isRetired = status === REQUIREMENT_STATUS_RETIRED;
2449
+
2450
+ if (isRetired && child.attr(REQUIREMENT_ATTR_IMPLEMENTED_BY) !== undefined) {
2451
+ errors.push(
2452
+ new ParseError(
2453
+ `requirement.${child.subType} "${child.name}" is @status: ` +
2454
+ `${REQUIREMENT_STATUS_RETIRED} and declares @${REQUIREMENT_ATTR_IMPLEMENTED_BY}. ` +
2455
+ `A retired capability has no implementation — that is what retiring it means. ` +
2456
+ `Delete the attribute; if the nodes are still there, the capability is not retired. ` +
2457
+ `What used to implement it belongs in \`notes\`, and what REPLACED it in ` +
2458
+ `@${REQUIREMENT_ATTR_SUPERSEDED_BY}.`,
2459
+ { code: "ERR_REQUIREMENT_RETIRED_HAS_IMPLEMENTORS", source: child.source },
2460
+ ),
2461
+ );
2462
+ }
2463
+
2464
+ if (!isRetired && child.attr(REQUIREMENT_ATTR_SUPERSEDED_BY) !== undefined) {
2465
+ errors.push(
2466
+ new ParseError(
2467
+ `requirement.${child.subType} "${child.name}" declares ` +
2468
+ `@${REQUIREMENT_ATTR_SUPERSEDED_BY} but its @${REQUIREMENT_ATTR_STATUS} is ` +
2469
+ `"${status ?? "(absent)"}". That attribute names the requirement which REPLACED a ` +
2470
+ `withdrawn one, so it is legal only on @${REQUIREMENT_ATTR_STATUS}: ` +
2471
+ `${REQUIREMENT_STATUS_RETIRED}.`,
2472
+ { code: "ERR_REQUIREMENT_SUPERSEDED_BY_NOT_RETIRED", source: child.source },
2473
+ ),
2474
+ );
2475
+ }
2476
+ }
2477
+ // Hierarchy IS nesting, so a retired requirement can sit at any depth.
2478
+ walk(child);
2479
+ }
2480
+ };
2481
+
2482
+ walk(root);
2483
+ return errors;
2484
+ }
@@ -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.12";
115
+ export const METAMODEL_VERSION = "0.13";
116
116
 
117
117
  /** The full canonical manifest. All collections are sorted for byte-stability. */
118
118
  interface RegistryManifest {
@@ -25,6 +25,11 @@
25
25
  // already decided to fail, and it returns undefined for anything it does not recognise, so
26
26
  // `@maxLenght` still reports as an unknown attribute. The map speaks only where it KNOWS.
27
27
  //
28
+ // ITS SIBLING IS `attr-contradictions.ts` — pairs of LIVE attributes that may not sit on one
29
+ // node. Same two consumers (the loader's diagnostic and the `meta upgrade` rewriter), same
30
+ // reason for existing; kept apart because a retirement matches ONE name while a contradiction
31
+ // matches a PAIR, and merging them would give every entry here fields it can never use.
32
+ //
28
33
  // CROSS-PORT: this is a DIAGNOSTIC, not registered vocabulary — it affects no registry
29
34
  // manifest and no load outcome, so it carries no registry-conformance obligation. The other
30
35
  // four ports fail identically today, just with the generic message; mirroring this map is a
@@ -91,6 +96,7 @@ export interface RetiredEntry extends RetirementNote {
91
96
  }
92
97
 
93
98
  const REQUIREMENT_MIGRATION = "docs/features/migrations/verified-by-retirement.md";
99
+ const RETIRED_STATUS_MIGRATION = "docs/features/migrations/retired-status-restore.md";
94
100
 
95
101
  export const RETIRED_VOCABULARY: readonly RetiredEntry[] = [
96
102
  // ── 0.24.0: `@violation` is renamed `@counterexample` ──
@@ -124,21 +130,58 @@ export const RETIRED_VOCABULARY: readonly RetiredEntry[] = [
124
130
  // behaviour anyone else can observe.
125
131
  rewrite: { kind: "dropAttr" },
126
132
  },
133
+ // (@supersededBy is NOT retired vocabulary. 0.24.0 deregistered it; FR-039 registers
134
+ // it again on `retired` only, and this time it RESOLVES — which is what the
135
+ // 2026-08-10 ruling asked for at point 4 and never got. An entry here would make
136
+ // `meta upgrade` delete an attribute the loader now accepts.)
137
+ // ── FR-039 (0.24.2): `abandoned` / `superseded` become `retired` ──
138
+ //
139
+ // These were retired in 0.24.0 on the rule that a requirement never journals what
140
+ // happened, and `meta upgrade` REFUSED them because what becomes of a retired
141
+ // capability's record was judgement. FR-039 restores the capability under a name
142
+ // that states the standing rule rather than the history — `retired` means "this
143
+ // must not be rebuilt" — so the edit is no longer a judgement call and the tool
144
+ // can make it.
145
+ //
146
+ // TWO entries rather than one value-map, because the rewriter's `renameAttrValue`
147
+ // carries a single `fromValue` and `attrValues` already scopes each occurrence to
148
+ // the value it fires on. `otherwise` is therefore unreachable, and is `refuse` so
149
+ // that a future third member cannot be silently dropped by this entry.
150
+ //
151
+ // `@implementedBy` on one of these is handled by ATTR_CONTRADICTIONS, not here —
152
+ // it is a live attribute made illegal by a sibling's VALUE, which is a different
153
+ // match shape. Both passes run in one `meta upgrade`, so a legacy entry carrying
154
+ // both is fully repaired in a single run rather than rewritten into a file that
155
+ // still will not load.
127
156
  {
128
- type: "requirement", subType: "*", attr: "supersededBy",
129
- since: "0.24.0",
130
- why: "a requirement is prescriptive — it states what should be true and is never a " +
131
- "journal of what happened",
132
- migration: REQUIREMENT_MIGRATION,
133
- rewrite: { kind: "dropAttr" },
157
+ type: "requirement", subType: "*", attr: "status",
158
+ attrValues: ["abandoned"],
159
+ since: "0.24.2",
160
+ why: "a retired capability is recorded as `retired`, which states the standing rule " +
161
+ "(do not rebuild this) rather than narrating what happened to it",
162
+ migration: RETIRED_STATUS_MIGRATION,
163
+ rewrite: {
164
+ kind: "renameAttrValue",
165
+ toAttr: "status",
166
+ fromValue: "abandoned",
167
+ toValue: "retired",
168
+ otherwise: "refuse",
169
+ },
134
170
  },
135
171
  {
136
172
  type: "requirement", subType: "*", attr: "status",
137
- attrValues: ["abandoned", "superseded"],
138
- since: "0.24.0",
139
- why: "retiring a capability is DELETING its requirement; version control holds that it " +
140
- "existed, and `notes` on a surviving entry holds what a reader still needs",
141
- migration: REQUIREMENT_MIGRATION,
173
+ attrValues: ["superseded"],
174
+ since: "0.24.2",
175
+ why: "`superseded` was `retired` plus a pointer, and the pointer is @supersededBy " +
176
+ "which is registered again, and now RESOLVES",
177
+ migration: RETIRED_STATUS_MIGRATION,
178
+ rewrite: {
179
+ kind: "renameAttrValue",
180
+ toAttr: "status",
181
+ fromValue: "superseded",
182
+ toValue: "retired",
183
+ otherwise: "refuse",
184
+ },
142
185
  },
143
186
 
144
187
  // ── FR-037 R1: @readOnly becomes the @mutability enum (0.24.0) ──
@@ -40,12 +40,20 @@
40
40
  // (flow mappings, `{ name: x, readOnly: true }`) was not matched at all. YAML's value extent
41
41
  // is not derivable by scanning; JSON's is.
42
42
  //
43
+ // IT ALSO RESOLVES ATTRIBUTE CONTRADICTIONS — two LIVE attributes that may not sit on one
44
+ // node (`attr-contradictions.ts`). Same machinery, different match: a retirement finds one
45
+ // key, a contradiction finds a PAIR inside one node body, which is exactly what `scopeRanges`
46
+ // already answers. Doing it by proximity instead ("a `fields` with an `expr` near it") was
47
+ // tried and took a `fields` whose neighbouring `expr` belonged to a SIBLING node.
48
+ //
43
49
  // IT REFUSES WHAT IT CANNOT KNOW. A retirement with no `rewrite` (`@status: abandoned`) is
44
50
  // reported, never guessed at. Deleting the node, retyping it, and fixing the residue it
45
51
  // describes are all defensible, and a wrong guess emits metadata that LOADS and means
46
52
  // something different — strictly worse than leaving it alone, because the adopter would
47
53
  // believe the migration finished.
48
54
 
55
+ import { ATTR_CONTRADICTIONS, contradictionScopeMatches } from "./attr-contradictions.js";
56
+ import type { AttrContradiction } from "./attr-contradictions.js";
49
57
  import {
50
58
  RETIRED_VOCABULARY,
51
59
  note,
@@ -158,15 +166,71 @@ function scopeRanges(source: string): ScopeRange[] {
158
166
  return ranges;
159
167
  }
160
168
 
161
- /** The type key governing `offset` — the innermost body containing it. */
162
- function scopeAt(ranges: readonly ScopeRange[], offset: number): string | undefined {
169
+ /** The innermost node body containing `offset`. */
170
+ function scopeRangeAt(ranges: readonly ScopeRange[], offset: number): ScopeRange | undefined {
163
171
  let best: ScopeRange | undefined;
164
172
  for (const r of ranges) {
165
173
  if (offset <= r.bodyStart || offset >= r.bodyEnd) continue;
166
174
  // Properly nested ranges: the innermost containing one starts last.
167
175
  if (best === undefined || r.bodyStart > best.bodyStart) best = r;
168
176
  }
169
- return best?.typeKey;
177
+ return best;
178
+ }
179
+
180
+ /** The type key governing `offset`. Derived from the range so the two cannot disagree —
181
+ * a retirement asks WHICH type, a contradiction asks WHICH NODE, and answering them from
182
+ * two separate walks is how the pair-matching would drift from the scoping. */
183
+ function scopeAt(ranges: readonly ScopeRange[], offset: number): string | undefined {
184
+ return scopeRangeAt(ranges, offset)?.typeKey;
185
+ }
186
+
187
+ /** One key occurrence: where the key starts, and where its value begins. */
188
+ interface KeySite {
189
+ readonly keyStart: number;
190
+ readonly afterKey: number;
191
+ }
192
+
193
+ /** Every place `attr` appears as a key inside `range`'s OWN body — a nested node's key of
194
+ * the same name belongs to that node, not to this one. Containment alone is not enough:
195
+ * a node body contains its children's bodies, so the innermost enclosing range must BE
196
+ * this range. That identity test is what proximity matching cannot express. */
197
+ function ownKeys(
198
+ source: string,
199
+ ranges: readonly ScopeRange[],
200
+ range: ScopeRange,
201
+ attr: string,
202
+ ): KeySite[] {
203
+ const out: KeySite[] = [];
204
+ const re = keyPattern(attr);
205
+ let m: RegExpExecArray | null;
206
+ while ((m = re.exec(source)) !== null) {
207
+ if (m.index <= range.bodyStart || m.index >= range.bodyEnd) continue;
208
+ if (scopeRangeAt(ranges, m.index)?.bodyStart !== range.bodyStart) continue;
209
+ out.push({ keyStart: m.index, afterKey: m.index + m[0].length });
210
+ }
211
+ return out;
212
+ }
213
+
214
+
215
+ /** True when `keep` holds one of the entry's `keepValues` (or the entry names none,
216
+ * in which case mere presence is the contradiction). */
217
+ function keepValueMatches(source: string, site: KeySite, c: AttrContradiction): boolean {
218
+ if (c.keepValues === undefined) return true;
219
+ const raw = valueSpan(source, site.afterKey)?.raw;
220
+ if (raw === undefined) return false;
221
+ return c.keepValues.some((v) => rawEquals(raw, v));
222
+ }
223
+
224
+ /** Does this key carry a string that actually says something? */
225
+ function suppliesText(source: string, site: KeySite): boolean {
226
+ const raw = valueSpan(source, site.afterKey)?.raw;
227
+ if (raw === undefined) return false;
228
+ try {
229
+ const v: unknown = JSON.parse(raw);
230
+ return typeof v === "string" && v.trim().length > 0;
231
+ } catch {
232
+ return false;
233
+ }
170
234
  }
171
235
 
172
236
  /**
@@ -282,6 +346,49 @@ export function rewriteDocument(source: string, opts: RewriteOpts = {}): Rewrite
282
346
  }
283
347
  }
284
348
 
349
+ // ── Attribute contradictions: two LIVE attrs that may not sit on one node ──
350
+ //
351
+ // Matched per NODE, not per occurrence, because the illegal thing is the pair. `ownKeys`
352
+ // supplies the node-identity test the proximity approach could not: a `@fields` and an
353
+ // `@expr` that merely appear near each other may belong to different siblings.
354
+ //
355
+ // THE TWO SIDES ARE ASKED DIFFERENT QUESTIONS, mirroring the loader's Rule 1a exactly
356
+ // (`validation-passes.ts`, `hasFieldsAttr` vs `hasExpr`). The DROP side counts on
357
+ // PRESENCE — `@fields: []` beside `@expr` is still a declaration of both, and is the case
358
+ // where the discard is total. The KEEP side counts only when it actually supplies a key,
359
+ // so `@expr: ""` beside `@fields` is a plain column index the loader accepts and this
360
+ // must not touch. If those two predicates ever diverge, this deletes an attribute from a
361
+ // document that was loading.
362
+ //
363
+ // IT SEES ONLY THIS NODE'S OWN TEXT. A node declaring `@expr` while INHERITING `@fields`
364
+ // through `extends` contradicts itself in the loaded model and not on the page, and no
365
+ // raw-text rewriter can resolve a super-reference. That case stays a refusal from the
366
+ // loader — correctly, since the fix is on the parent and is the adopter's call.
367
+ for (const range of ranges) {
368
+ for (const c of ATTR_CONTRADICTIONS) {
369
+ if (opts.maxVersion !== undefined && !atOrBefore(c.since, opts.maxVersion)) continue;
370
+ if (!contradictionScopeMatches(c, range.typeKey)) continue;
371
+ // `keep` must be present AND, when the entry names values, hold one of them —
372
+ // otherwise @status and @implementedBy would contradict on every status.
373
+ if (!ownKeys(source, ranges, range, c.keep).some(
374
+ (k) => suppliesText(source, k) && keepValueMatches(source, k, c),
375
+ )) continue;
376
+
377
+ for (const site of ownKeys(source, ranges, range, c.drop)) {
378
+ const span = valueSpan(source, site.afterKey);
379
+ if (span === undefined) continue;
380
+ const { start, end } = dropSpan(source, site.keyStart, span.end);
381
+ edits.push({ start, end, text: "" });
382
+ changes.push({
383
+ attr: c.drop,
384
+ from: c.drop,
385
+ to: `(removed — @${c.keep} keys this node)`,
386
+ line: lineAt(source, site.keyStart),
387
+ });
388
+ }
389
+ }
390
+ }
391
+
285
392
  for (const entry of RETIRED_VOCABULARY.filter((e) => e.attr !== undefined && inWindow(e))) {
286
393
  const attr = entry.attr as string;
287
394
  const re = keyPattern(attr);
@@ -293,8 +400,9 @@ export function rewriteDocument(source: string, opts: RewriteOpts = {}): Rewrite
293
400
  const afterKey = m.index + m[0].length;
294
401
 
295
402
  // Scope is decided HERE, per occurrence, from the enclosing node.
296
- const scope = scopeAt(ranges, keyStart);
297
- if (scope === undefined || !scopeMatches(entry, scope)) continue;
403
+ const scopeRange = scopeRangeAt(ranges, keyStart);
404
+ const scope = scopeRange?.typeKey;
405
+ if (scope === undefined || scopeRange === undefined || !scopeMatches(entry, scope)) continue;
298
406
 
299
407
  const line = lineAt(source, keyStart);
300
408
  const span = valueSpan(source, afterKey);
@@ -325,8 +433,18 @@ export function rewriteDocument(source: string, opts: RewriteOpts = {}): Rewrite
325
433
  const rw = entry.rewrite;
326
434
  if (rw === undefined) refuse();
327
435
  else if (rw.kind === "renameAttr") {
328
- edits.push({ start: keyStart, end: keyEnd, text: `"@${rw.to}"` });
329
- changes.push({ attr, from: attr, to: rw.to, line });
436
+ // A rename onto a key the node ALREADY declares would emit a duplicate — two
437
+ // `"@counterexample"` members in one object, where JSON parsers silently take
438
+ // the last and the author's surviving text is the one that loses. Refuse
439
+ // instead: which of the two sentences is the real one is exactly the judgement
440
+ // `meta upgrade` does not make.
441
+ const target = ownKeys(source, ranges, scopeRange, rw.to)
442
+ .filter((k) => k.keyStart !== keyStart);
443
+ if (target.length > 0) refuse();
444
+ else {
445
+ edits.push({ start: keyStart, end: keyEnd, text: `"@${rw.to}"` });
446
+ changes.push({ attr, from: attr, to: rw.to, line });
447
+ }
330
448
  } else if (rw.kind === "dropAttr") drop();
331
449
  else if (span === undefined) continue;
332
450
  else if (rawEquals(span.raw, rw.fromValue)) {