@ecoma-io/archkeep 0.16.0 → 0.17.0
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/README.md +1 -1
- package/cli.mjs +115 -0
- package/package.json +1 -1
- package/src/analysis/csharp.mjs +38 -9
- package/src/analysis/go.mjs +15 -1
- package/src/commands/adr.mjs +45 -4
- package/src/commands/decisions.mjs +291 -0
- package/src/commands/explain.mjs +136 -0
- package/src/commands/provenance-command.mjs +86 -17
- package/src/commands/provenance.mjs +60 -0
- package/src/commands/report.mjs +48 -1
- package/src/governance/adr-registry.mjs +252 -15
- package/src/governance/decision-fitness.mjs +213 -0
- package/src/governance/decision-graph.mjs +483 -0
- package/src/governance/provenance-record.mjs +150 -0
- package/src/providers/native/model.mjs +18 -4
- package/src/report/adr-text.mjs +109 -4
- package/src/report/decisions-text.mjs +164 -0
- package/src/report/explain-text.mjs +77 -1
- package/src/report/provenance-text.mjs +67 -1
- package/src/report/report-text.mjs +53 -18
|
@@ -13,14 +13,27 @@
|
|
|
13
13
|
* filename's id. The filesystem is the source of truth, so a file whose
|
|
14
14
|
* frontmatter id disagrees with its name is a loud error, never a drift the
|
|
15
15
|
* registry guesses at.
|
|
16
|
-
* - `status` — `proposed` (default), `accepted`,
|
|
16
|
+
* - `status` — the lifecycle state: `proposed` (default), `accepted`,
|
|
17
|
+
* `active`, `superseded`, or `retired`. Only `accepted` and `active` carry
|
|
18
|
+
* authority (`hasAuthority`); `active` is the accepted decision currently in
|
|
19
|
+
* force, `superseded` was replaced by a later decision, `retired` was
|
|
20
|
+
* withdrawn without a replacement.
|
|
17
21
|
* - `supersedes` — optional list of ADR ids this record replaces, giving the
|
|
18
|
-
* supersession chain.
|
|
22
|
+
* supersession chain. The reverse link — `supersededBy` — is derived on
|
|
23
|
+
* every record at load: the records whose `supersedes` names this one.
|
|
24
|
+
* - `created` / `updated` — optional STRING values from the committed bytes,
|
|
25
|
+
* the decision's own timeline. Never generated from the wall clock.
|
|
19
26
|
* - `bindings` — optional list of rule/fitness ids this ADR makes enforceable:
|
|
20
27
|
* the objects its decision binds. An ADR with no `bindings` is recorded but
|
|
21
28
|
* not yet enforceable; the moment a rule/fitness carries `decisionRef`
|
|
22
29
|
* naming it, the two sides of the binding exist.
|
|
23
30
|
*
|
|
31
|
+
* The record's markdown body may surface the decision's prose as optional
|
|
32
|
+
* fields when the `## ` heading is present — `context`, `decision`,
|
|
33
|
+
* `rationale`, `alternatives` (also spelled `## Refused alternatives`, the
|
|
34
|
+
* spelling this repository's own records use), `consequences`, `assumptions`.
|
|
35
|
+
* Body prose is free markdown and never throws; only frontmatter is strict.
|
|
36
|
+
*
|
|
24
37
|
* Frontmatter is a strict, minimal dialect — `key: value` lines, and list
|
|
25
38
|
* fields as `- item` continuation lines. It is never full YAML and never JSON
|
|
26
39
|
* (the same decision the intent model makes for `architecture-intent.json`: no
|
|
@@ -34,10 +47,14 @@
|
|
|
34
47
|
* `docs/adr/` produce byte-identical output.
|
|
35
48
|
* - **An unreadable registry is a loud failure, never an empty one.** A
|
|
36
49
|
* `docs/adr/` directory that exists but holds a file that will not parse, a
|
|
37
|
-
* duplicate id, a status outside the
|
|
50
|
+
* duplicate id, a status outside the five, an unknown frontmatter key, or
|
|
38
51
|
* a `supersedes`/`bindings` entry that is not what the field requires —
|
|
39
52
|
* any of those throws, so a caller can never mistake "could not read the
|
|
40
|
-
* registry" for "no ADRs".
|
|
53
|
+
* registry" for "no ADRs". So does a supersession graph that cannot be
|
|
54
|
+
* true (`validateLineage`): a `supersedes` target that is not a record, a
|
|
55
|
+
* record that supersedes itself, a cycle, a `superseded` record with no
|
|
56
|
+
* successor, a successor without authority, or an authoritative record
|
|
57
|
+
* (`active`/`accepted`) superseded by another.
|
|
41
58
|
* - **A `decisionRef` that does not resolve is `unknown`, never `pass`.** The
|
|
42
59
|
* registry's `resolveDecisionRef` answers the two-name space — an ADR id
|
|
43
60
|
* (matching a file) or a rule/fitness id the workspace declares. Anything
|
|
@@ -78,8 +95,27 @@ import { containmentViolation } from "../containment.mjs";
|
|
|
78
95
|
/** The directory, relative to a workspace root, where ADR files live. */
|
|
79
96
|
export const ADR_DIR = "docs/adr";
|
|
80
97
|
|
|
81
|
-
/** The
|
|
82
|
-
export const ADR_STATUSES = Object.freeze([
|
|
98
|
+
/** The five lifecycle statuses a record may carry. Any other value is a load error. */
|
|
99
|
+
export const ADR_STATUSES = Object.freeze([
|
|
100
|
+
"proposed",
|
|
101
|
+
"accepted",
|
|
102
|
+
"active",
|
|
103
|
+
"superseded",
|
|
104
|
+
"retired",
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Whether a status carries decision authority. Only `active` — the accepted
|
|
109
|
+
* decision currently in force — and `accepted` — a decision made and
|
|
110
|
+
* recorded — have it. `proposed` (a draft), `superseded` (replaced, authority
|
|
111
|
+
* transferred) and `retired` (withdrawn without a replacement) do not.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} status
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
export function hasAuthority(status) {
|
|
117
|
+
return status === "active" || status === "accepted";
|
|
118
|
+
}
|
|
83
119
|
|
|
84
120
|
/**
|
|
85
121
|
* Matches a valid ADR filename. The number is at least three digits so the
|
|
@@ -92,7 +128,14 @@ export const ADR_FILE_PATTERN = /^(\d{3,})-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$/u;
|
|
|
92
128
|
export const ADR_ID_PATTERN = /^\d{3,}-[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
93
129
|
|
|
94
130
|
/** The frontmatter keys a record file may carry. */
|
|
95
|
-
const FRONTMATTER_KEYS = Object.freeze([
|
|
131
|
+
const FRONTMATTER_KEYS = Object.freeze([
|
|
132
|
+
"id",
|
|
133
|
+
"status",
|
|
134
|
+
"supersedes",
|
|
135
|
+
"bindings",
|
|
136
|
+
"created",
|
|
137
|
+
"updated",
|
|
138
|
+
]);
|
|
96
139
|
|
|
97
140
|
/** A value's type, for an error message that shows what was actually there. */
|
|
98
141
|
function describe(value) {
|
|
@@ -116,6 +159,68 @@ function stripInlineComment(value) {
|
|
|
116
159
|
const hash = value.indexOf(" #");
|
|
117
160
|
return hash === -1 ? value : value.slice(0, hash).trim();
|
|
118
161
|
}
|
|
162
|
+
/** The markdown body after the frontmatter block, or the whole text when the file has none. */
|
|
163
|
+
function bodyBlock(text) {
|
|
164
|
+
if (!text.startsWith("---")) return text;
|
|
165
|
+
const end = text.indexOf("\n---", 3);
|
|
166
|
+
if (end === -1) return text; // frontmatterBlock throws this case first
|
|
167
|
+
return text.slice(end + 4).replace(/^\r?\n/, "");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The prose fields the body may surface, keyed by the exact `## ` heading
|
|
172
|
+
* that carries them. `## Refused alternatives` — the spelling this
|
|
173
|
+
* repository's own records use (`docs/adr/0003-…`, `docs/adr/0004-…`) — maps
|
|
174
|
+
* to the same field as the `## Alternatives` spelling the ADR template names.
|
|
175
|
+
* The body's `## Status` heading is deliberately absent: status is a
|
|
176
|
+
* frontmatter field, and the body's retelling is not the model's.
|
|
177
|
+
*/
|
|
178
|
+
const PROSE_FIELDS = Object.freeze({
|
|
179
|
+
Context: "context",
|
|
180
|
+
Decision: "decision",
|
|
181
|
+
Rationale: "rationale",
|
|
182
|
+
Alternatives: "alternatives",
|
|
183
|
+
"Refused alternatives": "alternatives",
|
|
184
|
+
Consequences: "consequences",
|
|
185
|
+
Assumptions: "assumptions",
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The optional prose fields parsed out of a record's body. Each `## ` heading
|
|
190
|
+
* in `PROSE_FIELDS` opens its field; the field's content is everything from
|
|
191
|
+
* the line after the heading to the line before the next `## ` heading
|
|
192
|
+
* (sub-headings like `###` stay inside their field). An absent heading is an
|
|
193
|
+
* absent field, and a body that is only prose never throws — frontmatter is
|
|
194
|
+
* the one strict dialect. A `## ` heading outside the field list closes the
|
|
195
|
+
* open field without opening one; a heading repeated within one body keeps
|
|
196
|
+
* the last occurrence, the one deterministic choice a non-throwing parser
|
|
197
|
+
* can make.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} body The markdown body after the frontmatter block.
|
|
200
|
+
* @returns {Record<string, string>} Only the fields whose headings were present.
|
|
201
|
+
*/
|
|
202
|
+
function parseProseFields(body) {
|
|
203
|
+
/** @type {Record<string, string>} */
|
|
204
|
+
const fields = {};
|
|
205
|
+
let current = null;
|
|
206
|
+
/** @type {string[]} */
|
|
207
|
+
let buffer = [];
|
|
208
|
+
const flush = () => {
|
|
209
|
+
if (current !== null) fields[current] = buffer.join("\n").trim();
|
|
210
|
+
};
|
|
211
|
+
for (const line of body.split("\n")) {
|
|
212
|
+
const heading = /^##\s+(.+?)\s*$/u.exec(line);
|
|
213
|
+
if (heading !== null) {
|
|
214
|
+
flush();
|
|
215
|
+
current = PROSE_FIELDS[heading[1]] ?? null;
|
|
216
|
+
buffer = [];
|
|
217
|
+
} else if (current !== null) {
|
|
218
|
+
buffer.push(line);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
flush();
|
|
222
|
+
return fields;
|
|
223
|
+
}
|
|
119
224
|
|
|
120
225
|
/**
|
|
121
226
|
* Parse the frontmatter block into a field map. The dialect is strict:
|
|
@@ -194,14 +299,19 @@ function toList(value) {
|
|
|
194
299
|
/**
|
|
195
300
|
* One parsed record, every field validated. A record an enforcer cannot trust
|
|
196
301
|
* must never be read as an absent one (the invariant), so every malformed
|
|
197
|
-
* field throws here rather than degrading the record.
|
|
302
|
+
* field throws here rather than degrading the record. The record's body is
|
|
303
|
+
* the exception by design: prose is free markdown and never throws — only
|
|
304
|
+
* frontmatter is strict.
|
|
198
305
|
*
|
|
199
|
-
* @param {{id: string, frontmatter: string|null}} parsed The
|
|
200
|
-
* id and the frontmatter block (null when the file has
|
|
201
|
-
*
|
|
306
|
+
* @param {{id: string, frontmatter: string|null, body?: string}} parsed The
|
|
307
|
+
* filename-derived id and the frontmatter block (null when the file has
|
|
308
|
+
* none); the markdown body defaults to the empty string.
|
|
309
|
+
* @returns {{id: string, status: string, created?: string, updated?: string,
|
|
310
|
+
* supersedes: string[], bindings: string[], context?: string, decision?: string,
|
|
311
|
+
* rationale?: string, alternatives?: string, consequences?: string, assumptions?: string}}
|
|
202
312
|
* @throws {Error} naming every violation at once.
|
|
203
313
|
*/
|
|
204
|
-
export function validateRecord({ id, frontmatter }) {
|
|
314
|
+
export function validateRecord({ id, frontmatter, body = "" }) {
|
|
205
315
|
const fields = frontmatter === null ? {} : parseFrontmatterFields(frontmatter, id);
|
|
206
316
|
const violations = [];
|
|
207
317
|
|
|
@@ -227,6 +337,16 @@ export function validateRecord({ id, frontmatter }) {
|
|
|
227
337
|
violations.push(`${id}: status "${fields.status}" is not one of ${ADR_STATUSES.join(", ")}`);
|
|
228
338
|
}
|
|
229
339
|
|
|
340
|
+
for (const key of ["created", "updated"]) {
|
|
341
|
+
const value = fields[key];
|
|
342
|
+
if (value !== undefined && typeof value !== "string") {
|
|
343
|
+
violations.push(
|
|
344
|
+
`${id}: ${key} must be a single string value — the decision's own timeline from the ` +
|
|
345
|
+
`committed bytes, never generated — got ${describe(value)}`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
230
350
|
for (const ref of toList(fields.supersedes)) {
|
|
231
351
|
if (!ADR_ID_PATTERN.test(ref)) {
|
|
232
352
|
violations.push(`${id}: supersedes entry ${describe(ref)} is not an ADR id`);
|
|
@@ -246,11 +366,121 @@ export function validateRecord({ id, frontmatter }) {
|
|
|
246
366
|
return {
|
|
247
367
|
id,
|
|
248
368
|
status: typeof fields.status === "string" ? fields.status : "proposed",
|
|
369
|
+
...(typeof fields.created === "string" ? { created: fields.created } : {}),
|
|
370
|
+
...(typeof fields.updated === "string" ? { updated: fields.updated } : {}),
|
|
249
371
|
supersedes: toList(fields.supersedes),
|
|
250
372
|
bindings: toList(fields.bindings),
|
|
373
|
+
...parseProseFields(body),
|
|
251
374
|
};
|
|
252
375
|
}
|
|
253
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Validates the supersession graph across every record and derives each
|
|
379
|
+
* record's `supersededBy` — the reverse of `supersedes`, the ids of the
|
|
380
|
+
* records whose `supersedes` names this one. A chain that cannot be true is
|
|
381
|
+
* itself an unreadable registry (the invariant), thrown as one message naming
|
|
382
|
+
* every violation, never returned as partial fact:
|
|
383
|
+
*
|
|
384
|
+
* 1. every `supersedes` target must be a record;
|
|
385
|
+
* 2. a record may not supersede itself;
|
|
386
|
+
* 3. the graph may not contain a cycle — no record may transitively replace
|
|
387
|
+
* itself;
|
|
388
|
+
* 4. a `superseded` record must have at least one successor (`retired` is its
|
|
389
|
+
* own reason and needs none) — a `superseded` status with no successor is
|
|
390
|
+
* dangling;
|
|
391
|
+
* 5. a successor — the record declaring `supersedes` — must itself carry
|
|
392
|
+
* authority, so `proposed` and `superseded` records may not replace
|
|
393
|
+
* another;
|
|
394
|
+
* 6. the contradiction rule: an authoritative record (`active`/`accepted`)
|
|
395
|
+
* may not be superseded by another — authority and supersession are
|
|
396
|
+
* mutually exclusive states.
|
|
397
|
+
*
|
|
398
|
+
* Deterministic: checks run in the records' registry (byte-sorted filename)
|
|
399
|
+
* order, and every derived `supersededBy` list is in the order the
|
|
400
|
+
* superseding records loaded.
|
|
401
|
+
*
|
|
402
|
+
* @param {object[]} records The validated records, in registry order.
|
|
403
|
+
* @returns {object[]} The same records, each carrying its derived
|
|
404
|
+
* `supersededBy` string array.
|
|
405
|
+
* @throws {Error} naming every lineage violation at once.
|
|
406
|
+
*/
|
|
407
|
+
export function validateLineage(records) {
|
|
408
|
+
const byId = new Map(records.map((record) => [record.id, record]));
|
|
409
|
+
/** @type {Map<string, string[]>} */
|
|
410
|
+
const supersededBy = new Map(records.map((record) => [record.id, []]));
|
|
411
|
+
const violations = [];
|
|
412
|
+
|
|
413
|
+
for (const record of records) {
|
|
414
|
+
for (const ref of record.supersedes) {
|
|
415
|
+
if (!byId.has(ref)) {
|
|
416
|
+
violations.push(`${record.id} supersedes ${ref}, which is not an ADR in ${ADR_DIR}`);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (ref === record.id) {
|
|
420
|
+
violations.push(`${record.id} supersedes itself — a record cannot replace itself`);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
if (record.status === "proposed" || record.status === "superseded") {
|
|
424
|
+
violations.push(
|
|
425
|
+
`${record.id} is ${record.status} and supersedes ${ref} — only a record with ` +
|
|
426
|
+
`authority (active or accepted) may be a successor`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
supersededBy.get(ref).push(record.id);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Cycles: a stack-based DFS over the declared graph. A node seen again on
|
|
434
|
+
// the current stack is a cycle, named from the first repeated node so the
|
|
435
|
+
// reported chain is the cycle itself, not a prefix of it.
|
|
436
|
+
const visiting = new Set();
|
|
437
|
+
const visited = new Set();
|
|
438
|
+
const stack = [];
|
|
439
|
+
const visit = (id) => {
|
|
440
|
+
if (visited.has(id)) return;
|
|
441
|
+
if (visiting.has(id)) {
|
|
442
|
+
violations.push(`supersedes cycle: ${[...stack.slice(stack.indexOf(id)), id].join(" -> ")}`);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
visiting.add(id);
|
|
446
|
+
stack.push(id);
|
|
447
|
+
const record = byId.get(id);
|
|
448
|
+
if (record !== undefined) {
|
|
449
|
+
for (const ref of record.supersedes) visit(ref);
|
|
450
|
+
}
|
|
451
|
+
stack.pop();
|
|
452
|
+
visiting.delete(id);
|
|
453
|
+
visited.add(id);
|
|
454
|
+
};
|
|
455
|
+
for (const record of records) visit(record.id);
|
|
456
|
+
|
|
457
|
+
for (const record of records) {
|
|
458
|
+
const successors = supersededBy.get(record.id);
|
|
459
|
+
if (record.status === "superseded" && successors.length === 0) {
|
|
460
|
+
violations.push(
|
|
461
|
+
`${record.id} is superseded but nothing supersedes it — a superseded record needs at ` +
|
|
462
|
+
`least one successor (retire it instead if it was withdrawn without a replacement)`,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
if ((record.status === "active" || record.status === "accepted") && successors.length > 0) {
|
|
466
|
+
violations.push(
|
|
467
|
+
`${record.id} is ${record.status} but superseded by [${successors.join(", ")}] — a ` +
|
|
468
|
+
`record with authority may not be superseded by another`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
for (const record of records) {
|
|
474
|
+
record.supersededBy = supersededBy.get(record.id);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (violations.length > 0) {
|
|
478
|
+
throw new Error(`archkeep: malformed ADR registry:\n ${violations.join("\n ")}`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return records;
|
|
482
|
+
}
|
|
483
|
+
|
|
254
484
|
/**
|
|
255
485
|
* Read and index every ADR file under `root/docs/adr/`. Deterministic:
|
|
256
486
|
* filenames are byte-sorted, and every list in the returned records is already
|
|
@@ -259,8 +489,9 @@ export function validateRecord({ id, frontmatter }) {
|
|
|
259
489
|
*
|
|
260
490
|
* An absent `docs/adr/` is an empty registry — a workspace that has not
|
|
261
491
|
* adopted ADRs yet is not a failure, and has nothing to resolve. A directory
|
|
262
|
-
* that exists but holds an unreadable file, a malformed record,
|
|
263
|
-
* id
|
|
492
|
+
* that exists but holds an unreadable file, a malformed record, a duplicate
|
|
493
|
+
* id, or a supersession graph that cannot be true (see `validateLineage`)
|
|
494
|
+
* throws; the caller maps that to exit 3, never to an empty list.
|
|
264
495
|
*
|
|
265
496
|
* @param {string} root Absolute workspace root.
|
|
266
497
|
* @param {{readdirSync?: (path: string) => string[], readFileSync?: (path: string, encoding: "utf8") => string,
|
|
@@ -363,11 +594,17 @@ export function loadAdrRegistry(root, io = {}) {
|
|
|
363
594
|
cause,
|
|
364
595
|
});
|
|
365
596
|
}
|
|
366
|
-
const record = validateRecord({
|
|
597
|
+
const record = validateRecord({
|
|
598
|
+
id,
|
|
599
|
+
frontmatter: frontmatterBlock(text),
|
|
600
|
+
body: bodyBlock(text),
|
|
601
|
+
});
|
|
367
602
|
byId.set(id, record);
|
|
368
603
|
records.push(record);
|
|
369
604
|
}
|
|
370
605
|
|
|
606
|
+
validateLineage(records);
|
|
607
|
+
|
|
371
608
|
return { records, byId };
|
|
372
609
|
}
|
|
373
610
|
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision fitness — the "IS IT STILL TRUE" verification level, one per
|
|
3
|
+
* decision that carries authority.
|
|
4
|
+
*
|
|
5
|
+
* This module DERIVES a decision's verification level from its attached
|
|
6
|
+
* executable constraints and their verdicts. It is a pure, descriptive reader
|
|
7
|
+
* of the governance state:
|
|
8
|
+
*
|
|
9
|
+
* - it READS the decision's `bindings` (the constraint/fitness ids a record
|
|
10
|
+
* makes enforceable) and the fitness-registry's verdicts for those
|
|
11
|
+
* constraints;
|
|
12
|
+
* - it never judges a constraint itself — the verdict for a bound constraint
|
|
13
|
+
* comes in from the caller (a fitness-registry run), and `decisionFitness`
|
|
14
|
+
* only folds those verdicts into one per-decision level;
|
|
15
|
+
* - it is NOT wired into `check` exit codes this wave — a violated or
|
|
16
|
+
* unverifiable decision does not fail `check` (Wave 2 keeps decision
|
|
17
|
+
* fitness descriptive; see the Wave 2 design contract's scope section).
|
|
18
|
+
*
|
|
19
|
+
* ## The vocabulary (per decision, only for decisions WITH authority)
|
|
20
|
+
*
|
|
21
|
+
* A decision with no authority (`proposed` a draft, `superseded` replaced,
|
|
22
|
+
* `retired` withdrawn) is not measured — its fitness is `not_applicable` with
|
|
23
|
+
* a reason. A decision with authority is `active` (accepted and currently
|
|
24
|
+
* governing) or `accepted` (a decision made and recorded, "not yet verified"
|
|
25
|
+
* is a valid intermediate).
|
|
26
|
+
*
|
|
27
|
+
* - `enforced` — at least one bound executable constraint/fitness resolves
|
|
28
|
+
* AND was evaluated AND passed (verified true).
|
|
29
|
+
* - `partially-enforced` — some bound constraints evaluated & passed, but
|
|
30
|
+
* others are unverified / coverage incomplete.
|
|
31
|
+
* - `violated` — at least one bound constraint/fitness FAILED: the decision's
|
|
32
|
+
* "what must remain true" is currently false. RED direction.
|
|
33
|
+
* - `unverifiable` — the decision has authority but no bound constraint/
|
|
34
|
+
* fitness can be resolved/evaluated. RED direction — it is NEVER a pass;
|
|
35
|
+
* "no violation" is not healthy.
|
|
36
|
+
*
|
|
37
|
+
* "Healthy" is NOT derivable from "no violations": a decision with no
|
|
38
|
+
* executable constraint is `unverifiable`, never healthy. `violated` and
|
|
39
|
+
* `unverifiable` are the two red directions; `enforced` is the only fully-green
|
|
40
|
+
* one.
|
|
41
|
+
*
|
|
42
|
+
* ## No `stale`, deliberately
|
|
43
|
+
*
|
|
44
|
+
* The contract offers a `stale` level only if the repository already has a
|
|
45
|
+
* time-based staleness notion. It does not (no decision-verification staleness
|
|
46
|
+
* exists anywhere in `src/`), so `stale` is folded into `unverifiable`: a
|
|
47
|
+
* decision whose evidence is out of date is, like one with no reachable
|
|
48
|
+
* constraint, not verified true — same red meaning, deterministic, no clock.
|
|
49
|
+
*
|
|
50
|
+
* ## Determinism
|
|
51
|
+
*
|
|
52
|
+
* No wall-clock time enters the model. The `io` argument exists for signature
|
|
53
|
+
* parity and future dependency injection (e.g. an injected clock), but the
|
|
54
|
+
* derivation needs none: it reads only the bound verdicts a caller supplies,
|
|
55
|
+
* so identical inputs yield byte-identical output. A caller that does need a
|
|
56
|
+
* timestamp (a later wave) injects the clock through `io` rather than reading
|
|
57
|
+
* the wall clock.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import { hasAuthority } from "./adr-registry.mjs";
|
|
61
|
+
|
|
62
|
+
/** The closed set of per-decision fitness levels `computeDecisionFitness` emits. */
|
|
63
|
+
export const DECISION_FITNESS_LEVELS = Object.freeze([
|
|
64
|
+
"enforced",
|
|
65
|
+
"partially-enforced",
|
|
66
|
+
"violated",
|
|
67
|
+
"unverifiable",
|
|
68
|
+
"not_applicable",
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
/** Whether a level names a red (never-healthy) direction. */
|
|
72
|
+
export function isRedDirection(level) {
|
|
73
|
+
return level === "violated" || level === "unverifiable";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The resolver that turns a bound constraint id into its verdict record.
|
|
78
|
+
*
|
|
79
|
+
* @typedef {(bindingId: string) => (object | undefined)} DecisionRefLookup
|
|
80
|
+
* Returns the fitness/constraint verdict for a bound id, or `undefined` when
|
|
81
|
+
* no constraint resolves (or no verdict was produced for it) — a binding the
|
|
82
|
+
* lookup cannot answer is an UNVERIFIED binding, never a silent pass.
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The per-decision fitness level.
|
|
87
|
+
*
|
|
88
|
+
* @typedef {object} DecisionFitness
|
|
89
|
+
* @property {string} id The record id (`NNN-slug`).
|
|
90
|
+
* @property {string} status The record's lifecycle status.
|
|
91
|
+
* @property {string} level One of `DECISION_FITNESS_LEVELS`.
|
|
92
|
+
* @property {boolean} verified True only for `enforced` — the decision's
|
|
93
|
+
* "what must remain true" has a constraint that verifies true.
|
|
94
|
+
* @property {string} [reason] WHY the level, present on every non-`enforced`
|
|
95
|
+
* level (which constraint failed, why "not applicable", why nothing
|
|
96
|
+
* verified). Optional: `enforced` needs no reason.
|
|
97
|
+
*/
|
|
98
|
+
/**
|
|
99
|
+
* Computes the per-decision verification level for every record.
|
|
100
|
+
*
|
|
101
|
+
* Deterministic and pure: folds the supplied per-constraint verdicts into one
|
|
102
|
+
* level per decision, reading only the arguments. Bindings that fail win over
|
|
103
|
+
* everything (`violated`); when nothing fails and nothing passes, the decision
|
|
104
|
+
* is `unverifiable` — never healthy, the silent direction this vocabulary
|
|
105
|
+
* exists to refuse.
|
|
106
|
+
*
|
|
107
|
+
* @param {Array<{id: string, status: string, bindings: string[]}>} records
|
|
108
|
+
* The ADR registry's parsed records (a `loadAdrRegistry` result or an
|
|
109
|
+
* equivalent in tests).
|
|
110
|
+
* @param {unknown} _fitnessVerdicts
|
|
111
|
+
* Reserved for parity with the wave's call shape. `computeDecisionFitness`
|
|
112
|
+
* it (the lookup is the single door through which verdicts enter, so the
|
|
113
|
+
* derivation stays agnostic to how the caller keys them).
|
|
114
|
+
* @param {DecisionRefLookup} decisionRefLookup Resolves a bound constraint id
|
|
115
|
+
* to its verdict record (or `undefined` when it does not resolve / was not
|
|
116
|
+
* evaluated). The only door through which verdicts enter the derivation.
|
|
117
|
+
* @param {object} [_io] The reserved `io` injection seam the wave's call shape
|
|
118
|
+
* coordinates on. This derivation is pure and needs no clock (see the
|
|
119
|
+
* no-`stale` note), so it is intentionally unused here; a later timestamp-
|
|
120
|
+
* bearing wave injects a clock through this slot.
|
|
121
|
+
* @returns {DecisionFitness[]} One entry per record, in input order.
|
|
122
|
+
*/
|
|
123
|
+
export function computeDecisionFitness(records, _fitnessVerdicts, decisionRefLookup, _io = {}) {
|
|
124
|
+
/** @type {DecisionFitness[]} */
|
|
125
|
+
const out = [];
|
|
126
|
+
|
|
127
|
+
for (const record of records) {
|
|
128
|
+
if (!hasAuthority(record.status)) {
|
|
129
|
+
out.push({
|
|
130
|
+
id: record.id,
|
|
131
|
+
status: record.status,
|
|
132
|
+
level: "not_applicable",
|
|
133
|
+
verified: false,
|
|
134
|
+
reason: `status "${record.status}" carries no authority — only active/accepted decisions are measured`,
|
|
135
|
+
});
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const bindings = record.bindings ?? [];
|
|
140
|
+
const resolved = bindings
|
|
141
|
+
.map((binding) => ({ binding, verdict: decisionRefLookup(binding) }))
|
|
142
|
+
.filter((entry) => entry.verdict !== undefined) // an unresolved binding is unverified
|
|
143
|
+
.map((entry) => entry.verdict);
|
|
144
|
+
|
|
145
|
+
const failed = resolved.filter((v) => v.verdict === "fail");
|
|
146
|
+
const passed = resolved.filter((v) => v.verdict === "pass");
|
|
147
|
+
|
|
148
|
+
if (failed.length > 0) {
|
|
149
|
+
out.push({
|
|
150
|
+
id: record.id,
|
|
151
|
+
status: record.status,
|
|
152
|
+
level: "violated",
|
|
153
|
+
verified: false,
|
|
154
|
+
reason: `bound constraint "${failed[0].name}" FAILED — what-must-remain-true is currently false`,
|
|
155
|
+
});
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (passed.length === 0) {
|
|
160
|
+
// RED: no bound constraint verifies true. Covers "no bindings",
|
|
161
|
+
// "bindings resolve to nothing", and "bindings evaluate but return
|
|
162
|
+
// unknown/not_applicable". Never a pass.
|
|
163
|
+
out.push({
|
|
164
|
+
id: record.id,
|
|
165
|
+
status: record.status,
|
|
166
|
+
level: "unverifiable",
|
|
167
|
+
verified: false,
|
|
168
|
+
reason: unverifiableReason(record, bindings, resolved),
|
|
169
|
+
});
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (passed.length === bindings.length && resolved.length === bindings.length) {
|
|
174
|
+
out.push({
|
|
175
|
+
id: record.id,
|
|
176
|
+
status: record.status,
|
|
177
|
+
level: "enforced",
|
|
178
|
+
verified: true,
|
|
179
|
+
});
|
|
180
|
+
} else {
|
|
181
|
+
out.push({
|
|
182
|
+
id: record.id,
|
|
183
|
+
status: record.status,
|
|
184
|
+
level: "partially-enforced",
|
|
185
|
+
verified: false,
|
|
186
|
+
reason:
|
|
187
|
+
`${passed.length} of ${bindings.length} bound constraint(s) verified true; ` +
|
|
188
|
+
`the rest are unverified or unevaluated`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Names WHY a decision with authority verifies nothing true.
|
|
198
|
+
*
|
|
199
|
+
* @param {{id: string}} record
|
|
200
|
+
* @param {string[]} bindings
|
|
201
|
+
* @param {Array<{name: string, verdict: string}>} resolved
|
|
202
|
+
* @returns {string}
|
|
203
|
+
*/
|
|
204
|
+
function unverifiableReason(record, bindings, resolved) {
|
|
205
|
+
if (bindings.length === 0) {
|
|
206
|
+
return `no executable constraint/fitness is bound to ${record.id} — nothing can be verified`;
|
|
207
|
+
}
|
|
208
|
+
if (resolved.length === 0) {
|
|
209
|
+
return `no bound constraint for ${record.id} resolves or was evaluated — none can be verified`;
|
|
210
|
+
}
|
|
211
|
+
const names = resolved.map((v) => v.name ?? "?").join(", ");
|
|
212
|
+
return `bound constraint(s) ${names} evaluated but none verified true — coverage incomplete, not enforced`;
|
|
213
|
+
}
|