@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
@@ -49,9 +49,9 @@ export const ORIGIN_DEFINITION: ProviderDefinition = {
49
49
  {
50
50
  "type": "origin",
51
51
  "subType": "aggregate",
52
- "description": "A value reduced from the related row-set reached along a relationship path (@via) from the base entity: count/sum/avg/min/max over a column (@of); any/all predicate quantifiers over a @filter; or collect (an array rollup of @of).",
52
+ "description": "A value reduced from the related row-set reached along a relationship path (@via) from the base entity: count/sum/avg/min/max over a column (@of); any/all predicate quantifiers over a @filter; or collect (an array rollup of the @of column, or of the carrying field.object's declared @objectRef value object when @of is omitted).",
53
53
  "whenToUse": "A projection needs a value derived by reducing related rows — a count/sum/avg/min/max, a 'did any/every related row match' flag, or an array of collected values. Declare it instead of hand-writing the aggregate query — it stays consistent and regenerates.",
54
- "rules": "@via may be omitted only when exactly one single-hop relationship leads from the base entity to the @of entity (single-hop-unique inference; FR-024, ADR-0029). Multi-hop paths must always be stated explicitly. @of is required for count/sum/avg/min/max/collect and forbidden for any/all (which quantify over rows via @filter, not a column). @filter is required for any/all. The field must be isArray:true for collect and isArray:false for every other @agg. @distinct and @orderBy are collect-only.",
54
+ "rules": "@via may be omitted only when exactly one single-hop relationship leads from the base entity to the @of entity (single-hop-unique inference; FR-024, ADR-0029). Multi-hop paths must always be stated explicitly. @of is required for count/sum/avg/min/max and forbidden for any/all (which quantify over rows via @filter, not a column). @filter is required for any/all. The field must be isArray:true for collect and isArray:false for every other @agg. @distinct and @orderBy are collect-only. On @agg:collect @of is OPTIONAL: omitting it declares a WHOLE-OBJECT rollup, which collects each related row as the carrying field's declared @objectRef value object instead of one scalar column. A whole-object rollup requires a field.object carrying @objectRef, requires that @objectRef to name an object.value, requires an explicit @via (there is no @of entity to infer the path from), and refuses @distinct. Its @orderBy keys resolve against the @via TERMINAL entity, not the head or a middle hop. Its value-object members bind to the terminal entity's fields BY NAME — member name == terminal field name, deliberately NOT extends, so one value object stays collectable from two different entities — and every member must match a terminal field agreeing on BOTH field.<subType> and array-ness.",
55
55
  "children": [
56
56
  {
57
57
  "type": "attr",
@@ -69,7 +69,7 @@ export const ORIGIN_DEFINITION: ProviderDefinition = {
69
69
  "all",
70
70
  "collect"
71
71
  ],
72
- "description": "The reducing function applied over the related row-set: count/sum/avg/min/max (numeric/ordinal reduces over @of); any/all (predicate quantifiers over @filter — @of forbidden; empty set → any=false, all=true); collect (array rollup of @of the field must be isArray)."
72
+ "description": "The reducing function applied over the related row-set: count/sum/avg/min/max (numeric/ordinal reduces over @of); any/all (predicate quantifiers over @filter — @of forbidden; empty set → any=false, all=true); collect (array rollup of the @of column, or of the carrying field.object's declared @objectRef value object when @of is omitted; the field must be isArray)."
73
73
  },
74
74
  {
75
75
  "type": "attr",
@@ -77,7 +77,7 @@ export const ORIGIN_DEFINITION: ProviderDefinition = {
77
77
  "name": "of",
78
78
  "min": 0,
79
79
  "max": 1,
80
- "description": "Dotted Entity.field reference identifying the column being aggregated (e.g. 'Week.durationMinutes'). Required for count/sum/avg/min/max/collect; forbidden for any/all (which quantify over rows via @filter, not a column)."
80
+ "description": "Dotted Entity.field reference identifying the column being aggregated (e.g. 'Week.durationMinutes'). Required for count/sum/avg/min/max; OPTIONAL for collect, where absent means a whole-object rollup of the field's declared @objectRef value object; forbidden for any/all (which quantify over rows via @filter, not a column)."
81
81
  },
82
82
  {
83
83
  "type": "attr",
@@ -101,7 +101,7 @@ export const ORIGIN_DEFINITION: ProviderDefinition = {
101
101
  "name": "distinct",
102
102
  "min": 0,
103
103
  "max": 1,
104
- "description": "Set (collect-only) to dedupe collected values (set semantics)."
104
+ "description": "Set (collect-only) to dedupe collected values (set semantics). Not supported on a whole-object collect (@of omitted): it is a guaranteed no-op whenever the value object carries the primary key, and a silent no-op is worse than a refusal."
105
105
  },
106
106
  {
107
107
  "type": "attr",
@@ -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.10";
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) ──
@@ -27,14 +27,24 @@
27
27
  // `scopeRanges` below recovers the enclosing `"<type>.<subType>"` for each occurrence, so
28
28
  // one pass over the document answers both correctly.
29
29
  //
30
- // CANONICAL JSON ONLY. YAML authoring is real (ADR-0006) but is not rewritable here: a
31
- // correct YAML editor needs the `yaml` package's CST, and this module is reachable from
32
- // `src/index.ts`, which the browser-safety test forbids from importing it. A hand-rolled
33
- // YAML mode was tried and shipped a file-corrupting bug a multi-item block sequence lost
34
- // every item but the first, because the value scanner stops at a newline while the
35
- // dominant in-repo authoring style (flow mappings, `{ name: x, readOnly: true }`) was not
36
- // matched at all, so the rename silently did nothing. `meta upgrade` refuses YAML by name
37
- // instead; a refusal an adopter can act on beats a success they cannot trust.
30
+ // CANONICAL JSON ONLY YAML lives in `core/vocabulary-rewrite-yaml.ts`, not here. This
31
+ // module is reachable from `src/index.ts`, which may not import the Node-only `yaml`
32
+ // package, so the YAML arm sits behind its own package subpath and `meta upgrade`
33
+ // dynamic-imports it. The split is a bundling constraint, not a difference in contract: both
34
+ // arms return the same result shape, scope every occurrence the same way, and refuse the
35
+ // same retirements.
36
+ //
37
+ // The reason YAML gets a parser and this arm does not: a hand-rolled YAML mode was tried
38
+ // here first and shipped a file-corrupting bug — a multi-item block sequence lost every item
39
+ // but the first, because a scanner stops at a newline — while the dominant authoring style
40
+ // (flow mappings, `{ name: x, readOnly: true }`) was not matched at all. YAML's value extent
41
+ // is not derivable by scanning; JSON's is.
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.
38
48
  //
39
49
  // IT REFUSES WHAT IT CANNOT KNOW. A retirement with no `rewrite` (`@status: abandoned`) is
40
50
  // reported, never guessed at. Deleting the node, retyping it, and fixing the residue it
@@ -42,6 +52,8 @@
42
52
  // something different — strictly worse than leaving it alone, because the adopter would
43
53
  // believe the migration finished.
44
54
 
55
+ import { ATTR_CONTRADICTIONS, contradictionScopeMatches } from "./attr-contradictions.js";
56
+ import type { AttrContradiction } from "./attr-contradictions.js";
45
57
  import {
46
58
  RETIRED_VOCABULARY,
47
59
  note,
@@ -154,15 +166,71 @@ function scopeRanges(source: string): ScopeRange[] {
154
166
  return ranges;
155
167
  }
156
168
 
157
- /** The type key governing `offset` — the innermost body containing it. */
158
- 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 {
159
171
  let best: ScopeRange | undefined;
160
172
  for (const r of ranges) {
161
173
  if (offset <= r.bodyStart || offset >= r.bodyEnd) continue;
162
174
  // Properly nested ranges: the innermost containing one starts last.
163
175
  if (best === undefined || r.bodyStart > best.bodyStart) best = r;
164
176
  }
165
- 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
+ }
166
234
  }
167
235
 
168
236
  /**
@@ -278,6 +346,49 @@ export function rewriteDocument(source: string, opts: RewriteOpts = {}): Rewrite
278
346
  }
279
347
  }
280
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
+
281
392
  for (const entry of RETIRED_VOCABULARY.filter((e) => e.attr !== undefined && inWindow(e))) {
282
393
  const attr = entry.attr as string;
283
394
  const re = keyPattern(attr);
@@ -289,8 +400,9 @@ export function rewriteDocument(source: string, opts: RewriteOpts = {}): Rewrite
289
400
  const afterKey = m.index + m[0].length;
290
401
 
291
402
  // Scope is decided HERE, per occurrence, from the enclosing node.
292
- const scope = scopeAt(ranges, keyStart);
293
- 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;
294
406
 
295
407
  const line = lineAt(source, keyStart);
296
408
  const span = valueSpan(source, afterKey);
@@ -321,8 +433,18 @@ export function rewriteDocument(source: string, opts: RewriteOpts = {}): Rewrite
321
433
  const rw = entry.rewrite;
322
434
  if (rw === undefined) refuse();
323
435
  else if (rw.kind === "renameAttr") {
324
- edits.push({ start: keyStart, end: keyEnd, text: `"@${rw.to}"` });
325
- 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
+ }
326
448
  } else if (rw.kind === "dropAttr") drop();
327
449
  else if (span === undefined) continue;
328
450
  else if (rawEquals(span.raw, rw.fromValue)) {