@ecoma-io/archkeep 0.16.1 → 0.18.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 +258 -20
- package/package.json +2 -2
- package/src/architecture-intent/judge.mjs +19 -6
- package/src/commands/adr.mjs +45 -4
- package/src/commands/change-intent.mjs +55 -8
- package/src/commands/change.mjs +332 -11
- package/src/commands/debt.mjs +26 -5
- package/src/commands/decisions.mjs +291 -0
- package/src/commands/delta-classify.mjs +257 -0
- package/src/commands/delta.mjs +269 -8
- package/src/commands/evolution.mjs +758 -5
- package/src/commands/explain.mjs +207 -1
- package/src/commands/history.mjs +81 -5
- package/src/commands/plan-context-command.mjs +163 -2
- package/src/commands/provenance-command.mjs +86 -17
- package/src/commands/provenance.mjs +60 -0
- package/src/commands/report.mjs +48 -1
- package/src/commands/trajectory.mjs +89 -3
- package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
- package/src/governance/adr-registry.mjs +252 -15
- package/src/governance/debt-ledger.mjs +261 -19
- package/src/governance/decision-fitness.mjs +213 -0
- package/src/governance/decision-graph.mjs +483 -0
- package/src/governance/decision-lineage.mjs +250 -0
- package/src/governance/evolution-event.mjs +470 -0
- package/src/governance/evolution-store.mjs +362 -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/change-text.mjs +21 -3
- package/src/report/debt-text.mjs +42 -6
- package/src/report/decisions-text.mjs +164 -0
- package/src/report/delta-text.mjs +36 -1
- package/src/report/evolution-text.mjs +231 -2
- package/src/report/explain-text.mjs +122 -1
- package/src/report/history-text.mjs +9 -3
- package/src/report/plan-context-text.mjs +94 -0
- package/src/report/provenance-text.mjs +67 -1
- package/src/report/report-text.mjs +53 -18
- package/src/report/snapshot-text.mjs +35 -1
- package/src/report/trajectory-text.mjs +30 -1
|
@@ -77,6 +77,10 @@
|
|
|
77
77
|
* never as an empty ledger.
|
|
78
78
|
*/
|
|
79
79
|
|
|
80
|
+
import { createHash } from "node:crypto";
|
|
81
|
+
|
|
82
|
+
import { canonicalizeJson } from "../canonical.mjs";
|
|
83
|
+
|
|
80
84
|
import { referenceTime as clockReferenceTime } from "./clock.mjs";
|
|
81
85
|
import { EXPIRED_WAIVER_EVIDENCE, suppressionFate } from "./waiver.mjs";
|
|
82
86
|
|
|
@@ -118,9 +122,122 @@ function owningProjectForPath(path, byName) {
|
|
|
118
122
|
bestRoot = root;
|
|
119
123
|
}
|
|
120
124
|
}
|
|
125
|
+
|
|
121
126
|
return best;
|
|
122
127
|
}
|
|
123
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The stable identity of a debt entry: `sha256` of its canonical `{kind,
|
|
131
|
+
* source}` — the same mechanism `eventId` uses for evolution events (one
|
|
132
|
+
* pattern, one canonicalizer), never the wall clock, a sequence or a random.
|
|
133
|
+
* The same fact must always hash to the same id; any caller that emits these
|
|
134
|
+
* ids into an evolution event's `debt.introduced`/`debt.resolved` MUST use
|
|
135
|
+
* this exact identity, or the event-linked lifecycle will never match the
|
|
136
|
+
* ledger.
|
|
137
|
+
*
|
|
138
|
+
* `expired-waiver` maps back to `waiver`: it is the SAME accepted violation,
|
|
139
|
+
* and its id must not change when its `expiresAt` passes — a fact's identity
|
|
140
|
+
* cannot depend on a transient state.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} kind The entry's `kind`.
|
|
143
|
+
* @param {string} source The entry's `source` (its keying field).
|
|
144
|
+
* @returns {string} The stable hex id.
|
|
145
|
+
*/
|
|
146
|
+
export function entryId(kind, source) {
|
|
147
|
+
const semanticKind = kind === "expired-waiver" ? "waiver" : kind;
|
|
148
|
+
return createHash("sha256")
|
|
149
|
+
.update(canonicalizeJson({ kind: semanticKind, source }))
|
|
150
|
+
.digest("hex");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The stable identity of a structured debt fact: `entryId` over the fact's
|
|
155
|
+
* canonical JSON. Where `entryId(kind, source)` keys on a single string (a
|
|
156
|
+
* waiver path, an unresolved boundary), `debtFactId` keys on the full semantic
|
|
157
|
+
* fact so two distinct facts can never collide on one id.
|
|
158
|
+
*
|
|
159
|
+
* This is the ONE identity the event-linked lifecycle links against: a
|
|
160
|
+
* producer (`change`, `delta`) that emits `debt.introduced`/`debt.resolved`
|
|
161
|
+
* MUST call this exact function with the same structured fact the ledger
|
|
162
|
+
* derives its entry from, or the ids will never match (the broken lifecycle
|
|
163
|
+
* F-DEB-1 exists to close). The fact excludes prose — a reworded message must
|
|
164
|
+
* not re-key a fact (F-DEB-5 drift, F-DEB-8 aspirational gap).
|
|
165
|
+
*
|
|
166
|
+
* @param {string} kind The entry's `kind`.
|
|
167
|
+
* @param {object} fact The structured semantic fact, e.g. a drift finding
|
|
168
|
+
* `{source, target, rule}` or an aspirational gap `{from, to}`.
|
|
169
|
+
* @returns {string} The stable hex id.
|
|
170
|
+
*/
|
|
171
|
+
export function debtFactId(kind, fact) {
|
|
172
|
+
return entryId(kind, canonicalizeJson(fact));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The structured drift fact a judge finding keys on: `{source, target, rule}`
|
|
177
|
+
* over exactly the fields that were judged. Presence findings (projectMissing,
|
|
178
|
+
* projectPresent, projectTagMissing) carry no `target`; the fact spans only
|
|
179
|
+
* the present fields — shared by the ledger's drift entry and every producer
|
|
180
|
+
* that emits `debt.introduced`/`debt.resolved`, so they can never disagree
|
|
181
|
+
* about which id a finding owns.
|
|
182
|
+
*
|
|
183
|
+
* @param {{source?: string, target?: string, rule?: string}} finding A judge
|
|
184
|
+
* finding (`judgeIntent`'s `{source, target, rule, …}`).
|
|
185
|
+
* @returns {{source: string, target?: string, rule?: string}} The drift fact.
|
|
186
|
+
*/
|
|
187
|
+
export function driftFactOf(finding) {
|
|
188
|
+
return {
|
|
189
|
+
source: finding.source,
|
|
190
|
+
...(finding.target === undefined ? {} : { target: finding.target }),
|
|
191
|
+
...(finding.rule === undefined ? {} : { rule: finding.rule }),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The debt a base→head transition opened and closed, expressed as ledger ids
|
|
197
|
+
* (design §8: `debt.introduced` names debt created, `debt.resolved` names debt
|
|
198
|
+
* resolved). Two `judgeIntent` verdicts — one over the base graph, one over
|
|
199
|
+
* the head graph, both judged under ONE current intent — diff on the STABLE
|
|
200
|
+
* ids: a finding present at base but gone at head is resolved; present at head
|
|
201
|
+
* but not base is introduced; a fact that never moved is neither. Identity
|
|
202
|
+
* never depends on prose (F-DEB-5 drift, F-DEB-8 aspirational gap), so the
|
|
203
|
+
* ids emitted here are byte-identical to what `computeDebtLedger` derives for
|
|
204
|
+
* the same fact — the ONE home that makes the event-linked lifecycle fire.
|
|
205
|
+
*
|
|
206
|
+
* Aspirational gaps count as debt too: an optional row not built at base but
|
|
207
|
+
* built at head is resolved debt; one that stops being built is introduced.
|
|
208
|
+
*
|
|
209
|
+
* @param {object} baseVerdict A `judgeIntent` result over the base graph.
|
|
210
|
+
* @param {object} headVerdict A `judgeIntent` result over the head graph.
|
|
211
|
+
* @returns {{introduced: string[], resolved: string[]}} Stable debt ids.
|
|
212
|
+
*/
|
|
213
|
+
export function debtChangeDiff(baseVerdict, headVerdict) {
|
|
214
|
+
const driftOf = (v) => (v.findings ?? []).map((f) => debtFactId("drift", driftFactOf(f)));
|
|
215
|
+
const gapOf = (v) =>
|
|
216
|
+
(v.gaps ?? []).map((g) => debtFactId("aspirational-gap", { from: g.from, to: g.to }));
|
|
217
|
+
const baseIds = new Set([...driftOf(baseVerdict), ...gapOf(baseVerdict)]);
|
|
218
|
+
const headIds = new Set([...driftOf(headVerdict), ...gapOf(headVerdict)]);
|
|
219
|
+
const introduced = [...headIds].filter((id) => !baseIds.has(id)).sort();
|
|
220
|
+
const resolved = [...baseIds].filter((id) => !headIds.has(id)).sort();
|
|
221
|
+
return { introduced, resolved };
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Reduces an `opts.events` value to a loaded event array, or `null` when no
|
|
225
|
+
* event store is linked. Accepts an already-loaded array or a
|
|
226
|
+
* `{ getEvents(dir) }`-shaped reader (design §4). `null` means "not linked":
|
|
227
|
+
* no `introducedBy`/`resolvedBy` is ever guessed.
|
|
228
|
+
*
|
|
229
|
+
* @param {{events?: object[]|{getEvents?: (dir?: string) => object[]},
|
|
230
|
+
* eventsDir?: string}} opts
|
|
231
|
+
* @returns {object[]|null}
|
|
232
|
+
*/
|
|
233
|
+
function loadEvents(opts) {
|
|
234
|
+
if (!opts.events) return null;
|
|
235
|
+
if (Array.isArray(opts.events)) return opts.events;
|
|
236
|
+
if (typeof opts.events.getEvents === "function")
|
|
237
|
+
return opts.events.getEvents(opts.eventsDir) ?? [];
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
124
241
|
/**
|
|
125
242
|
* The complete ledger over one ordered snapshot set. Deterministic: the same
|
|
126
243
|
* files, the same current facts and the same `referenceTime` produce the same
|
|
@@ -131,17 +248,27 @@ function owningProjectForPath(path, byName) {
|
|
|
131
248
|
* test runs could share. (The ledger's own determinism is about a fixed
|
|
132
249
|
* clock.)
|
|
133
250
|
*
|
|
134
|
-
* @param {{suppressions?: object[], intentNotes?: string[],
|
|
251
|
+
* @param {{suppressions?: object[], intentNotes?: string[], gaps?: {from: string,
|
|
252
|
+
* to: string, note?: string}[], findings?: object[],
|
|
135
253
|
* unresolved?: object[]}} current The current run's candid facts: the loaded
|
|
136
|
-
* boundary config's `suppressions`, `judgeIntent`'s
|
|
137
|
-
* gaps
|
|
254
|
+
* boundary config's `suppressions`, `judgeIntent`'s aspirational-gap facts
|
|
255
|
+
* (`gaps`, structured `{from, to}` — the identity source) with `intentNotes`
|
|
256
|
+
* as their prose display (deprecated dance when `gaps` is absent),
|
|
257
|
+
* `findings` (drift), and `unresolved`.
|
|
138
258
|
* @param {{files: {name: string, envelope: object, id: string}[]}} snapshots
|
|
139
259
|
* From `readSnapshots(dir)`, in history order.
|
|
140
|
-
* @param {{referenceTime?: number|string
|
|
260
|
+
* @param {{referenceTime?: number|string, events?: object[]|{getEvents?: (dir?: string) => object[]},
|
|
261
|
+
* eventsDir?: string}} [opts] `events` links an event store (design §4): an
|
|
262
|
+
* already-loaded array of evolution events, or a `{ getEvents(dir) }`-shaped
|
|
263
|
+
* reader. Absent ⇒ no `introducedBy`/`resolvedBy` is ever set and a
|
|
264
|
+
* `lifecycle.note` states the refs are unavailable — refs are never guessed.
|
|
141
265
|
* @returns {{entries: {source: string, kind: string, severity: string,
|
|
142
|
-
* age: number, count: number, remediationHint: string
|
|
266
|
+
* age: number, count: number, remediationHint: string, id: string,
|
|
267
|
+
* status: "active", introducedBy?: string}[],
|
|
268
|
+
* resolved: {id: string, status: "resolved", resolvedBy: string}[],
|
|
143
269
|
* total: number, byKind: object, bySeverity: object, agings: boolean,
|
|
144
|
-
* sampleTime: string
|
|
270
|
+
* sampleTime: string,
|
|
271
|
+
* lifecycle: {linked: boolean, note: string|null}}}
|
|
145
272
|
*/
|
|
146
273
|
export function computeDebtLedger(current, snapshots, opts = {}) {
|
|
147
274
|
const referenceTime = opts.referenceTime ?? clockReferenceTime();
|
|
@@ -167,7 +294,7 @@ export function computeDebtLedger(current, snapshots, opts = {}) {
|
|
|
167
294
|
|
|
168
295
|
const byName = headProjects(files);
|
|
169
296
|
|
|
170
|
-
/** @type {{source: string, kind: string, severity: string, age: number, count: number, remediationHint: string}[]} */
|
|
297
|
+
/** @type {{source: string, kind: string, severity: string, age: number, count: number, remediationHint: string, id: string, status: "active", introducedBy?: string}[]} */
|
|
171
298
|
const entries = [];
|
|
172
299
|
|
|
173
300
|
for (const suppression of current.suppressions ?? []) {
|
|
@@ -179,28 +306,58 @@ export function computeDebtLedger(current, snapshots, opts = {}) {
|
|
|
179
306
|
// suppression (no `expiresAt`) is `suppress`: still low and permanent.
|
|
180
307
|
const fate = suppressionFate(suppression, sampleTime);
|
|
181
308
|
const expired = fate === "reassert";
|
|
309
|
+
const kind = expired ? "expired-waiver" : "waiver";
|
|
182
310
|
entries.push({
|
|
183
311
|
source: suppression.path,
|
|
184
|
-
kind
|
|
312
|
+
kind,
|
|
185
313
|
severity: expired ? "medium" : "low",
|
|
186
314
|
age: project ? ageOf(project) : 0,
|
|
187
315
|
count: 1,
|
|
316
|
+
id: entryId(kind, suppression.path),
|
|
317
|
+
status: "active",
|
|
188
318
|
remediationHint: expired
|
|
189
319
|
? `the waiver at '${suppression.path}' expired — the boundary it accepted is live again (${EXPIRED_WAIVER_EVIDENCE}); renew it or retire it`
|
|
190
320
|
: `the accepted violation at '${suppression.path}' is still suppressed — ` +
|
|
191
321
|
(project ? `owning project '${project}'` : "retire it or confirm the reason"),
|
|
192
322
|
});
|
|
193
323
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
324
|
+
// Aspirational-gap entries: an `optional: true` `allowed` row not yet built.
|
|
325
|
+
// Identity comes from the STRUCTURED `{from, to}` (F-DEB-8) — never from the
|
|
326
|
+
// prose note, which re-keys every gap when the wording changes. `gaps` is
|
|
327
|
+
// the structured source when a caller threads it (the producer emits the
|
|
328
|
+
// same `{from, to}` ids the ledger derives here); the prose `intentNotes`
|
|
329
|
+
// fallback keys on the note itself only for callers that pass notes with no
|
|
330
|
+
// structured gaps — a deprecated shape, retained so the identity home stays
|
|
331
|
+
// single (the `debtFactId` here is the one the producers must match).
|
|
332
|
+
const gaps = current.gaps ?? [];
|
|
333
|
+
if (gaps.length > 0) {
|
|
334
|
+
for (const gap of gaps) {
|
|
335
|
+
entries.push({
|
|
336
|
+
source: gap.note ?? `${gap.from} → ${gap.to}`,
|
|
337
|
+
kind: "aspirational-gap",
|
|
338
|
+
severity: "low",
|
|
339
|
+
age: 0,
|
|
340
|
+
count: 1,
|
|
341
|
+
id: debtFactId("aspirational-gap", { from: gap.from, to: gap.to }),
|
|
342
|
+
status: "active",
|
|
343
|
+
remediationHint:
|
|
344
|
+
"an optional allowed row is not yet built — either build it or remove the row",
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
for (const note of current.intentNotes ?? []) {
|
|
349
|
+
entries.push({
|
|
350
|
+
source: note,
|
|
351
|
+
kind: "aspirational-gap",
|
|
352
|
+
severity: "low",
|
|
353
|
+
age: 0,
|
|
354
|
+
count: 1,
|
|
355
|
+
id: entryId("aspirational-gap", note),
|
|
356
|
+
status: "active",
|
|
357
|
+
remediationHint:
|
|
358
|
+
"an optional allowed row is not yet built — either build it or remove the row",
|
|
359
|
+
});
|
|
360
|
+
}
|
|
204
361
|
}
|
|
205
362
|
|
|
206
363
|
// Which projects hold an accepted waiver — so a drift finding in the same
|
|
@@ -217,12 +374,20 @@ export function computeDebtLedger(current, snapshots, opts = {}) {
|
|
|
217
374
|
for (const finding of current.findings ?? []) {
|
|
218
375
|
const project = typeof finding.source === "string" ? finding.source : null;
|
|
219
376
|
const waiverFailed = project !== null && waiverProjects.has(project);
|
|
377
|
+
// The stable fact keys on the full semantic tuple (source, target, rule)
|
|
378
|
+
// — never on `finding.source` alone, which collides every distinct
|
|
379
|
+
// same-source finding onto one id (F-DEB-5), and never on the prose
|
|
380
|
+
// `finding.message`, which re-keys a fact when the wording changes.
|
|
381
|
+
// `driftFactOf` is the ONE builder both the ledger and the producers use,
|
|
382
|
+
// so an introduced id can never disagree with the ledger's active id.
|
|
220
383
|
entries.push({
|
|
221
384
|
source: finding.source ?? finding.message,
|
|
222
385
|
kind: "drift",
|
|
223
386
|
severity: waiverFailed ? "high" : "medium",
|
|
224
387
|
age: project ? ageOf(project) : 0,
|
|
225
388
|
count: 1,
|
|
389
|
+
id: debtFactId("drift", driftFactOf(finding)),
|
|
390
|
+
status: "active",
|
|
226
391
|
remediationHint: waiverFailed
|
|
227
392
|
? `this drift finding is in a project with an accepted waiver — the accepted violation is failing again, resolve it or remove the waiver`
|
|
228
393
|
: "a dependency the intent forbids (or allows but is not built) — resolve the contradiction",
|
|
@@ -235,6 +400,8 @@ export function computeDebtLedger(current, snapshots, opts = {}) {
|
|
|
235
400
|
severity: "unknown",
|
|
236
401
|
age: 0,
|
|
237
402
|
count: 1,
|
|
403
|
+
id: entryId("unresolved", unresolved.boundary),
|
|
404
|
+
status: "active",
|
|
238
405
|
remediationHint:
|
|
239
406
|
"an intent boundary matched no observed project — the intent cannot be verified",
|
|
240
407
|
});
|
|
@@ -270,5 +437,80 @@ export function computeDebtLedger(current, snapshots, opts = {}) {
|
|
|
270
437
|
if (entry.severity !== "unknown") bySeverity[entry.severity] += 1;
|
|
271
438
|
}
|
|
272
439
|
|
|
273
|
-
|
|
440
|
+
// The lifecycle surface (design §6): every active entry carries a stable id
|
|
441
|
+
// and `status: "active"`. When an event store is linked, REPAIR events name
|
|
442
|
+
// the debt they closed (`debt.resolved`) and introduction events name what
|
|
443
|
+
// they opened (`debt.introduced`); the closure is only accepted when the
|
|
444
|
+
// candidate fact is NOT still active at head (an id that came back is not
|
|
445
|
+
// resolved). Without a linked store, `resolved` stays empty and no ref is
|
|
446
|
+
// ever fabricated — the note states the refs are unavailable instead.
|
|
447
|
+
const events = loadEvents(opts);
|
|
448
|
+
const activeIds = new Set(entries.map((entry) => entry.id));
|
|
449
|
+
/** @type {{id: string, status: "resolved", resolvedBy: string}[]} */
|
|
450
|
+
const resolved = [];
|
|
451
|
+
const lifecycle = { linked: events !== null, note: null };
|
|
452
|
+
|
|
453
|
+
if (events === null) {
|
|
454
|
+
lifecycle.note = "no event store linked — lifecycle refs unavailable";
|
|
455
|
+
} else {
|
|
456
|
+
/** @type {Map<string, string>} id → the first event that introduced it. */
|
|
457
|
+
const introducedByForId = new Map();
|
|
458
|
+
/** @type {Set<string>} debt ids already placed on the resolved list. */
|
|
459
|
+
const resolvedSeen = new Set();
|
|
460
|
+
for (const event of events) {
|
|
461
|
+
// The store validates that every event carries a string id, but the
|
|
462
|
+
// reader shape is loosely typed — coerce so the ref string is always a
|
|
463
|
+
// real string, never a fabricated one.
|
|
464
|
+
const eventId = typeof event?.id === "string" ? event.id : "";
|
|
465
|
+
for (const debtId of event?.debt?.introduced ?? []) {
|
|
466
|
+
if (typeof debtId === "string" && !introducedByForId.has(debtId)) {
|
|
467
|
+
introducedByForId.set(debtId, eventId);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const repairs = Array.isArray(event?.classifications)
|
|
471
|
+
? event.classifications.includes("REPAIR")
|
|
472
|
+
: false;
|
|
473
|
+
if (!repairs) continue;
|
|
474
|
+
for (const debtId of event?.debt?.resolved ?? []) {
|
|
475
|
+
// Closure is only real when the candidate fact is gone at head; a
|
|
476
|
+
// debt id still active is not resolved (closed then re-opened), and
|
|
477
|
+
// an id NO event ever introduced was never debt — resolving it would
|
|
478
|
+
// invent a foreign fact out of nothing (inv. 2/7, F-DEB-2).
|
|
479
|
+
if (
|
|
480
|
+
typeof debtId !== "string" ||
|
|
481
|
+
activeIds.has(debtId) ||
|
|
482
|
+
resolvedSeen.has(debtId) ||
|
|
483
|
+
!introducedByForId.has(debtId)
|
|
484
|
+
)
|
|
485
|
+
continue;
|
|
486
|
+
resolvedSeen.add(debtId);
|
|
487
|
+
// The resolved surface carries ONLY evidence-backed refs. The full
|
|
488
|
+
// original entry (kind, severity, age, count) is not reconstructed
|
|
489
|
+
// here — that record lives on in the history snapshots behind the
|
|
490
|
+
// ledger; `kind:"debt"`, `age:0`, `count:1` would be fabricated
|
|
491
|
+
// numerics (design §6 retains the record, never a stand-in).
|
|
492
|
+
resolved.push({ id: debtId, status: "resolved", resolvedBy: eventId });
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (resolved.length > 0 && lifecycle.note === null) {
|
|
496
|
+
lifecycle.note =
|
|
497
|
+
"resolved rows retain only evidence-backed refs — each closed debt's full entry lives in the history snapshots";
|
|
498
|
+
}
|
|
499
|
+
for (const entry of entries) {
|
|
500
|
+
const introducedBy = introducedByForId.get(entry.id);
|
|
501
|
+
if (introducedBy !== undefined) entry.introducedBy = introducedBy;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
resolved.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
entries,
|
|
508
|
+
resolved,
|
|
509
|
+
total: entries.length,
|
|
510
|
+
byKind,
|
|
511
|
+
bySeverity,
|
|
512
|
+
agings,
|
|
513
|
+
sampleTime,
|
|
514
|
+
lifecycle,
|
|
515
|
+
};
|
|
274
516
|
}
|
|
@@ -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
|
+
}
|