@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.
- package/dist/attr-contradictions.d.ts +52 -0
- package/dist/attr-contradictions.d.ts.map +1 -0
- package/dist/attr-contradictions.js +100 -0
- package/dist/attr-contradictions.js.map +1 -0
- package/dist/core/requirement/meta-requirement.d.ts +9 -1
- package/dist/core/requirement/meta-requirement.d.ts.map +1 -1
- package/dist/core/requirement/meta-requirement.js +15 -2
- package/dist/core/requirement/meta-requirement.js.map +1 -1
- package/dist/core/requirement/requirement-constants.d.ts +30 -2
- package/dist/core/requirement/requirement-constants.d.ts.map +1 -1
- package/dist/core/requirement/requirement-constants.js +32 -1
- package/dist/core/requirement/requirement-constants.js.map +1 -1
- package/dist/core/requirement/requirement-definition.embedded.d.ts.map +1 -1
- package/dist/core/requirement/requirement-definition.embedded.js +22 -4
- package/dist/core/requirement/requirement-definition.embedded.js.map +1 -1
- package/dist/core/vocabulary-rewrite-yaml.d.ts.map +1 -1
- package/dist/core/vocabulary-rewrite-yaml.js +103 -0
- package/dist/core/vocabulary-rewrite-yaml.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +10 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/loader/meta-data-loader.d.ts.map +1 -1
- package/dist/loader/meta-data-loader.js +5 -1
- package/dist/loader/meta-data-loader.js.map +1 -1
- package/dist/loader/validation-passes.d.ts +1 -0
- package/dist/loader/validation-passes.d.ts.map +1 -1
- package/dist/loader/validation-passes.js +73 -6
- package/dist/loader/validation-passes.js.map +1 -1
- package/dist/registry-manifest.d.ts +1 -1
- package/dist/registry-manifest.js +1 -1
- package/dist/retired-vocabulary.d.ts.map +1 -1
- package/dist/retired-vocabulary.js +54 -11
- package/dist/retired-vocabulary.js.map +1 -1
- package/dist/vocabulary-rewrite.d.ts.map +1 -1
- package/dist/vocabulary-rewrite.js +115 -7
- package/dist/vocabulary-rewrite.js.map +1 -1
- package/package.json +1 -1
- package/src/attr-contradictions.ts +141 -0
- package/src/core/requirement/meta-requirement.ts +18 -1
- package/src/core/requirement/requirement-constants.ts +34 -1
- package/src/core/requirement/requirement-definition.embedded.ts +22 -4
- package/src/core/vocabulary-rewrite-yaml.ts +108 -0
- package/src/errors.ts +10 -0
- package/src/index.ts +9 -0
- package/src/loader/meta-data-loader.ts +6 -1
- package/src/loader/validation-passes.ts +95 -5
- package/src/registry-manifest.ts +1 -1
- package/src/retired-vocabulary.ts +54 -11
- 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}
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
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
|
+
}
|
package/src/registry-manifest.ts
CHANGED
|
@@ -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.
|
|
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: "
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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: ["
|
|
138
|
-
since: "0.24.
|
|
139
|
-
why: "
|
|
140
|
-
"
|
|
141
|
-
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
|
|
162
|
-
function
|
|
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
|
|
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
|
|
297
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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)) {
|