@metaobjectsdev/cli 0.24.1 → 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.
- 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/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/requirement-check.ts +150 -32
- package/src/lib/requirement-lint.ts +356 -0
|
@@ -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++;
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// `meta verify` — the requirement AUTHORING lint.
|
|
2
|
+
//
|
|
3
|
+
// A different kind of claim from the gate in `requirement-check.ts`, which is why
|
|
4
|
+
// it is a separate function rather than more branches inside `checkRequirements`.
|
|
5
|
+
//
|
|
6
|
+
// THE GATE referential integrity — links sit at or below the floor, nesting
|
|
7
|
+
// agrees with levels, references resolve. A finding there means the
|
|
8
|
+
// ledger DISAGREES WITH THE MODEL.
|
|
9
|
+
// THE LINT authoring quality — the name is addressable, the prose slots hold
|
|
10
|
+
// distinct content, nothing was written into a slot no surface reads.
|
|
11
|
+
// A finding here means the ledger is INTERNALLY WASTEFUL: it still
|
|
12
|
+
// agrees with the model, it just records less than its author thinks.
|
|
13
|
+
//
|
|
14
|
+
// Keeping them apart is not tidiness. `meta verify` prints at most 20 warnings, and
|
|
15
|
+
// a ledger with 240 entries can produce hundreds of prose findings — enough to push
|
|
16
|
+
// every WARN_REQUIREMENT_OBJECT_UNCLAIMED off the end of the list. Two sections with
|
|
17
|
+
// two caps means the lint cannot drown the gate.
|
|
18
|
+
//
|
|
19
|
+
// IT READS THE LOADED MODEL, NEVER THE FILES. Extensions and overlays mean the text
|
|
20
|
+
// on disk is not the effective model: an attr set in one file and overridden in
|
|
21
|
+
// another, or inherited through `extends`, reads differently from every angle except
|
|
22
|
+
// the loaded tree. A raw-file linter would be wrong for any project using either.
|
|
23
|
+
//
|
|
24
|
+
// EVERY FINDING IS A WARNING, BY CONSTRUCTION — not by a switch. Stated as a rule so
|
|
25
|
+
// the next check added here follows it: a check newly added to a shipping gate cannot
|
|
26
|
+
// be shown not to fire on an estate that already exists, and prose findings that turn
|
|
27
|
+
// `meta verify` red on upgrade teach people to switch the gate off, which costs more
|
|
28
|
+
// than the padding they caught. The precedent is object coverage, which stayed a
|
|
29
|
+
// warning because on one real estate it reported every entity in the repository.
|
|
30
|
+
//
|
|
31
|
+
// There is deliberately NO severity constant to flip. `verify` prints this section
|
|
32
|
+
// with `log.warn` unconditionally and computes its exit code from the GATE alone, so
|
|
33
|
+
// a constant here would have promised a promotion it could not deliver. If a check in
|
|
34
|
+
// this file ever has to fail a build, the honest move is to move the check into the
|
|
35
|
+
// gate — which is a decision about what the check IS, not a severity edit.
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
DOC_ATTR_DESCRIPTION,
|
|
39
|
+
DOC_ATTR_SUMMARY,
|
|
40
|
+
DOC_ATTR_TITLE,
|
|
41
|
+
REQUIREMENT_ATTR_COUNTEREXAMPLE,
|
|
42
|
+
REQUIREMENT_ATTR_STATEMENT,
|
|
43
|
+
REQUIREMENT_ATTR_TRACKED_BY,
|
|
44
|
+
// The port's ONE identifier splitter, used here for its word boundaries rather
|
|
45
|
+
// than its underscores: `normalise` lowercases and maps every separator to a
|
|
46
|
+
// space, so `toSnakeCase(name)` and a hand-rolled camel split are identical
|
|
47
|
+
// downstream (checked over 20k random identifiers). Sharing it means the lint
|
|
48
|
+
// splits a name the same way the column/table/kebab namers do — a private copy
|
|
49
|
+
// would silently stop agreeing the day that rule is corrected.
|
|
50
|
+
toSnakeCase,
|
|
51
|
+
type MetaData,
|
|
52
|
+
} from "@metaobjectsdev/metadata";
|
|
53
|
+
// The SAME collection the gate walks, so the two sections of one `meta verify` run
|
|
54
|
+
// address a node identically — and the dotted path each reports is the address the
|
|
55
|
+
// requirement-test generator turns into the stub's filename. (Pinned by a test that
|
|
56
|
+
// compares this path set against `walkRequirements()`, the generator's own walk,
|
|
57
|
+
// rather than leaving the two to agree by inspection.)
|
|
58
|
+
import {
|
|
59
|
+
collectAddressedRequirements, type AddressedRequirement, type Diagnostic,
|
|
60
|
+
} from "./requirement-check.js";
|
|
61
|
+
|
|
62
|
+
export const WARN_REQUIREMENT_NAME_NOT_ADDRESSABLE = "WARN_REQUIREMENT_NAME_NOT_ADDRESSABLE";
|
|
63
|
+
export const WARN_REQUIREMENT_NAME_READS_AS_PROSE = "WARN_REQUIREMENT_NAME_READS_AS_PROSE";
|
|
64
|
+
export const WARN_REQUIREMENT_NAME_RESTATES_STATEMENT = "WARN_REQUIREMENT_NAME_RESTATES_STATEMENT";
|
|
65
|
+
export const WARN_REQUIREMENT_PROSE_DUPLICATED = "WARN_REQUIREMENT_PROSE_DUPLICATED";
|
|
66
|
+
export const WARN_REQUIREMENT_PROSE_EMPTY = "WARN_REQUIREMENT_PROSE_EMPTY";
|
|
67
|
+
export const WARN_REQUIREMENT_INERT_DOC_SLOT = "WARN_REQUIREMENT_INERT_DOC_SLOT";
|
|
68
|
+
export const WARN_REQUIREMENT_TITLE_IS_AN_ID = "WARN_REQUIREMENT_TITLE_IS_AN_ID";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Characters that break the two things a requirement's `name` IS.
|
|
72
|
+
*
|
|
73
|
+
* `.` the dotted-path separator. A name containing one is INDISTINGUISHABLE
|
|
74
|
+
* from nesting: a single node named "Orders.Recorded" and a node "Orders"
|
|
75
|
+
* containing a node "Recorded" both produce the path `Orders.Recorded`, so
|
|
76
|
+
* the address stops identifying one node and the two collide on the same
|
|
77
|
+
* emitted stub file.
|
|
78
|
+
* `/` `\` path separators. The default stub path is
|
|
79
|
+
* `requirements/<path>.test.ts`, so a name of `../../thing` writes OUTSIDE
|
|
80
|
+
* the stub directory — and `owns()`, which is what lets the runner reap a
|
|
81
|
+
* stub whose requirement was deleted, will never claim it back.
|
|
82
|
+
* the rest illegal in a filename on Windows, so the stub cannot be written at
|
|
83
|
+
* all there. `:` doubles as half of the `::` package separator.
|
|
84
|
+
*
|
|
85
|
+
* Every one of these loads today: the loader constrains a requirement's name no
|
|
86
|
+
* more than any other node's, and nothing downstream re-checks it.
|
|
87
|
+
*
|
|
88
|
+
* A SEAM, recorded so the decision stays visible: the general form of this rule is
|
|
89
|
+
* not requirement-specific at all — a `.` in ANY node's name breaks the dotted
|
|
90
|
+
* child-name addressing the whole metamodel uses — so it arguably belongs in the
|
|
91
|
+
* loader as a cross-port warning, beside WARN_ENUM_NORMALIZE_AMBIGUOUS. It is
|
|
92
|
+
* scoped here because the requirement surface is where it currently bites (a name
|
|
93
|
+
* becomes a stub FILENAME only here), and because promoting it means five ports,
|
|
94
|
+
* expected-warnings.json, conformance fixtures and a metamodelVersion question.
|
|
95
|
+
* That is an FR, not a line in this file.
|
|
96
|
+
*/
|
|
97
|
+
const UNADDRESSABLE = /[./\\:*?"<>|\p{Cc}]/gu;
|
|
98
|
+
|
|
99
|
+
/** How one offending character is named in the message, so the author is told which
|
|
100
|
+
* one to remove rather than left to diff their name against a regex.
|
|
101
|
+
*
|
|
102
|
+
* Only `.` needs a written entry — the rest are self-describing once quoted, and a
|
|
103
|
+
* hand-written entry per character was a table that had to be kept in step with
|
|
104
|
+
* UNADDRESSABLE: adding a character to the regex and forgetting the table printed
|
|
105
|
+
* a bare code point where the character itself was meant. Derived, so it cannot
|
|
106
|
+
* drift. */
|
|
107
|
+
function describeChar(c: string): string {
|
|
108
|
+
if (c === ".") return "'.' (the dotted-path separator)";
|
|
109
|
+
// A control character has no printable form to quote, so name its code point.
|
|
110
|
+
return /\p{Cc}/u.test(c) ? `U+${c.charCodeAt(0).toString(16).padStart(4, "0")}` : `'${c}'`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Word count at which a name stops reading as a label and starts reading as prose.
|
|
115
|
+
*
|
|
116
|
+
* A threshold, and the only one in this file — chosen high on purpose. Five words
|
|
117
|
+
* is still plausibly a label ("Order recording for placed orders"); six is a
|
|
118
|
+
* sentence. Under-firing is the right failure here: renaming a requirement changes
|
|
119
|
+
* its address AND its emitted stub filename, so a false positive asks the author to
|
|
120
|
+
* pay a migration for nothing.
|
|
121
|
+
*/
|
|
122
|
+
const PROSE_WORD_COUNT = 6;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Slots that say nothing on a requirement — and the two deliberate exclusions.
|
|
126
|
+
*
|
|
127
|
+
* `summary` qualifies: `@statement` is REQUIRED on both subtypes and is already the
|
|
128
|
+
* one-line sentence, so a summary beside it can only repeat it, and nothing reads
|
|
129
|
+
* it. `spec/capability-ledger.md`'s requirement attribute table does not list it.
|
|
130
|
+
*
|
|
131
|
+
* `title` is NOT on this list, and an earlier version of this file had it there —
|
|
132
|
+
* wrongly. That same attribute table charters it BY NAME on a requirement ("a short
|
|
133
|
+
* noun-phrase label — `name` is an identifier, this is what an index shows"), which
|
|
134
|
+
* is exactly this node type's situation: a requirement's address renders as a dotted
|
|
135
|
+
* camelCase path. Measured against three real ledgers the ban would have told two
|
|
136
|
+
* adopters to delete 355 authored labels, 123 of which carry words the name does not.
|
|
137
|
+
* That the requirements page does not render `title` yet is a gap in the RENDERER,
|
|
138
|
+
* and a tool reporting its own backlog in an adopter's terminal is noise.
|
|
139
|
+
*
|
|
140
|
+
* `notes` is excluded for the opposite reason: chartered internal-only, so being
|
|
141
|
+
* unrendered is the point of it.
|
|
142
|
+
*/
|
|
143
|
+
const INERT_SLOTS = [DOC_ATTR_SUMMARY] as const;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A catalogue or ticket id at the head of a label — `FR-448 …`, `PLAT-77 …`.
|
|
147
|
+
*
|
|
148
|
+
* The real failure the id case represents is FIELD OVERLOADING, not an unread slot:
|
|
149
|
+
* a citation lives in the display label because nothing else was offered, and
|
|
150
|
+
* `@trackedBy` (free-form, deliberately never resolved) is what was. The measured
|
|
151
|
+
* values carry an id AND a noun phrase — "FR-448 — prompt construction as typed
|
|
152
|
+
* payloads through a render engine" — so the fix is to SPLIT them. Moving the whole
|
|
153
|
+
* string to `@trackedBy` would throw the label away, which is why the message says
|
|
154
|
+
* split rather than move.
|
|
155
|
+
*/
|
|
156
|
+
const TITLE_IS_AN_ID = /^[A-Z]{2,}[- ]?\d+/;
|
|
157
|
+
|
|
158
|
+
/** Lowercase, drop everything that is not alphanumeric, collapse the gaps.
|
|
159
|
+
* Two slots "say the same thing" only if they survive this identically — no
|
|
160
|
+
* similarity score, no threshold. A fuzzy match on prose produces findings the
|
|
161
|
+
* author can argue with, and a gate people argue with is a gate people mute. */
|
|
162
|
+
function normalise(text: string): string {
|
|
163
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Whether `text`'s LEADING sentence says the same thing as `key`. False when the
|
|
167
|
+
* text is a single sentence — the caller's whole-string comparison covers that.
|
|
168
|
+
*
|
|
169
|
+
* Newlines are collapsed FIRST. A `description` is routinely authored as a YAML
|
|
170
|
+
* literal block, so the repeated opening sentence usually wraps — and `.` in a JS
|
|
171
|
+
* regex does not cross a newline, which made this arm silently miss exactly the
|
|
172
|
+
* authoring style most likely to trip it. The whole-string arm never had the bug
|
|
173
|
+
* because `normalise` already flattens whitespace. */
|
|
174
|
+
function leadingSentenceMatches(text: string, key: string): boolean {
|
|
175
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
176
|
+
const m = /^(.+?[.!?])\s+\S/.exec(flat);
|
|
177
|
+
return m?.[1] !== undefined && normalise(m[1]) === key;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A resolving string read, normalised so "declared but blank" and "absent" are
|
|
181
|
+
* distinguishable — the first is a finding, the second usually is not.
|
|
182
|
+
* `attr()` RESOLVES in TypeScript (ADR-0039), so a requirement inheriting its
|
|
183
|
+
* prose through `extends` is linted on what it effectively carries. */
|
|
184
|
+
function readSlot(node: MetaData, name: string): string | undefined {
|
|
185
|
+
const raw = node.attr(name);
|
|
186
|
+
return typeof raw === "string" ? raw : undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* An OWN-ONLY string read — one of the two sanctioned `own*()` uses in this file
|
|
191
|
+
* (ADR-0039 requires every such call to name its case).
|
|
192
|
+
*
|
|
193
|
+
* The checks split by what they are ABOUT, and the split decides the accessor:
|
|
194
|
+
*
|
|
195
|
+
* RESOLVING a check about what a node effectively SAYS. Two slots holding one
|
|
196
|
+
* sentence is a property of the effective node — a child may override
|
|
197
|
+
* one slot and inherit the other, and only the resolved pair shows it.
|
|
198
|
+
* OWN-ONLY a check about a DECLARATION, whose fix is a single edit at a single
|
|
199
|
+
* node. `title` set once on an abstract is inherited by every child, so
|
|
200
|
+
* a resolving read reports it once per child at addresses where the
|
|
201
|
+
* author will find no `title` to delete — on a ledger using the shared
|
|
202
|
+
* abstract idiom, one mistake can fill the whole lint cap with lines
|
|
203
|
+
* nobody can act on where they are pointed.
|
|
204
|
+
*/
|
|
205
|
+
function readOwnSlot(node: MetaData, name: string): string | undefined {
|
|
206
|
+
const raw = node.ownAttr(name);
|
|
207
|
+
return typeof raw === "string" ? raw : undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Echo an authored value back inside a message without letting a long one take over
|
|
211
|
+
* the terminal. Findings are printed one per line and a `description`-length slot
|
|
212
|
+
* would wrap for a dozen of them. */
|
|
213
|
+
function excerpt(value: string): string {
|
|
214
|
+
const LIMIT = 80;
|
|
215
|
+
const flat = value.replace(/\s+/g, " ").trim();
|
|
216
|
+
return JSON.stringify(flat.length > LIMIT ? `${flat.slice(0, LIMIT - 1)}…` : flat);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function warn(path: string, code: string, message: string): Diagnostic {
|
|
220
|
+
return { severity: "warn", code, path, message };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Lint every `requirement.*` node in the loaded model.
|
|
225
|
+
*
|
|
226
|
+
* Returns `[]` for a model declaring none — opt-in by declaration, the same way
|
|
227
|
+
* the gate and the docs surface are, so turning this on by default is a no-op for
|
|
228
|
+
* every project without a ledger.
|
|
229
|
+
*
|
|
230
|
+
* What it CANNOT tell you: whether a statement is true, whether a description is
|
|
231
|
+
* useful, or whether the counterexample would actually falsify the claim. Those
|
|
232
|
+
* are the judgements the ledger exists to record and no check reaches them. Every
|
|
233
|
+
* finding below is about a slot's MECHANICS — is this content reachable, is it
|
|
234
|
+
* distinct from its neighbour, is the name still an address.
|
|
235
|
+
*/
|
|
236
|
+
export function lintRequirements(
|
|
237
|
+
root: MetaData,
|
|
238
|
+
/** The run's already-collected requirements, when one exists. The lint takes the
|
|
239
|
+
* ADDRESSES alone rather than the gate's full `RequirementScan`: it has nothing
|
|
240
|
+
* to do with who claims what, and asking for the claim set would make a
|
|
241
|
+
* standalone call pay for a resolution it never reads. */
|
|
242
|
+
addressed: readonly AddressedRequirement[] = collectAddressedRequirements(root),
|
|
243
|
+
): Diagnostic[] {
|
|
244
|
+
const out: Diagnostic[] = [];
|
|
245
|
+
|
|
246
|
+
for (const { node, path } of addressed) {
|
|
247
|
+
const name = node.name;
|
|
248
|
+
|
|
249
|
+
// -- the name is an address, not prose ------------------------------------
|
|
250
|
+
// EVERY problem with one name is reported in ONE finding. These were two
|
|
251
|
+
// branches of an if/else, so a name that was both padded and dotted reported
|
|
252
|
+
// only the dot: the author fixed it, re-ran, and was told about the padding on
|
|
253
|
+
// a second pass. One name is one edit, so it is one finding.
|
|
254
|
+
const problems: string[] = [];
|
|
255
|
+
const offenders = [...new Set(name.match(UNADDRESSABLE) ?? [])];
|
|
256
|
+
if (offenders.length > 0) {
|
|
257
|
+
problems.push(`contains ${offenders.map(describeChar).join(", ")}`);
|
|
258
|
+
}
|
|
259
|
+
if (name.trim() === "") problems.push("is blank");
|
|
260
|
+
else if (name.trim() !== name) problems.push("has leading or trailing whitespace");
|
|
261
|
+
if (problems.length > 0) {
|
|
262
|
+
out.push(warn(path, WARN_REQUIREMENT_NAME_NOT_ADDRESSABLE,
|
|
263
|
+
`name ${JSON.stringify(name)} ${problems.join(", and ")}. A requirement's name is its ` +
|
|
264
|
+
`address — it is the segment of the dotted path '${path}' and the filename of its generated ` +
|
|
265
|
+
`test stub — so this either collides with nesting or produces a path the stub cannot be ` +
|
|
266
|
+
`written to.`));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const statementKey = normalise(readSlot(node, REQUIREMENT_ATTR_STATEMENT) ?? "");
|
|
270
|
+
|
|
271
|
+
// -- the name is not the claim --------------------------------------------
|
|
272
|
+
// Ordered before the prose-shape check and exclusive with it: when the name IS
|
|
273
|
+
// the statement, "rename it" is the wrong instruction. The instruction is "you
|
|
274
|
+
// have written the claim twice; @statement is the one that is read".
|
|
275
|
+
if (statementKey !== "" && normalise(toSnakeCase(name)) === statementKey) {
|
|
276
|
+
out.push(warn(path, WARN_REQUIREMENT_NAME_RESTATES_STATEMENT,
|
|
277
|
+
`name '${name}' says the same thing as @statement. The claim belongs in @statement, which every ` +
|
|
278
|
+
`surface reads; the name is an address and is better as a short identifier.`));
|
|
279
|
+
} else if (name.trim().split(/\s+/).filter((w) => w !== "").length >= PROSE_WORD_COUNT) {
|
|
280
|
+
out.push(warn(path, WARN_REQUIREMENT_NAME_READS_AS_PROSE,
|
|
281
|
+
`name '${name}' reads as a sentence. The name is an address — the dotted path other entries and ` +
|
|
282
|
+
`the generated stub filename are built from — so a short identifier keeps both legible. Put the ` +
|
|
283
|
+
`prose in @statement.`));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// -- required prose that is present but says nothing -----------------------
|
|
287
|
+
// The loader enforces PRESENCE (min: 1), never content, so `statement: ""`
|
|
288
|
+
// loads clean today and satisfies a required attribute with nothing in it.
|
|
289
|
+
for (const slot of [REQUIREMENT_ATTR_STATEMENT, REQUIREMENT_ATTR_COUNTEREXAMPLE]) {
|
|
290
|
+
// OWN-ONLY: the fix is deleting or filling one declaration. See readOwnSlot.
|
|
291
|
+
const value = readOwnSlot(node, slot);
|
|
292
|
+
if (value !== undefined && value.trim() === "") {
|
|
293
|
+
out.push(warn(path, WARN_REQUIREMENT_PROSE_EMPTY,
|
|
294
|
+
`@${slot} is declared but empty. The loader requires the attribute to be present, not to say ` +
|
|
295
|
+
`anything — an empty one passes every check while recording nothing.`));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// -- two slots holding one sentence ---------------------------------------
|
|
300
|
+
// The padding failure the authoring guidance names: restating the claim under a
|
|
301
|
+
// second heading reads as diligence and makes every later reader trust the
|
|
302
|
+
// ledger less. Only EXACT repeats are reported, whole or as the description's
|
|
303
|
+
// leading sentence; paraphrase is the broader failure and no mechanical rule
|
|
304
|
+
// reaches it without inventing findings.
|
|
305
|
+
if (statementKey !== "") {
|
|
306
|
+
// No blank-check needed: this arm only runs when `statementKey` is non-empty,
|
|
307
|
+
// and a blank description normalises to "" — which cannot equal it. That makes
|
|
308
|
+
// this guard identical to the `counterexample` arm below.
|
|
309
|
+
const description = readSlot(node, DOC_ATTR_DESCRIPTION);
|
|
310
|
+
if (description !== undefined) {
|
|
311
|
+
if (normalise(description) === statementKey) {
|
|
312
|
+
out.push(warn(path, WARN_REQUIREMENT_PROSE_DUPLICATED,
|
|
313
|
+
`description repeats @statement verbatim. @statement already IS the description of what the ` +
|
|
314
|
+
`requirement is; description holds the SCOPE — what the claim covers, what it deliberately ` +
|
|
315
|
+
`does not, which sibling entry owns the rest. If the scope is obvious, leave it off.`));
|
|
316
|
+
} else if (leadingSentenceMatches(description, statementKey)) {
|
|
317
|
+
out.push(warn(path, WARN_REQUIREMENT_PROSE_DUPLICATED,
|
|
318
|
+
`description opens by repeating @statement verbatim, then continues. Drop the first sentence — ` +
|
|
319
|
+
`it is already read from @statement, and description is only the scope that follows it.`));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const counterexample = readSlot(node, REQUIREMENT_ATTR_COUNTEREXAMPLE);
|
|
324
|
+
if (counterexample !== undefined && normalise(counterexample) === statementKey) {
|
|
325
|
+
out.push(warn(path, WARN_REQUIREMENT_PROSE_DUPLICATED,
|
|
326
|
+
`@counterexample repeats @statement verbatim. It must describe what BREAKING the claim looks ` +
|
|
327
|
+
`like — the thing you could point at to falsify it — which is what makes the claim checkable.`));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// -- content written where nothing reads it --------------------------------
|
|
332
|
+
for (const slot of INERT_SLOTS) {
|
|
333
|
+
// OWN-ONLY: the fix is deleting one attribute. See readOwnSlot.
|
|
334
|
+
const value = readOwnSlot(node, slot);
|
|
335
|
+
if (value === undefined || value.trim() === "") continue;
|
|
336
|
+
out.push(warn(path, WARN_REQUIREMENT_INERT_DOC_SLOT,
|
|
337
|
+
`@${slot} says nothing here that @${REQUIREMENT_ATTR_STATEMENT} does not. A requirement's ` +
|
|
338
|
+
`statement is REQUIRED and is already the one-line sentence, so a summary beside it can only ` +
|
|
339
|
+
`repeat it — and no requirement surface reads it. Its content is invisible: ${excerpt(value)}. ` +
|
|
340
|
+
`Delete it. (@title is different and is NOT flagged: it is chartered as the entry's LABEL, ` +
|
|
341
|
+
`because a requirement's name is an identifier.)`));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// -- an id is not a label -------------------------------------------------
|
|
345
|
+
const title = readOwnSlot(node, DOC_ATTR_TITLE);
|
|
346
|
+
if (title !== undefined && TITLE_IS_AN_ID.test(title.trim())) {
|
|
347
|
+
out.push(warn(path, WARN_REQUIREMENT_TITLE_IS_AN_ID,
|
|
348
|
+
`@title opens with a catalogue or ticket id: ${excerpt(title)}. A title is a NOUN PHRASE and ` +
|
|
349
|
+
`an id is not a name, so this is two things in one slot. SPLIT them — put the id in ` +
|
|
350
|
+
`@${REQUIREMENT_ATTR_TRACKED_BY}, which is the free-form reference slot and IS read, and leave ` +
|
|
351
|
+
`the phrase as the title. Moving the whole string would throw the label away.`));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return out;
|
|
356
|
+
}
|