@metaobjectsdev/cli 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/src/commands/upgrade.d.ts.map +1 -1
- package/dist/src/commands/upgrade.js +13 -7
- package/dist/src/commands/upgrade.js.map +1 -1
- package/dist/src/commands/verify.d.ts.map +1 -1
- package/dist/src/commands/verify.js +46 -10
- package/dist/src/commands/verify.js.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +7 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/args.d.ts +8 -0
- package/dist/src/lib/args.d.ts.map +1 -1
- package/dist/src/lib/args.js +2 -0
- package/dist/src/lib/args.js.map +1 -1
- package/dist/src/lib/codegen-drift.d.ts.map +1 -1
- package/dist/src/lib/codegen-drift.js +47 -6
- package/dist/src/lib/codegen-drift.js.map +1 -1
- package/dist/src/lib/requirement-check.d.ts +61 -7
- package/dist/src/lib/requirement-check.d.ts.map +1 -1
- package/dist/src/lib/requirement-check.js +113 -29
- package/dist/src/lib/requirement-check.js.map +1 -1
- package/dist/src/lib/requirement-lint.d.ts +29 -0
- package/dist/src/lib/requirement-lint.d.ts.map +1 -0
- package/dist/src/lib/requirement-lint.js +318 -0
- package/dist/src/lib/requirement-lint.js.map +1 -0
- package/package.json +10 -10
- package/src/commands/upgrade.ts +13 -7
- package/src/commands/verify.ts +51 -8
- package/src/index.ts +7 -0
- package/src/lib/args.ts +10 -0
- package/src/lib/codegen-drift.ts +54 -6
- package/src/lib/requirement-check.ts +150 -32
- package/src/lib/requirement-lint.ts +356 -0
package/src/lib/codegen-drift.ts
CHANGED
|
@@ -2,10 +2,25 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Regenerates the configured codegen into a throwaway temp directory and DIFFs
|
|
4
4
|
// the freshly-generated file tree against the committed output (the config's
|
|
5
|
-
// outDir / per-target outDirs).
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// outDir / per-target outDirs). Reuses the exact same `runGen` pipeline `meta gen`
|
|
6
|
+
// uses, so the comparison is faithful.
|
|
7
|
+
//
|
|
8
|
+
// A DIFFERENCE IS NOT AUTOMATICALLY DRIFT. This gate used to treat "metadata
|
|
9
|
+
// changed but `meta gen` wasn't re-run" and "a generated file was hand-edited" as
|
|
10
|
+
// one verdict. Only the first is drift; the second is the documented workflow —
|
|
11
|
+
// `meta gen` three-way-merges hand edits and reports "merged", and the product
|
|
12
|
+
// says in as many words that anything inside a generated file is fair game to
|
|
13
|
+
// edit. Convicting both made the gate unusable for anyone who took that offer,
|
|
14
|
+
// and its printed remedy was a LOOP: running `meta gen` merges the edit back in,
|
|
15
|
+
// so the next run failed identically. Requirement-test stubs were the worst case,
|
|
16
|
+
// being worthless until hand-edited.
|
|
17
|
+
//
|
|
18
|
+
// The discriminator is `.gen-state/.hashes.json`, which records what the GENERATOR
|
|
19
|
+
// WROTE rather than what the file became, and is the committed half of `.gen-state`
|
|
20
|
+
// precisely so this is answerable on a machine that did not generate the output.
|
|
21
|
+
// When a fresh regen hashes to what we recorded writing, the generator's
|
|
22
|
+
// contribution is current and the on-disk difference is a preserved hand edit.
|
|
23
|
+
// Design: spec/design-docs/2026-08-27-codegen-drift-hand-edits-design.md
|
|
9
24
|
//
|
|
10
25
|
// Faithfulness note: generated content can embed import paths computed RELATIVE
|
|
11
26
|
// to a target's outDir (and between targets). To keep the regen byte-identical
|
|
@@ -24,7 +39,12 @@ import {
|
|
|
24
39
|
} from "node:fs";
|
|
25
40
|
import { tmpdir } from "node:os";
|
|
26
41
|
import { join, relative, resolve, isAbsolute } from "node:path";
|
|
27
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
runGen,
|
|
44
|
+
contentHash,
|
|
45
|
+
readGeneratedHash,
|
|
46
|
+
listGeneratedPaths,
|
|
47
|
+
} from "@metaobjectsdev/codegen-ts";
|
|
28
48
|
import type { MetaobjectsGenConfig } from "@metaobjectsdev/codegen-ts";
|
|
29
49
|
import type { MetaData } from "@metaobjectsdev/metadata";
|
|
30
50
|
|
|
@@ -97,6 +117,11 @@ export async function computeCodegenDrift(
|
|
|
97
117
|
): Promise<CodegenDriftResult> {
|
|
98
118
|
const root = isAbsolute(projectRoot) ? projectRoot : resolve(projectRoot);
|
|
99
119
|
|
|
120
|
+
// The PROJECT's snapshot manifest, never the temp one built below — that temp
|
|
121
|
+
// manifest only describes the regen that just ran, so it can say nothing about
|
|
122
|
+
// what `meta gen` last wrote here. Same default location runner.ts derives.
|
|
123
|
+
const projectGenStateDir = join(root, ".metaobjects", ".gen-state");
|
|
124
|
+
|
|
100
125
|
const committedDirs = committedOutDirs(config, root);
|
|
101
126
|
if (committedDirs.length === 0) {
|
|
102
127
|
return {
|
|
@@ -171,6 +196,11 @@ export async function computeCodegenDrift(
|
|
|
171
196
|
// Diff each committed outDir against its temp mirror.
|
|
172
197
|
const driftedFiles = new Set<string>();
|
|
173
198
|
const lines: string[] = [];
|
|
199
|
+
|
|
200
|
+
// Whether we have any record of what we wrote here. With records, the orphan
|
|
201
|
+
// branch below can scope itself to our own output; with none, it cannot tell
|
|
202
|
+
// our stale output from a stranger's file, so the old verdict stands.
|
|
203
|
+
const haveWriteRecords = listGeneratedPaths(projectGenStateDir).length > 0;
|
|
174
204
|
for (const committed of committedDirs) {
|
|
175
205
|
const fresh = tempFor(committed);
|
|
176
206
|
const committedFiles = new Set(listFiles(committed));
|
|
@@ -181,6 +211,19 @@ export async function computeCodegenDrift(
|
|
|
181
211
|
const inCommitted = committedFiles.has(rel);
|
|
182
212
|
const inFresh = freshFiles.has(rel);
|
|
183
213
|
if (inCommitted && !inFresh) {
|
|
214
|
+
// JURISDICTION, not staleness. A regen not emitting a path means
|
|
215
|
+
// "stale generated output" only for a path we have ever WRITTEN; for
|
|
216
|
+
// anything else it means the file was never ours. outDir is a
|
|
217
|
+
// directory, not a namespace we own: the documented quickstart itself
|
|
218
|
+
// fills it with strangers — `npx tsc` under a stock tsconfig (no
|
|
219
|
+
// `outDir` of its own) drops .js/.d.ts/.map beside the sources — and
|
|
220
|
+
// convicting those failed a project with no drift of any kind.
|
|
221
|
+
// `meta gen`'s orphan sweep already scopes itself this way via
|
|
222
|
+
// `listGeneratedPaths` before it will delete anything; the gate asks
|
|
223
|
+
// the same question of the same evidence so the two doors agree.
|
|
224
|
+
if (haveWriteRecords && readGeneratedHash(projectGenStateDir, relKey) === undefined) {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
184
227
|
driftedFiles.add(relKey);
|
|
185
228
|
lines.push(`- ${relKey} (committed but regen would not emit it)`);
|
|
186
229
|
} else if (!inCommitted && inFresh) {
|
|
@@ -189,7 +232,12 @@ export async function computeCodegenDrift(
|
|
|
189
232
|
} else {
|
|
190
233
|
const a = readFileSync(join(committed, rel), "utf8");
|
|
191
234
|
const b = readFileSync(join(fresh, rel), "utf8");
|
|
192
|
-
|
|
235
|
+
// Not `a !== b` alone: that convicts the hand edit `meta gen` preserved.
|
|
236
|
+
// The question this gate can honestly answer is "is the GENERATED
|
|
237
|
+
// contribution current?", and the recorded hash answers exactly it.
|
|
238
|
+
// FAILS CLOSED, matching `isPristineGenerated`: with no recorded hash
|
|
239
|
+
// nothing is proven, so the old byte verdict stands.
|
|
240
|
+
if (a !== b && readGeneratedHash(projectGenStateDir, relKey) !== contentHash(b)) {
|
|
193
241
|
driftedFiles.add(relKey);
|
|
194
242
|
lines.push(`~ ${relKey} (committed content differs from a fresh regen)`);
|
|
195
243
|
}
|
|
@@ -48,8 +48,12 @@ export type Severity = "error" | "warn";
|
|
|
48
48
|
export interface Diagnostic {
|
|
49
49
|
severity: Severity;
|
|
50
50
|
code: string;
|
|
51
|
-
/** The
|
|
52
|
-
name
|
|
51
|
+
/** The subject's ADDRESS, when the diagnostic has one — the dotted child-name
|
|
52
|
+
* path. Two branches of a ledger may reuse a NAME, so a bare name does not
|
|
53
|
+
* locate the node, and a diagnostic you cannot locate is one you cannot act on.
|
|
54
|
+
* Absent on a diagnostic whose subject is not a requirement (object coverage
|
|
55
|
+
* names the entity in its message instead). */
|
|
56
|
+
path?: string;
|
|
53
57
|
message: string;
|
|
54
58
|
}
|
|
55
59
|
|
|
@@ -151,21 +155,107 @@ function subtreeClaimsAnything(req: MetaRequirement): boolean {
|
|
|
151
155
|
return false;
|
|
152
156
|
}
|
|
153
157
|
|
|
154
|
-
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
export
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
/** A requirement paired with its ADDRESS — the dotted child-name path from the
|
|
159
|
+
* root, which is how every other node in the model is addressed, and which the
|
|
160
|
+
* requirement-test generator also turns into the stub's filename. */
|
|
161
|
+
export interface AddressedRequirement {
|
|
162
|
+
readonly node: MetaRequirement;
|
|
163
|
+
readonly path: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Every `requirement.*` node in the tree, at any nesting depth, each with its
|
|
168
|
+
* dotted path. Hierarchy IS nesting — an L1 solution contains its L2 segments —
|
|
169
|
+
* so this is a walk, not a scan of a flat list keyed by a `parent` string.
|
|
170
|
+
*
|
|
171
|
+
* ONE derivation of the address for the gate AND the authoring lint, deliberately.
|
|
172
|
+
* They are two sections of a single `meta verify` run, and when they walked
|
|
173
|
+
* separately they addressed the same node two different ways — the gate by bare
|
|
174
|
+
* `name`, which is ambiguous the moment two branches of a ledger reuse one, and the
|
|
175
|
+
* lint by path. A reader cannot reconcile two addressings of one tree.
|
|
176
|
+
*
|
|
177
|
+
* The traversal descends through EVERY node rather than only through requirements:
|
|
178
|
+
* a requirement somewhere the child rules did not anticipate is still gated, which
|
|
179
|
+
* is the fail-closed direction for a gate.
|
|
180
|
+
*/
|
|
181
|
+
export function collectAddressedRequirements(root: MetaData): AddressedRequirement[] {
|
|
182
|
+
const out: AddressedRequirement[] = [];
|
|
183
|
+
const walk = (n: MetaData, prefix: string): void => {
|
|
160
184
|
for (const c of n.children()) {
|
|
161
|
-
|
|
162
|
-
|
|
185
|
+
// Only a requirement contributes a path segment. An intervening non-requirement
|
|
186
|
+
// node is traversed THROUGH, so the address stays the requirement hierarchy.
|
|
187
|
+
const isReq = c.type === TYPE_REQUIREMENT;
|
|
188
|
+
const path = isReq ? (prefix === "" ? c.name : `${prefix}.${c.name}`) : prefix;
|
|
189
|
+
if (isReq) out.push({ node: c as MetaRequirement, path });
|
|
190
|
+
walk(c, path);
|
|
163
191
|
}
|
|
164
192
|
};
|
|
165
|
-
walk(root);
|
|
193
|
+
walk(root, "");
|
|
166
194
|
return out;
|
|
167
195
|
}
|
|
168
196
|
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Resolve a `@supersededBy` reference to a requirement in the same ledger (FR-039).
|
|
200
|
+
*
|
|
201
|
+
* A requirement is addressed the way every other node is: a package qualifies the
|
|
202
|
+
* ROOT-level node only, and each dotted segment after it walks CHILD names — so
|
|
203
|
+
* `acme::caps::Billing.InvoiceRecord` names the `InvoiceRecord` child of the
|
|
204
|
+
* root-level `Billing`. A bare reference binds package-locally first and then at
|
|
205
|
+
* root level, which is the ADR-0042 contract every other reference already follows.
|
|
206
|
+
*
|
|
207
|
+
* Deliberately resolves against the LEDGER, not the model: a capability is replaced
|
|
208
|
+
* by another capability. Pointing this at an entity would be `@implementedBy` wearing
|
|
209
|
+
* a different name, and `@implementedBy` is exactly what a retired entry may not have.
|
|
210
|
+
*/
|
|
211
|
+
export function resolveRequirementRef(
|
|
212
|
+
addressed: readonly AddressedRequirement[],
|
|
213
|
+
ref: string,
|
|
214
|
+
referrerPkg: string,
|
|
215
|
+
): MetaRequirement | undefined {
|
|
216
|
+
const keyed = new Map<string, MetaRequirement>();
|
|
217
|
+
for (const { node, path } of addressed) {
|
|
218
|
+
const pkg = node.package ?? node.fileDefaultPackage ?? "";
|
|
219
|
+
if (pkg !== "") keyed.set(`${pkg}::${path}`, node);
|
|
220
|
+
// Bare path is registered too, so a single-package ledger can reference
|
|
221
|
+
// without repeating its own package on every line.
|
|
222
|
+
if (!keyed.has(path)) keyed.set(path, node);
|
|
223
|
+
}
|
|
224
|
+
// An FQN binds exactly; a bare ref prefers the referrer's own package.
|
|
225
|
+
const exact = keyed.get(ref);
|
|
226
|
+
if (exact !== undefined) return exact;
|
|
227
|
+
if (referrerPkg !== "") {
|
|
228
|
+
const local = keyed.get(`${referrerPkg}::${ref}`);
|
|
229
|
+
if (local !== undefined) return local;
|
|
230
|
+
}
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The nodes alone, for callers with no use for the address. */
|
|
235
|
+
export function collectRequirements(root: MetaData): MetaRequirement[] {
|
|
236
|
+
return collectAddressedRequirements(root).map((r) => r.node);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* What one `meta verify` run computes once and both requirement passes read.
|
|
241
|
+
*
|
|
242
|
+
* The gate and the summary each need the same two things, and each used to derive
|
|
243
|
+
* both for itself — so a run walked the model twice and resolved every
|
|
244
|
+
* `@implementedBy` claim twice, the second of which is the expensive half
|
|
245
|
+
* (resolution is O(claims x root objects)). Threading the result rather than the
|
|
246
|
+
* function is what the "SHARED by the gate and the summary" note on
|
|
247
|
+
* `claimedObjectKeys` was always asking for.
|
|
248
|
+
*/
|
|
249
|
+
export interface RequirementScan {
|
|
250
|
+
readonly addressed: readonly AddressedRequirement[];
|
|
251
|
+
readonly claimedObjects: ReadonlySet<string>;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function scanRequirements(root: MetaData): RequirementScan {
|
|
255
|
+
const addressed = collectAddressedRequirements(root);
|
|
256
|
+
return { addressed, claimedObjects: claimedObjectKeys(root, addressed.map((r) => r.node)) };
|
|
257
|
+
}
|
|
258
|
+
|
|
169
259
|
/**
|
|
170
260
|
* Resolution keys of every object claimed by a requirement.
|
|
171
261
|
*
|
|
@@ -232,14 +322,12 @@ function coverableEntities(root: MetaData): MetaData[] {
|
|
|
232
322
|
* prove: that a status is *true*, or that a node actually implements the
|
|
233
323
|
* requirement claiming it. No test can. That truth is the adopter's job.
|
|
234
324
|
*/
|
|
235
|
-
export function checkRequirements(root: MetaData): Diagnostic[] {
|
|
325
|
+
export function checkRequirements(root: MetaData, scan: RequirementScan = scanRequirements(root)): Diagnostic[] {
|
|
236
326
|
const out: Diagnostic[] = [];
|
|
237
|
-
const
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
const claimedObjects = claimedObjectKeys(root, reqs);
|
|
327
|
+
const { addressed, claimedObjects } = scan;
|
|
328
|
+
if (addressed.length === 0) return out; // opt-in by declaration — no requirements, nothing to say
|
|
241
329
|
|
|
242
|
-
for (const req of
|
|
330
|
+
for (const { node: req, path: reqPath } of addressed) {
|
|
243
331
|
const architectural = req.subType === REQUIREMENT_SUBTYPE_ARCHITECTURAL;
|
|
244
332
|
const level = req.level();
|
|
245
333
|
const refs = req.implementedBy();
|
|
@@ -258,7 +346,7 @@ export function checkRequirements(root: MetaData): Diagnostic[] {
|
|
|
258
346
|
if (!levelled || !Number.isInteger(level)
|
|
259
347
|
|| level < REQUIREMENT_MIN_LEVEL || level > REQUIREMENT_MAX_LEVEL) {
|
|
260
348
|
out.push({
|
|
261
|
-
severity: "error", code: ERR_REQUIREMENT_BAD_LEVEL,
|
|
349
|
+
severity: "error", code: ERR_REQUIREMENT_BAD_LEVEL, path: reqPath,
|
|
262
350
|
message: `level must be an integer ${REQUIREMENT_MIN_LEVEL}-${REQUIREMENT_MAX_LEVEL} (got ${String(level)}). ` +
|
|
263
351
|
`L1 solution, L2 segment (app/library), L3 service, L4 object, L5 member.` +
|
|
264
352
|
(architectural ? ` On an architectural requirement the level is optional — omit it for a flat policy.` : ``),
|
|
@@ -270,7 +358,7 @@ export function checkRequirements(root: MetaData): Diagnostic[] {
|
|
|
270
358
|
const pl = (parent as MetaRequirement).level();
|
|
271
359
|
if (pl !== undefined && level !== undefined && level <= pl) {
|
|
272
360
|
out.push({
|
|
273
|
-
severity: "error", code: ERR_REQUIREMENT_LEVEL_NESTING,
|
|
361
|
+
severity: "error", code: ERR_REQUIREMENT_LEVEL_NESTING, path: reqPath,
|
|
274
362
|
message: `nested under "${parent.name}" (level ${pl}) but declares level ${level}. ` +
|
|
275
363
|
`Nesting is the hierarchy — a child sits strictly below its parent.`,
|
|
276
364
|
});
|
|
@@ -281,7 +369,7 @@ export function checkRequirements(root: MetaData): Diagnostic[] {
|
|
|
281
369
|
// -- the link boundary ----------------------------------------------------
|
|
282
370
|
if (refs.length > 0 && !req.mayReferenceModel()) {
|
|
283
371
|
out.push({
|
|
284
|
-
severity: "error", code: ERR_REQUIREMENT_LINK_ABOVE_FLOOR,
|
|
372
|
+
severity: "error", code: ERR_REQUIREMENT_LINK_ABOVE_FLOOR, path: reqPath,
|
|
285
373
|
message: `'implementedBy' is legal at L${REQUIREMENT_LINK_FLOOR_LEVEL} (object) and ` +
|
|
286
374
|
`L${REQUIREMENT_MAX_LEVEL} (member) only. L1-L3 are organisational and never reference ` +
|
|
287
375
|
`the model — move the links to a nested L${REQUIREMENT_LINK_FLOOR_LEVEL} child.`,
|
|
@@ -310,7 +398,7 @@ export function checkRequirements(root: MetaData): Diagnostic[] {
|
|
|
310
398
|
// promised.
|
|
311
399
|
if (!architectural && level === REQUIREMENT_LINK_FLOOR_LEVEL && !isObjectRef) {
|
|
312
400
|
out.push({
|
|
313
|
-
severity: "error", code: ERR_REQUIREMENT_L4_NOT_OBJECT,
|
|
401
|
+
severity: "error", code: ERR_REQUIREMENT_L4_NOT_OBJECT, path: reqPath,
|
|
314
402
|
message: `L${REQUIREMENT_LINK_FLOOR_LEVEL} references an object; '${ref}' names a member. ` +
|
|
315
403
|
`Move it to a nested L${REQUIREMENT_LEVEL_MEMBER} child, or reference the object itself.`,
|
|
316
404
|
});
|
|
@@ -318,7 +406,7 @@ export function checkRequirements(root: MetaData): Diagnostic[] {
|
|
|
318
406
|
}
|
|
319
407
|
if (!architectural && level === REQUIREMENT_LEVEL_MEMBER && isObjectRef) {
|
|
320
408
|
out.push({
|
|
321
|
-
severity: "error", code: ERR_REQUIREMENT_L5_NOT_MEMBER,
|
|
409
|
+
severity: "error", code: ERR_REQUIREMENT_L5_NOT_MEMBER, path: reqPath,
|
|
322
410
|
message: `L${REQUIREMENT_LEVEL_MEMBER} references a member (field, view or identity); ` +
|
|
323
411
|
`'${ref}' names an object. Move it to its L${REQUIREMENT_LINK_FLOOR_LEVEL} parent.`,
|
|
324
412
|
});
|
|
@@ -332,7 +420,7 @@ export function checkRequirements(root: MetaData): Diagnostic[] {
|
|
|
332
420
|
// job, and the reason this check cannot live in the loader.
|
|
333
421
|
if (req.requiresLiveNodes()) {
|
|
334
422
|
out.push({
|
|
335
|
-
severity: "error", code: ERR_REQUIREMENT_DANGLING_REF,
|
|
423
|
+
severity: "error", code: ERR_REQUIREMENT_DANGLING_REF, path: reqPath,
|
|
336
424
|
message: `'${ref}' does not resolve in the loaded model (status '${String(req.status())}' — ` +
|
|
337
425
|
`the model moved and the requirement is stale).` + didYouMeanHint(root, owner),
|
|
338
426
|
});
|
|
@@ -340,6 +428,32 @@ export function checkRequirements(root: MetaData): Diagnostic[] {
|
|
|
340
428
|
}
|
|
341
429
|
}
|
|
342
430
|
|
|
431
|
+
// -- @supersededBy resolution (FR-039) ------------------------------------
|
|
432
|
+
// The 2026-08-10 ruling asked for exactly this at point 4 — "a supersededBy
|
|
433
|
+
// that RESOLVES (FQN-checked, so verify can fail on a dangling one), turning
|
|
434
|
+
// 'deleted in S6' from an inert comment into a build gate" — and 0.24.0
|
|
435
|
+
// deregistered the unresolved string version without ever building it.
|
|
436
|
+
//
|
|
437
|
+
// Resolution is what makes a supersession CHAIN survive. A prose note points
|
|
438
|
+
// one hop and rots when that hop is itself retired; a resolved reference does
|
|
439
|
+
// not, because the target is a real node carrying its own @supersededBy.
|
|
440
|
+
//
|
|
441
|
+
// The target is a REQUIREMENT, not a model node: a capability is replaced by
|
|
442
|
+
// another capability. It resolves package-locally under ADR-0042 through the
|
|
443
|
+
// requirement's own effective package, exactly as @implementedBy does.
|
|
444
|
+
const superseded = req.supersededBy();
|
|
445
|
+
if (superseded !== undefined) {
|
|
446
|
+
const referrerPkg = req.package ?? req.fileDefaultPackage ?? "";
|
|
447
|
+
if (resolveRequirementRef(addressed, superseded, referrerPkg) === undefined) {
|
|
448
|
+
out.push({
|
|
449
|
+
severity: "error", code: ERR_REQUIREMENT_DANGLING_REF, path: reqPath,
|
|
450
|
+
message: `@supersededBy '${superseded}' does not name a requirement in the loaded ` +
|
|
451
|
+
`ledger. It must name the requirement that REPLACED this one — if nothing did, ` +
|
|
452
|
+
`drop the attribute and let \`notes\` carry why the capability went.`,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
343
457
|
// -- architectural universality, v1: claim-set arithmetic -----------------
|
|
344
458
|
// A live policy claimed by nothing is the audited-base case: declared and
|
|
345
459
|
// applied to nothing. Deliberately NOT a violation-predicate DSL — that
|
|
@@ -362,7 +476,7 @@ export function checkRequirements(root: MetaData): Diagnostic[] {
|
|
|
362
476
|
// levelled one, true again at the link floor.
|
|
363
477
|
if (architectural && live && refs.length === 0 && req.mayReferenceModel()) {
|
|
364
478
|
out.push({
|
|
365
|
-
severity: "error", code: ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS,
|
|
479
|
+
severity: "error", code: ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS, path: reqPath,
|
|
366
480
|
message: `architectural requirement is '${String(status)}' but nothing implements it. ` +
|
|
367
481
|
`Its check is universality — a claim set of zero means the policy is declared and unapplied.`,
|
|
368
482
|
});
|
|
@@ -372,7 +486,7 @@ export function checkRequirements(root: MetaData): Diagnostic[] {
|
|
|
372
486
|
const disposition = req.disposition();
|
|
373
487
|
if (disposition !== undefined && !req.hasOutstandingWork()) {
|
|
374
488
|
out.push({
|
|
375
|
-
severity: "warn", code: WARN_REQUIREMENT_DISPOSITION_NOT_APPLICABLE,
|
|
489
|
+
severity: "warn", code: WARN_REQUIREMENT_DISPOSITION_NOT_APPLICABLE, path: reqPath,
|
|
376
490
|
message: `carries @disposition '${disposition}' but its status is '${String(status)}', which has no ` +
|
|
377
491
|
`outstanding work to decide about. A disposition is meaningful on 'planned' and 'partial' only — ` +
|
|
378
492
|
`on any other status the decision IS the status.`,
|
|
@@ -387,7 +501,7 @@ export function checkRequirements(root: MetaData): Diagnostic[] {
|
|
|
387
501
|
// is empty is a capability declared and built by nobody.
|
|
388
502
|
if (!architectural && live && !subtreeClaimsAnything(req)) {
|
|
389
503
|
out.push({
|
|
390
|
-
severity: "warn", code: WARN_REQUIREMENT_NOTHING_IMPLEMENTS,
|
|
504
|
+
severity: "warn", code: WARN_REQUIREMENT_NOTHING_IMPLEMENTS, path: reqPath,
|
|
391
505
|
message: `is '${String(status)}' but neither it nor anything nested under it names an ` +
|
|
392
506
|
`implementing node. A functional requirement's check is existence — a subtree that claims ` +
|
|
393
507
|
`nothing is a capability nobody built.`,
|
|
@@ -396,7 +510,7 @@ export function checkRequirements(root: MetaData): Diagnostic[] {
|
|
|
396
510
|
|
|
397
511
|
if (disposition === REQUIREMENT_DISPOSITION_DEFERRED && req.trackedBy().length === 0) {
|
|
398
512
|
out.push({
|
|
399
|
-
severity: "warn", code: WARN_REQUIREMENT_DEFERRED_UNTRACKED,
|
|
513
|
+
severity: "warn", code: WARN_REQUIREMENT_DEFERRED_UNTRACKED, path: reqPath,
|
|
400
514
|
message: `is deferred but names no @trackedBy issue. Deferring without a ticket is how a known gap ` +
|
|
401
515
|
`becomes an unknown one — nothing will raise it again.`,
|
|
402
516
|
});
|
|
@@ -447,8 +561,11 @@ export function checkRequirements(root: MetaData): Diagnostic[] {
|
|
|
447
561
|
* are deliberately the ones an author would otherwise have to write a script to
|
|
448
562
|
* get: how many gaps are recorded, and how many of those nobody has ruled on.
|
|
449
563
|
*/
|
|
450
|
-
export function summariseRequirements(
|
|
451
|
-
|
|
564
|
+
export function summariseRequirements(
|
|
565
|
+
root: MetaData,
|
|
566
|
+
scan: RequirementScan = scanRequirements(root),
|
|
567
|
+
): RequirementSummary | undefined {
|
|
568
|
+
const reqs = scan.addressed.map((r) => r.node);
|
|
452
569
|
if (reqs.length === 0) return undefined; // opt-in by declaration
|
|
453
570
|
|
|
454
571
|
const summary: RequirementSummary = {
|
|
@@ -475,9 +592,10 @@ export function summariseRequirements(root: MetaData): RequirementSummary | unde
|
|
|
475
592
|
}
|
|
476
593
|
}
|
|
477
594
|
|
|
478
|
-
// Both sides of the ratio come from the SAME
|
|
479
|
-
//
|
|
480
|
-
|
|
595
|
+
// Both sides of the ratio come from the SAME scan the gate read, so the printed
|
|
596
|
+
// summary cannot disagree with the diagnostics printed beneath it — previously
|
|
597
|
+
// the same helper, now literally the same result.
|
|
598
|
+
const claimed = scan.claimedObjects;
|
|
481
599
|
for (const ent of coverableEntities(root)) {
|
|
482
600
|
summary.entitiesTotal++;
|
|
483
601
|
if (claimed.has(ent.resolutionKey())) summary.entitiesClaimed++;
|