@metaobjectsdev/cli 0.22.1 → 0.23.1-rc.1

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.
@@ -31,6 +31,7 @@ import {
31
31
  REQUIREMENT_MIN_LEVEL,
32
32
  REQUIREMENT_MAX_LEVEL,
33
33
  REQUIREMENT_LEVEL_MEMBER,
34
+ REQUIREMENT_DISPOSITION_DEFERRED,
34
35
  REQUIREMENT_STATUSES_REQUIRING_LIVE_NODES,
35
36
  resolveObjectRef,
36
37
  didYouMeanHint,
@@ -57,6 +58,26 @@ export const ERR_REQUIREMENT_L4_NOT_OBJECT = "ERR_REQUIREMENT_L4_NOT_OBJECT";
57
58
  export const ERR_REQUIREMENT_L5_NOT_MEMBER = "ERR_REQUIREMENT_L5_NOT_MEMBER";
58
59
  export const ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS = "ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS";
59
60
  export const WARN_REQUIREMENT_OBJECT_UNCLAIMED = "WARN_REQUIREMENT_OBJECT_UNCLAIMED";
61
+ export const WARN_REQUIREMENT_DISPOSITION_NOT_APPLICABLE = "WARN_REQUIREMENT_DISPOSITION_NOT_APPLICABLE";
62
+ export const WARN_REQUIREMENT_DEFERRED_UNTRACKED = "WARN_REQUIREMENT_DEFERRED_UNTRACKED";
63
+ export const WARN_REQUIREMENT_NOTHING_IMPLEMENTS = "WARN_REQUIREMENT_NOTHING_IMPLEMENTS";
64
+
65
+ /** Counts behind the summary line `meta verify` prints on every run, clean or
66
+ * not. A clean run that says nothing cannot distinguish "checked, all good"
67
+ * from "checked nothing" — and a ledger that skipped an entire grain reads
68
+ * identically to a complete one. */
69
+ export interface RequirementSummary {
70
+ total: number;
71
+ functional: number;
72
+ architectural: number;
73
+ byStatus: Record<string, number>;
74
+ /** partial or planned with NO disposition recorded — the unreviewed gaps. */
75
+ undecided: number;
76
+ /** deferred entries naming no ticket, so nobody will be reminded. */
77
+ deferredUntracked: number;
78
+ entitiesTotal: number;
79
+ entitiesClaimed: number;
80
+ }
60
81
 
61
82
  /** Severity of the object-coverage gate. Promotion to `"error"` is a one-line
62
83
  * flip here, which activates an already-written test rather than requiring new
@@ -95,6 +116,79 @@ export function splitMemberRef(ref: string): { owner: string; path: string[] } {
95
116
  return { owner: ref.slice(0, dot), path: ref.slice(dot + 1).split(".") };
96
117
  }
97
118
 
119
+ /** Resolution keys of every root-level object whose `extends` chain reaches
120
+ * `ancestor`. Walks the RESOLVED super pointer rather than the raw string, so a
121
+ * cross-package or dotted reference resolves the same way the loader resolved
122
+ * it — reading `superRef` here would be a second, divergent resolver. */
123
+ function subtypesOf(root: MetaData, ancestor: MetaData): string[] {
124
+ const out: string[] = [];
125
+ for (const cand of root.children()) {
126
+ if (cand.type !== TYPE_OBJECT || cand === ancestor) continue;
127
+ const seen = new Set<MetaData>();
128
+ let cur = cand.superData;
129
+ while (cur !== undefined && !seen.has(cur)) {
130
+ seen.add(cur);
131
+ if (cur === ancestor) { out.push(cand.resolutionKey()); break; }
132
+ cur = cur.superData;
133
+ }
134
+ }
135
+ return out;
136
+ }
137
+
138
+ /** True when this requirement, or anything nested beneath it, names an
139
+ * implementing node. Subtree-scoped deliberately: an L1 solution that delegates
140
+ * everything to its children implements nothing directly, and flagging that
141
+ * would fire on the correct shape of every tree. */
142
+ function subtreeClaimsAnything(req: MetaRequirement): boolean {
143
+ if (req.implementedBy().length > 0) return true;
144
+ for (const child of req.children()) {
145
+ if (child.type !== TYPE_REQUIREMENT) continue;
146
+ if (subtreeClaimsAnything(child as MetaRequirement)) return true;
147
+ }
148
+ return false;
149
+ }
150
+
151
+ /**
152
+ * Resolve the owner segment of an `@implementedBy` reference to the node it names.
153
+ *
154
+ * OBJECTS FIRST, through the loader's own resolver, so package-local binding stays the
155
+ * ADR-0042 contract and never a parallel name scan (#228).
156
+ *
157
+ * Then ROOT-LEVEL NON-OBJECT nodes — `template.prompt` and its siblings today. The
158
+ * attribute is documented as naming "the model nodes realising this requirement", and a
159
+ * declared prompt is one: it is the durable artifact a capability like "the game master
160
+ * is told what the party can see" actually lives in. Resolving only objects meant the
161
+ * prompt estate — the thing whose retirement is hardest to see in a model, since a
162
+ * removed prompt leaves no table behind — was the one part of a model that could not
163
+ * carry a status. So L4 means "a declared top-level model node", not "an object".
164
+ *
165
+ * Requirements themselves are excluded: hierarchy is nesting, and a requirement claiming
166
+ * a requirement would be a second, contradictory parent mechanism.
167
+ */
168
+ function resolveClaimTarget(root: MetaData, owner: string, referrerPkg: string): MetaData | undefined {
169
+ const { node } = resolveObjectRef(root, owner, referrerPkg);
170
+ if (node !== undefined) return node;
171
+
172
+ const candidates = root
173
+ .children()
174
+ .filter((c) => c.type !== TYPE_OBJECT && c.type !== TYPE_REQUIREMENT);
175
+
176
+ // A fully-qualified reference binds exactly, like every other FQN in the model.
177
+ if (owner.includes(PACKAGE_SEPARATOR)) {
178
+ return candidates.find((c) => c.resolutionKey() === owner);
179
+ }
180
+ // A bare reference prefers the referrer's own package, then a root-level node of that
181
+ // bare name. An ambiguous bare name binds NOTHING — same fail-closed rule objects use,
182
+ // because silently picking one of two same-named nodes is how a claim ends up pointing
183
+ // at the wrong thing without anyone noticing.
184
+ const local = referrerPkg === "" ? [] : candidates.filter((c) => c.resolutionKey() === `${referrerPkg}${PACKAGE_SEPARATOR}${owner}`);
185
+ if (local.length === 1) return local[0];
186
+ // Root-level (unpackaged) only, matching resolveObjectRef's own bare fallback. A bare
187
+ // ref must not reach into an arbitrary package just because the name is unique there.
188
+ const bare = candidates.filter((c) => c.name === owner && c.resolutionKey() === owner);
189
+ return bare.length === 1 ? bare[0] : undefined;
190
+ }
191
+
98
192
  /** Walk dotted member segments by CHILD NAME from an object node. */
99
193
  function resolveMember(obj: MetaData, path: string[]): MetaData | undefined {
100
194
  let cur: MetaData | undefined = obj;
@@ -120,6 +214,64 @@ export function collectRequirements(root: MetaData): MetaRequirement[] {
120
214
  return out;
121
215
  }
122
216
 
217
+ /**
218
+ * Resolution keys of every object claimed by a requirement.
219
+ *
220
+ * SHARED by the gate and the summary deliberately. The two used to compute this
221
+ * separately and drifted: the summary missed the extends-chain propagation the
222
+ * gate applies to an ARCHITECTURAL claim, so a project using the documented
223
+ * BaseEntity pattern got a summary reporting entities as unclaimed while the
224
+ * gate beneath it named none. A summary that contradicts its own diagnostics
225
+ * reads as a measurement and cannot be reconciled with the run that produced it.
226
+ */
227
+ function claimedObjectKeys(root: MetaData, reqs: MetaRequirement[]): Set<string> {
228
+ const claimed = new Set<string>();
229
+ for (const req of reqs) {
230
+ // A PLANNED requirement never contributes to coverage. Otherwise the
231
+ // cheapest way to clear an unclaimed-entity warning would be to declare an
232
+ // intention — the gate would measure ambition rather than work.
233
+ if (req.isPlanned()) continue;
234
+ // referrerPkg is the requirement's own effective package, so a bare ref
235
+ // binds package-locally under the ADR-0042 contract — the loader's own
236
+ // resolver, never a parallel name scan (#228).
237
+ const referrerPkg = req.package ?? req.fileDefaultPackage ?? "";
238
+ for (const ref of req.implementedBy()) {
239
+ const { owner, path } = splitMemberRef(ref);
240
+ const node = resolveClaimTarget(root, owner, referrerPkg);
241
+ if (node === undefined) continue;
242
+ if (path.length > 0 && resolveMember(node, path) === undefined) continue;
243
+ claimed.add(node.resolutionKey());
244
+ // ARCHITECTURAL claims propagate DOWN the extends chain; functional ones
245
+ // do not. A policy ("every row is addressable") claimed on an abstract
246
+ // BaseEntity genuinely holds for everything extending it — that is what
247
+ // universality means, and without this the documented BaseEntity pattern
248
+ // is worse than not using it. A functional claim is the opposite: it says
249
+ // this entity exists for a REASON, and inheriting a reason from a shared
250
+ // base would mean adding an entity no longer forces anyone to say what it
251
+ // is for. Same mechanism, opposite polarity — as everywhere else in the
252
+ // subtype split.
253
+ if (req.subType === REQUIREMENT_SUBTYPE_ARCHITECTURAL) {
254
+ for (const sub of subtypesOf(root, node)) claimed.add(sub);
255
+ }
256
+ }
257
+ }
258
+ return claimed;
259
+ }
260
+
261
+ /**
262
+ * The entities object coverage measures — and the same set for the gate and the
263
+ * summary, for the reason given on `claimedObjectKeys`.
264
+ *
265
+ * An ABSTRACT entity is shape, not data: there is no table and no rows, so
266
+ * demanding a capability claim for it is the same category error as demanding
267
+ * one for an object.value. It is exempt for the same reason.
268
+ */
269
+ function coverableEntities(root: MetaData): MetaData[] {
270
+ return root.children().filter(
271
+ (n) => n.type === TYPE_OBJECT && n.subType === OBJECT_SUBTYPE_ENTITY && !n.isAbstract,
272
+ );
273
+ }
274
+
123
275
  /**
124
276
  * Check the requirement tree against the loaded model.
125
277
  *
@@ -133,20 +285,31 @@ export function checkRequirements(root: MetaData): Diagnostic[] {
133
285
  const reqs = collectRequirements(root);
134
286
  if (reqs.length === 0) return out; // opt-in by declaration — no requirements, nothing to say
135
287
 
136
- const claimedObjects = new Set<string>();
288
+ const claimedObjects = claimedObjectKeys(root, reqs);
137
289
 
138
290
  for (const req of reqs) {
139
291
  const architectural = req.subType === REQUIREMENT_SUBTYPE_ARCHITECTURAL;
140
292
  const level = req.level();
141
293
  const refs = req.implementedBy();
142
294
 
143
- if (!architectural) {
144
- if (level === undefined || !Number.isInteger(level)
295
+ // -- the level rules -------------------------------------------------------
296
+ // A functional requirement MUST be levelled. An architectural one MAY be,
297
+ // and levelling is the OPT-IN: unlevelled it is the original flat,
298
+ // object-independent policy and these rules must not touch it. Once a level
299
+ // is present the node has joined a tree, and `@level`'s own registered
300
+ // description promises that "the same rules as functional apply: nesting
301
+ // must agree with the level". Enforcing that here is what makes the promise
302
+ // true — a levelled architectural node used to be exempt from BOTH checks,
303
+ // so an ISO-25010 tree could re-ascend or declare a level 7 in silence.
304
+ const levelled = level !== undefined;
305
+ if (!architectural || levelled) {
306
+ if (!levelled || !Number.isInteger(level)
145
307
  || level < REQUIREMENT_MIN_LEVEL || level > REQUIREMENT_MAX_LEVEL) {
146
308
  out.push({
147
309
  severity: "error", code: ERR_REQUIREMENT_BAD_LEVEL, name: req.name,
148
310
  message: `level must be an integer ${REQUIREMENT_MIN_LEVEL}-${REQUIREMENT_MAX_LEVEL} (got ${String(level)}). ` +
149
- `L1 solution, L2 segment (app/library), L3 service, L4 object, L5 member.`,
311
+ `L1 solution, L2 segment (app/library), L3 service, L4 object, L5 member.` +
312
+ (architectural ? ` On an architectural requirement the level is optional — omit it for a flat policy.` : ``),
150
313
  });
151
314
  }
152
315
  // Nesting IS the hierarchy, so a child must sit strictly below its parent.
@@ -180,13 +343,19 @@ export function checkRequirements(root: MetaData): Diagnostic[] {
180
343
  // binds package-locally under the ADR-0042 contract — the loader's own
181
344
  // resolver, never a parallel name scan (#228).
182
345
  const referrerPkg = req.package ?? req.fileDefaultPackage ?? "";
183
- const { node } = resolveObjectRef(root, owner, referrerPkg);
346
+ const node = resolveClaimTarget(root, owner, referrerPkg);
184
347
  const isObjectRef = path.length === 0;
185
348
 
186
- if (node !== undefined && (isObjectRef || resolveMember(node, path) !== undefined)) {
187
- claimedObjects.add(node.resolutionKey());
188
- }
189
-
349
+ // GRAIN, and it stays functional-only DELIBERATELY. On a functional
350
+ // requirement L4 and L5 MEAN "an object" and "a member" — that is what the
351
+ // allocation step allocates. On a levelled architectural one the upper
352
+ // tiers are a quality taxonomy and L4/L5 retain only their link-floor
353
+ // meaning, so a policy whose claim set legitimately mixes grains ("every
354
+ // money FIELD declares its currency", claimed alongside the entities that
355
+ // hold them) must not be forced to split by grain to say so. Extending
356
+ // this to architectural would be a new rule, not the missing half of an
357
+ // existing one — unlike the level checks above, which `@level` already
358
+ // promised.
190
359
  if (!architectural && level === REQUIREMENT_LINK_FLOOR_LEVEL && !isObjectRef) {
191
360
  out.push({
192
361
  severity: "error", code: ERR_REQUIREMENT_L4_NOT_OBJECT, name: req.name,
@@ -227,13 +396,59 @@ export function checkRequirements(root: MetaData): Diagnostic[] {
227
396
  const status = req.status();
228
397
  const live = status !== undefined
229
398
  && REQUIREMENT_STATUSES_REQUIRING_LIVE_NODES.includes(status as RequirementStatus);
230
- if (architectural && live && refs.length === 0) {
399
+ // Two exemptions, both structural rather than lenient.
400
+ //
401
+ // `planned` — a policy that is not built yet is SUPPOSED to be applied to
402
+ // nothing, so the check would fire on precisely the entries it should stay
403
+ // quiet about.
404
+ //
405
+ // An ORGANISATIONAL node in a levelled architectural tree — an "ISO 25010
406
+ // Security" at L1 delegates to its children and names nothing, exactly as
407
+ // an L1 functional node does. mayReferenceModel() is the right predicate
408
+ // because it already encodes "is this tier allowed to name the model at
409
+ // all": true for a flat policy (the original form), false for L1-L3 of a
410
+ // levelled one, true again at the link floor.
411
+ if (architectural && live && refs.length === 0 && req.mayReferenceModel()) {
231
412
  out.push({
232
413
  severity: "error", code: ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS, name: req.name,
233
414
  message: `architectural requirement is '${String(status)}' but nothing implements it. ` +
234
415
  `Its check is universality — a claim set of zero means the policy is declared and unapplied.`,
235
416
  });
236
417
  }
418
+
419
+ // -- disposition: the decision, not the state -----------------------------
420
+ const disposition = req.disposition();
421
+ if (disposition !== undefined && !req.hasOutstandingWork()) {
422
+ out.push({
423
+ severity: "warn", code: WARN_REQUIREMENT_DISPOSITION_NOT_APPLICABLE, name: req.name,
424
+ message: `carries @disposition '${disposition}' but its status is '${String(status)}', which has no ` +
425
+ `outstanding work to decide about. A disposition is meaningful on 'planned' and 'partial' only — ` +
426
+ `on any other status the decision IS the status.`,
427
+ });
428
+ }
429
+ // -- functional existence, SUBTREE-scoped ---------------------------------
430
+ // A functional requirement's check is EXISTENCE: it fails when nothing
431
+ // implements it. But an organisational tier legitimately implements nothing
432
+ // ITSELF — it delegates to children, and that is the whole shape of the
433
+ // tree. So the question is not "does this node claim anything" but "does
434
+ // anything in this subtree claim anything". A live L1 whose entire subtree
435
+ // is empty is a capability declared and built by nobody.
436
+ if (!architectural && live && !subtreeClaimsAnything(req)) {
437
+ out.push({
438
+ severity: "warn", code: WARN_REQUIREMENT_NOTHING_IMPLEMENTS, name: req.name,
439
+ message: `is '${String(status)}' but neither it nor anything nested under it names an ` +
440
+ `implementing node. A functional requirement's check is existence — a subtree that claims ` +
441
+ `nothing is a capability nobody built.`,
442
+ });
443
+ }
444
+
445
+ if (disposition === REQUIREMENT_DISPOSITION_DEFERRED && req.trackedBy().length === 0) {
446
+ out.push({
447
+ severity: "warn", code: WARN_REQUIREMENT_DEFERRED_UNTRACKED, name: req.name,
448
+ message: `is deferred but names no @trackedBy issue. Deferring without a ticket is how a known gap ` +
449
+ `becomes an unknown one — nothing will raise it again.`,
450
+ });
451
+ }
237
452
  }
238
453
 
239
454
  // -- object coverage: adding an entity forces a requirement -----------------
@@ -257,8 +472,7 @@ export function checkRequirements(root: MetaData): Diagnostic[] {
257
472
  //
258
473
  // So a green run means "every entity is claimed by something", not "every node is
259
474
  // described". The stronger reading would be false.
260
- for (const ent of root.children()) {
261
- if (ent.type !== TYPE_OBJECT || ent.subType !== OBJECT_SUBTYPE_ENTITY) continue;
475
+ for (const ent of coverableEntities(root)) {
262
476
  const key = ent.resolutionKey();
263
477
  if (!claimedObjects.has(key)) {
264
478
  out.push({
@@ -270,3 +484,52 @@ export function checkRequirements(root: MetaData): Diagnostic[] {
270
484
 
271
485
  return out;
272
486
  }
487
+
488
+ /**
489
+ * Count what the ledger contains, for the line `meta verify` prints on EVERY
490
+ * run — including a clean one.
491
+ *
492
+ * This exists because silence is ambiguous. A run that prints nothing cannot be
493
+ * told apart from a run that checked nothing, and an entry that skipped a whole
494
+ * grain of the model looks exactly like one that covered it. The counts below
495
+ * are deliberately the ones an author would otherwise have to write a script to
496
+ * get: how many gaps are recorded, and how many of those nobody has ruled on.
497
+ */
498
+ export function summariseRequirements(root: MetaData): RequirementSummary | undefined {
499
+ const reqs = collectRequirements(root);
500
+ if (reqs.length === 0) return undefined; // opt-in by declaration
501
+
502
+ const summary: RequirementSummary = {
503
+ total: reqs.length,
504
+ functional: 0,
505
+ architectural: 0,
506
+ byStatus: {},
507
+ undecided: 0,
508
+ deferredUntracked: 0,
509
+ entitiesTotal: 0,
510
+ entitiesClaimed: 0,
511
+ };
512
+
513
+ for (const req of reqs) {
514
+ if (req.subType === REQUIREMENT_SUBTYPE_ARCHITECTURAL) summary.architectural++;
515
+ else summary.functional++;
516
+
517
+ const status = req.status();
518
+ if (status !== undefined) summary.byStatus[status] = (summary.byStatus[status] ?? 0) + 1;
519
+
520
+ if (req.hasOutstandingWork() && req.disposition() === undefined) summary.undecided++;
521
+ if (req.disposition() === REQUIREMENT_DISPOSITION_DEFERRED && req.trackedBy().length === 0) {
522
+ summary.deferredUntracked++;
523
+ }
524
+ }
525
+
526
+ // Both sides of the ratio come from the SAME helpers the gate uses, so the
527
+ // printed summary cannot disagree with the diagnostics printed beneath it.
528
+ const claimed = claimedObjectKeys(root, reqs);
529
+ for (const ent of coverableEntities(root)) {
530
+ summary.entitiesTotal++;
531
+ if (claimed.has(ent.resolutionKey())) summary.entitiesClaimed++;
532
+ }
533
+
534
+ return summary;
535
+ }