@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
package/src/report/adr-text.mjs
CHANGED
|
@@ -15,10 +15,90 @@ import { ADR_DIR, ADR_STATUSES } from "../governance/adr-registry.mjs";
|
|
|
15
15
|
/** A status label for a record, in the text a human reads. */
|
|
16
16
|
function statusLabel(status) {
|
|
17
17
|
if (status === "accepted") return "accepted";
|
|
18
|
+
if (status === "active") return "active";
|
|
18
19
|
if (status === "superseded") return "superseded";
|
|
20
|
+
if (status === "retired") return "retired";
|
|
19
21
|
return "proposed";
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The `fitness:` line for one record — the per-decision verification level
|
|
26
|
+
* the command derives (`../governance/decision-fitness.mjs` owns the
|
|
27
|
+
* vocabulary). The line is always printed with its reason when the level has
|
|
28
|
+
* one; an `enforced` level carries none and says so. This command alone can
|
|
29
|
+
* never verify anything (its header's "What it cannot assert" owns why), so
|
|
30
|
+
* the level comes in precomputed from the caller — absent it, the line is an
|
|
31
|
+
* explicit "(not measured)" that cannot be mistaken for a verdict.
|
|
32
|
+
*
|
|
33
|
+
* @param {{level: string, verified: boolean, reason?: string}|undefined} fitness
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
function fitnessLine(fitness) {
|
|
37
|
+
if (fitness === undefined) return `fitness: ${"(not measured)"}`;
|
|
38
|
+
if (fitness.verified)
|
|
39
|
+
return `fitness: ${fitness.level} — verified true: bound constraints resolve and pass`;
|
|
40
|
+
if (typeof fitness.reason === "string") {
|
|
41
|
+
return `fitness: ${fitness.level} — ${fitness.reason}`;
|
|
42
|
+
}
|
|
43
|
+
return `fitness: ${fitness.level}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The `created:` / `updated:` metadata lines for one record, when the record
|
|
48
|
+
* carries them. Absent frontmatter stays absent — these are the decision's
|
|
49
|
+
* own committed timeline, never a wall-clock guess.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} record
|
|
52
|
+
* @returns {string[]}
|
|
53
|
+
*/
|
|
54
|
+
function timelineLines(record) {
|
|
55
|
+
const lines = [];
|
|
56
|
+
if (typeof record.created === "string") lines.push(`created: ${record.created}`);
|
|
57
|
+
if (typeof record.updated === "string") lines.push(`updated: ${record.updated}`);
|
|
58
|
+
return lines;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The reverse lineage line — the records whose `supersedes` names this one,
|
|
63
|
+
* derived at load by the registry and therefore always consistent with the
|
|
64
|
+
* forward `supersedes:` line on the other record.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} record
|
|
67
|
+
* @returns {string[]}
|
|
68
|
+
*/
|
|
69
|
+
function supersededByLines(record) {
|
|
70
|
+
if (!Array.isArray(record.supersededBy) || record.supersededBy.length === 0) return [];
|
|
71
|
+
return [`supersededBy: ${record.supersededBy.join(", ")}`];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One prose field rendered for the single-record view: the label on the
|
|
76
|
+
* first line, continued lines aligned under the prose. A field that is
|
|
77
|
+
* absent or blank renders nothing — the record's own body decides.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} label The 12-wide `label:` prefix.
|
|
80
|
+
* @param {string} text The field's markdown prose.
|
|
81
|
+
* @returns {string[]}
|
|
82
|
+
*/
|
|
83
|
+
function proseLines(label, text) {
|
|
84
|
+
const content = text.split("\n").map((line) => line.trimEnd());
|
|
85
|
+
const out = [`${label}${content[0]}`];
|
|
86
|
+
for (const line of content.slice(1)) {
|
|
87
|
+
out.push(`${" ".repeat(label.length)}${line}`);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The prose fields of one record, in the order §3.2 of the wave 2 contract names them. */
|
|
93
|
+
const PROSE_FIELDS = [
|
|
94
|
+
["context: ", "context"],
|
|
95
|
+
["decision: ", "decision"],
|
|
96
|
+
["rationale: ", "rationale"],
|
|
97
|
+
["alternatives:", "alternatives"],
|
|
98
|
+
["consequences:", "consequences"],
|
|
99
|
+
["assumptions: ", "assumptions"],
|
|
100
|
+
];
|
|
101
|
+
|
|
22
102
|
/**
|
|
23
103
|
* The `bindings:` line for one record — the ONE place either face decides how
|
|
24
104
|
* a binding is rendered.
|
|
@@ -54,21 +134,26 @@ function bindingsLine(record, known) {
|
|
|
54
134
|
* Renders the whole registry: one block per record, each naming its status,
|
|
55
135
|
* its supersession chain, and the rules/fitnesses it binds.
|
|
56
136
|
*
|
|
57
|
-
* @param {{records: object[], knownFitness?: Set<string
|
|
137
|
+
* @param {{records: object[], knownFitness?: Set<string>, fitnessById?:
|
|
138
|
+
* Map<string, {level: string, verified: boolean, reason?: string}>}} result
|
|
58
139
|
* @returns {string}
|
|
59
140
|
*/
|
|
60
|
-
export function formatAdrDump({ records, knownFitness }) {
|
|
141
|
+
export function formatAdrDump({ records, knownFitness, fitnessById }) {
|
|
61
142
|
if (records.length === 0) {
|
|
62
143
|
return `no ADRs in ${ADR_DIR}/ — nothing is recorded, and nothing is enforceable through it`;
|
|
63
144
|
}
|
|
64
145
|
const known = knownFitness ?? new Set();
|
|
146
|
+
const fitnessMap = fitnessById ?? new Map();
|
|
65
147
|
const blocks = records.map((record) => {
|
|
66
148
|
const header = `${record.id} (${statusLabel(record.status)})`;
|
|
67
149
|
const lines = [header, "-".repeat(header.length)];
|
|
68
150
|
if (record.supersedes.length > 0) {
|
|
69
151
|
lines.push(`supersedes: ${record.supersedes.join(", ")}`);
|
|
70
152
|
}
|
|
153
|
+
lines.push(...supersededByLines(record));
|
|
154
|
+
lines.push(...timelineLines(record));
|
|
71
155
|
lines.push(bindingsLine(record, known));
|
|
156
|
+
lines.push(fitnessLine(fitnessMap.get(record.id)));
|
|
72
157
|
lines.push(`status set: ${ADR_STATUSES.join(", ")}`);
|
|
73
158
|
return lines.join("\n");
|
|
74
159
|
});
|
|
@@ -110,20 +195,40 @@ export function formatAdrMissing({ adrId }) {
|
|
|
110
195
|
* a reader who asked about one record sees strictly less than one who dumped
|
|
111
196
|
* the registry otherwise, and what they stop seeing is the marker.
|
|
112
197
|
*
|
|
113
|
-
*
|
|
198
|
+
* Beyond the dump's lines it also renders the record's own prose (context,
|
|
199
|
+
* decision, rationale, alternatives, consequences, assumptions) when the body
|
|
200
|
+
* records them — this is the view a reader asking "why was this decided" gets.
|
|
201
|
+
*
|
|
202
|
+
* @param {{id: string, status: string, supersedes: string[], supersededBy?:
|
|
203
|
+
* string[], bindings: string[], created?: string, updated?: string, context?:
|
|
204
|
+
* string, decision?: string, rationale?: string, alternatives?: string,
|
|
205
|
+
* consequences?: string, assumptions?: string}} record
|
|
114
206
|
* @param {Set<string>} [knownFitness] The ids the registry's records mention.
|
|
115
207
|
* Optional only so the parameter can be omitted where there is nothing to
|
|
116
208
|
* compare against; an absent set marks every binding rather than none,
|
|
117
209
|
* because "nothing corroborates this" is the honest answer when no set was
|
|
118
210
|
* supplied — never the quiet one.
|
|
211
|
+
* @param {Map<string, {level: string, verified: boolean, reason?: string}>}
|
|
212
|
+
* [fitnessById] The fitness level per record id, computed by the caller
|
|
213
|
+
* (`../commands/adr.mjs`). Absent, a record's fitness line reads as the
|
|
214
|
+
* explicit "(not measured)" — never as a verdict this renderer invented.
|
|
119
215
|
* @returns {string}
|
|
120
216
|
*/
|
|
121
|
-
export function formatAdrRecord(record, knownFitness) {
|
|
217
|
+
export function formatAdrRecord(record, knownFitness, fitnessById) {
|
|
122
218
|
const header = `${record.id} (${statusLabel(record.status)})`;
|
|
123
219
|
const lines = [header, "-".repeat(header.length)];
|
|
124
220
|
if (record.supersedes.length > 0) {
|
|
125
221
|
lines.push(`supersedes: ${record.supersedes.join(", ")}`);
|
|
126
222
|
}
|
|
223
|
+
lines.push(...supersededByLines(record));
|
|
224
|
+
lines.push(...timelineLines(record));
|
|
127
225
|
lines.push(bindingsLine(record, knownFitness ?? new Set()));
|
|
226
|
+
lines.push(fitnessLine(fitnessById?.get(record.id)));
|
|
227
|
+
for (const [label, key] of PROSE_FIELDS) {
|
|
228
|
+
const text = record[key];
|
|
229
|
+
if (typeof text === "string" && text.trim() !== "") {
|
|
230
|
+
lines.push(...proseLines(label, text));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
128
233
|
return lines.join("\n");
|
|
129
234
|
}
|
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
* outcome, so an empty reconciliation is a verifiable claim rather than
|
|
14
14
|
* silence (`../../../../AGENTS.md`).
|
|
15
15
|
*
|
|
16
|
+
* With `--event-out`, the report's first section names the reconcile event
|
|
17
|
+
* that was recorded (id, directory, duplicate-or-written) — the one section
|
|
18
|
+
* that renders only when the flag was passed, so the default report is
|
|
19
|
+
* byte-identical to a pre-wave-3 run.
|
|
20
|
+
*
|
|
16
21
|
* This module decides nothing. A formatter that filtered would be a rule
|
|
17
22
|
* wearing a formatter's name (`./README.md`).
|
|
18
23
|
*/
|
|
@@ -62,14 +67,27 @@ function describeOrigin(provenance) {
|
|
|
62
67
|
/**
|
|
63
68
|
* The whole change report.
|
|
64
69
|
*
|
|
65
|
-
* @param {{change: object, coverage: object}} input
|
|
66
|
-
* `../commands/change.mjs`'s result payload; `coverage` its
|
|
70
|
+
* @param {{change: object, coverage: object, eventWritten?: object}} input
|
|
71
|
+
* `change` is `../commands/change.mjs`'s result payload; `coverage` its
|
|
72
|
+
* coverage block; `eventWritten` the reconcile event write result (`{dir,
|
|
73
|
+
* id, duplicate}`) — present ONLY when `--event-out` was passed, so the
|
|
74
|
+
* default report is byte-identical to a pre-wave-3 run.
|
|
67
75
|
* @returns {string}
|
|
68
76
|
*/
|
|
69
|
-
export function formatChangeReport({ change, coverage }) {
|
|
77
|
+
export function formatChangeReport({ change, coverage, eventWritten }) {
|
|
70
78
|
const { intent, baseline, head, reconciliation, constraints, policy } = change;
|
|
71
79
|
const sections = [];
|
|
72
80
|
|
|
81
|
+
// The reconcile event line renders ONLY when `--event-out` was passed and
|
|
82
|
+
// the run wrote one (`../commands/change.mjs`): the write is opt-in output
|
|
83
|
+
// the report makes observable rather than a file appearing silently.
|
|
84
|
+
if (eventWritten !== undefined) {
|
|
85
|
+
sections.push(
|
|
86
|
+
`event reconcile/change ${eventWritten.id.slice(0, 8)} → ${eventWritten.dir}` +
|
|
87
|
+
(eventWritten.duplicate ? " (duplicate — nothing written)" : ""),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
73
91
|
sections.push(
|
|
74
92
|
`intent ${intent.file} — base ${intent.base.commit.slice(0, 8)}` +
|
|
75
93
|
(intent.summary === undefined ? "" : `\n "${intent.summary}"`),
|
package/src/report/debt-text.mjs
CHANGED
|
@@ -41,8 +41,11 @@ function sanitize(text) {
|
|
|
41
41
|
*
|
|
42
42
|
* @param {{ledger: {dir: string, snapshots: number, agings: boolean,
|
|
43
43
|
* sampleTime: string, entries: {source: string, kind: string,
|
|
44
|
-
* severity: string, age: number, count: number, remediationHint: string
|
|
45
|
-
*
|
|
44
|
+
* severity: string, age: number, count: number, remediationHint: string,
|
|
45
|
+
* id: string, status: string, introducedBy?: string}[],
|
|
46
|
+
* resolved?: {id: string, status: string, resolvedBy: string}[],
|
|
47
|
+
* total: number, byKind: object, bySeverity: object,
|
|
48
|
+
* lifecycle?: {linked: boolean, note: string|null}},
|
|
46
49
|
* coverage: object}} input
|
|
47
50
|
* @returns {string}
|
|
48
51
|
*/
|
|
@@ -60,15 +63,14 @@ export function formatDebtReport({ ledger, coverage }) {
|
|
|
60
63
|
|
|
61
64
|
const orderedKinds = [
|
|
62
65
|
["waiver", "waivers (accepted boundary violations)"],
|
|
66
|
+
["expired-waiver", "expired waivers (accepted violations that lapsed back into force)"],
|
|
63
67
|
["aspirational-gap", "aspirational gaps (optional allowed rows not built)"],
|
|
64
68
|
["drift", "drift findings"],
|
|
65
69
|
["unresolved", "unresolved intent"],
|
|
66
70
|
];
|
|
67
|
-
let sawAny = false;
|
|
68
71
|
for (const [kind, label] of orderedKinds) {
|
|
69
72
|
const items = ledger.entries.filter((e) => e.kind === kind);
|
|
70
73
|
if (items.length === 0) continue;
|
|
71
|
-
sawAny = true;
|
|
72
74
|
sections.push(`${items.length} ${label}:`);
|
|
73
75
|
for (const entry of items) {
|
|
74
76
|
const age = ledger.agings ? `age ${entry.age}` : "age not yet established";
|
|
@@ -76,17 +78,51 @@ export function formatDebtReport({ ledger, coverage }) {
|
|
|
76
78
|
` [${entry.kind}] ${entry.severity} ${sanitize(entry.source)} (${age}, count ${entry.count})`,
|
|
77
79
|
);
|
|
78
80
|
sections.push(` ${sanitize(entry.remediationHint)}`);
|
|
81
|
+
// The lifecycle fields (design §6) ride the entry as appended lines only;
|
|
82
|
+
// every existing line above keeps its exact bytes. When no event store is
|
|
83
|
+
// linked, the id/status are still printed (they are facts about the entry,
|
|
84
|
+
// always determinable); refs are printed only when actually present.
|
|
85
|
+
sections.push(` id ${entry.id} · status ${entry.status}`);
|
|
86
|
+
if (entry.introducedBy) sections.push(` introducedBy ${entry.introducedBy}`);
|
|
79
87
|
}
|
|
80
88
|
}
|
|
81
|
-
|
|
82
|
-
|
|
89
|
+
// The positive claim must be byte-truthful: no CURRENT findings AND no
|
|
90
|
+
// retained resolution history. A ledger with an empty entry list but a
|
|
91
|
+
// non-empty resolved list is not "no architecture debt" — it has history
|
|
92
|
+
// that was resolved and is retained below (F-DEB-4).
|
|
93
|
+
const resolved = ledger.resolved ?? [];
|
|
94
|
+
if (ledger.entries.length === 0 && resolved.length === 0) {
|
|
83
95
|
sections.push(
|
|
84
96
|
"✔ no architecture debt — no waivers, aspirational gaps, drift or unresolved intent",
|
|
85
97
|
);
|
|
98
|
+
} else if (ledger.entries.length === 0 && resolved.length > 0) {
|
|
99
|
+
sections.push(
|
|
100
|
+
`no current architecture debt; ${resolved.length} resolved entry${resolved.length === 1 ? "" : "s"} retained below`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The resolved surface (design §6): debt whose candidate fact is gone at
|
|
105
|
+
// head AND closed by evidence (a REPAIR event). Entries are retained — the
|
|
106
|
+
// history is never deleted — but they are no longer current findings. This
|
|
107
|
+
// list is empty (and no line is printed) when no event store is linked or
|
|
108
|
+
// nothing has been resolved.
|
|
109
|
+
if (resolved.length > 0) {
|
|
110
|
+
sections.push(`${resolved.length} resolved (no longer current findings):`);
|
|
111
|
+
for (const entry of resolved) {
|
|
112
|
+
// The resolved surface is evidence-backed only — id/status/resolvedBy
|
|
113
|
+
// (F-DEB-2). The kind/severity/hint of the original entry live in the
|
|
114
|
+
// history snapshots the ledger read, never in this row.
|
|
115
|
+
sections.push(` id ${entry.id} · status ${entry.status}`);
|
|
116
|
+
if (entry.resolvedBy) sections.push(` resolvedBy ${entry.resolvedBy}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (ledger.lifecycle?.note) {
|
|
120
|
+
sections.push(ledger.lifecycle.note);
|
|
86
121
|
}
|
|
87
122
|
|
|
88
123
|
sections.push(
|
|
89
124
|
`total ${ledger.total} ${word} · byKind: waiver ${ledger.byKind.waiver}, ` +
|
|
125
|
+
`expired-waiver ${ledger.byKind["expired-waiver"]}, ` +
|
|
90
126
|
`aspirational-gap ${ledger.byKind["aspirational-gap"]}, drift ${ledger.byKind.drift}, ` +
|
|
91
127
|
`unresolved ${ledger.byKind.unresolved} · bySeverity: high ${ledger.bySeverity.high}, ` +
|
|
92
128
|
`medium ${ledger.bySeverity.medium}, low ${ledger.bySeverity.low}`,
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `decisions` command's text renderer — the deterministic chain
|
|
3
|
+
* decision → governed rows → projects → findings → fitness, one function a
|
|
4
|
+
* test drives for both the resolved and the unresolved walk.
|
|
5
|
+
*
|
|
6
|
+
* The renderer owns the invariant: a chain that could not walk every hop
|
|
7
|
+
* (`walk.ok === false`) renders a loud unresolved block, never a clean-looking
|
|
8
|
+
* chain. The walk (`../governance/decision-graph.mjs`) reports each
|
|
9
|
+
* unresolved reference with a reason; this face prints them all, so a reader
|
|
10
|
+
* sees which hop broke and why — the same refrain as every other surface in
|
|
11
|
+
* this wave ("an empty result is a claim, not a shrug").
|
|
12
|
+
*
|
|
13
|
+
* It is a pure function of the walk result + the record + the fitness level;
|
|
14
|
+
* it reads no files. The fitness line reuses the `fitness:` byte convention
|
|
15
|
+
* `./adr-text.mjs` establishes, so the two decision surfaces cannot disagree
|
|
16
|
+
* about what a level means.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** A status label for a record, in the same terms `./adr-text.mjs` uses. */
|
|
20
|
+
function statusLabel(status) {
|
|
21
|
+
if (status === "accepted") return "accepted";
|
|
22
|
+
if (status === "active") return "active";
|
|
23
|
+
if (status === "superseded") return "superseded";
|
|
24
|
+
if (status === "retired") return "retired";
|
|
25
|
+
return "proposed";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The `fitness:` line — byte-identical to `./adr-text.mjs`'s own, so a fitness
|
|
30
|
+
* level means the same thing on the `adr` face and here.
|
|
31
|
+
*
|
|
32
|
+
* @param {{level: string, verified: boolean, reason?: string}|undefined} fitness
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
function fitnessLine(fitness) {
|
|
36
|
+
if (fitness === undefined) return `fitness: ${"(not measured)"}`;
|
|
37
|
+
if (fitness.verified)
|
|
38
|
+
return `fitness: ${fitness.level} — verified true: bound constraints resolve and pass`;
|
|
39
|
+
if (typeof fitness.reason === "string") {
|
|
40
|
+
return `fitness: ${fitness.level} — ${fitness.reason}`;
|
|
41
|
+
}
|
|
42
|
+
return `fitness: ${fitness.level}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One prose field rendered for a record, first line carrying the label. */
|
|
46
|
+
function proseLines(label, text) {
|
|
47
|
+
const first = text.split("\n")[0];
|
|
48
|
+
const rest = text.split("\n").slice(1);
|
|
49
|
+
const out = [`${label}${first}`];
|
|
50
|
+
for (const line of rest) out.push(`${" ".repeat(label.length)}${line}`);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The prose fields of one record, in the order the wave-2 contract names them. */
|
|
55
|
+
const PROSE_FIELDS = [
|
|
56
|
+
["context: ", "context"],
|
|
57
|
+
["decision: ", "decision"],
|
|
58
|
+
["rationale: ", "rationale"],
|
|
59
|
+
["alternatives:", "alternatives"],
|
|
60
|
+
["consequences:", "consequences"],
|
|
61
|
+
["assumptions: ", "assumptions"],
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/** The kind label a governed row node renders as. */
|
|
65
|
+
const ROW_KIND_LABEL = {
|
|
66
|
+
intent: "intent row",
|
|
67
|
+
constraint: "constraint",
|
|
68
|
+
fitness: "fitness rule",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Renders the deterministic decision chain for one record.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} args
|
|
75
|
+
* @param {string} args.decisionId The id the caller asked about.
|
|
76
|
+
* @param {object} args.record The ADR record the chain is about.
|
|
77
|
+
* @param {object} args.walk The `forwardDecision` walk
|
|
78
|
+
* (`../governance/decision-graph.mjs`): `{ok, nodes, edges, unresolved}`.
|
|
79
|
+
* @param {{level: string, verified: boolean, reason?: string}|undefined}
|
|
80
|
+
* args.fitness The per-decision fitness level, computed by the caller.
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
export function formatDecisionChain({ decisionId, record, walk, fitness }) {
|
|
84
|
+
const lines = [];
|
|
85
|
+
|
|
86
|
+
// An id the registry does not know is a named unknown, never a clean chain:
|
|
87
|
+
// the record is null, so there is no header to derive — the walk's
|
|
88
|
+
// unresolved block below is the whole answer. The renderer must not throw
|
|
89
|
+
// over a null record; that is precisely the case it exists to report.
|
|
90
|
+
const header =
|
|
91
|
+
record === null || record === undefined
|
|
92
|
+
? `${decisionId} (unknown)`
|
|
93
|
+
: `${record.id} (${statusLabel(record.status)})`;
|
|
94
|
+
lines.push(header, "-".repeat(header.length));
|
|
95
|
+
|
|
96
|
+
if (record !== null && record !== undefined) {
|
|
97
|
+
for (const [label, key] of PROSE_FIELDS) {
|
|
98
|
+
const text = record[key];
|
|
99
|
+
if (typeof text === "string" && text.trim() !== "") {
|
|
100
|
+
lines.push(...proseLines(label, text));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if ((record.supersedes ?? []).length > 0) {
|
|
105
|
+
lines.push(`supersedes: ${record.supersedes.join(", ")}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
lines.push(fitnessLine(fitness));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The governed rows the decision attaches (decisionRef citations and
|
|
112
|
+
// bindings): the "who stands on this decision" half of the chain.
|
|
113
|
+
const rowNodes = walk.nodes.filter(
|
|
114
|
+
(node) => node.kind === "constraint" || node.kind === "intent" || node.kind === "fitness",
|
|
115
|
+
);
|
|
116
|
+
if (rowNodes.length > 0) {
|
|
117
|
+
lines.push("governs:");
|
|
118
|
+
for (const node of rowNodes) {
|
|
119
|
+
const governed = walk.edges
|
|
120
|
+
.filter((edge) => edge.kind === "governs" && edge.from === node.id)
|
|
121
|
+
.map((edge) => edge.to);
|
|
122
|
+
const kind = ROW_KIND_LABEL[node.kind] ?? node.kind;
|
|
123
|
+
const target = governed.length > 0 ? ` → ${governed.join(", ")}` : "";
|
|
124
|
+
lines.push(` ${node.id} (${kind})${target}`);
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
lines.push("governs: (none — recorded but not enforceable)");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The evidence leg: every project the rows govern, and each project's
|
|
131
|
+
// current findings. A project with no findings is a fact, stated as such.
|
|
132
|
+
const projectNodes = walk.nodes.filter((node) => node.kind === "project");
|
|
133
|
+
if (projectNodes.length > 0) {
|
|
134
|
+
lines.push("evidence:");
|
|
135
|
+
for (const project of projectNodes) {
|
|
136
|
+
const findings = walk.nodes
|
|
137
|
+
.filter((node) => node.kind === "finding")
|
|
138
|
+
.filter((finding) =>
|
|
139
|
+
walk.edges.some(
|
|
140
|
+
(edge) => edge.kind === "finding" && edge.from === project.id && edge.to === finding.id,
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
if (findings.length === 0) {
|
|
144
|
+
lines.push(` ${project.id}: no current findings`);
|
|
145
|
+
} else {
|
|
146
|
+
for (const finding of findings) {
|
|
147
|
+
const ruleId = finding.data?.ruleId ?? "";
|
|
148
|
+
lines.push(` ${project.id}: ${finding.label}${ruleId ? ` (${ruleId})` : ""}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The loud unresolved block — a walk that could not resolve every hop never
|
|
155
|
+
// reads as a clean chain.
|
|
156
|
+
if (!walk.ok) {
|
|
157
|
+
lines.push("unresolved:");
|
|
158
|
+
for (const entry of walk.unresolved) {
|
|
159
|
+
lines.push(` ${entry.ref}: ${entry.reason}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return lines.join("\n");
|
|
164
|
+
}
|
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
* `../commands/delta.mjs` pushes there — fold into the report as their own
|
|
19
19
|
* lines, so a note that rides the JSON envelope also reaches the terminal.
|
|
20
20
|
*
|
|
21
|
-
*
|
|
21
|
+
* The wave-3 additive block — `classifications` and `affected` — is appended
|
|
22
|
+
* after every existing line (and only when the payload carries the fields), so
|
|
23
|
+
* a payload that predates them renders exactly what it always did. This module
|
|
24
|
+
* decides nothing. A formatter that filtered would be a rule
|
|
22
25
|
* wearing a formatter's name (`./README.md`).
|
|
23
26
|
*/
|
|
24
27
|
|
|
@@ -259,6 +262,38 @@ export function formatDeltaReport({ delta, coverage }) {
|
|
|
259
262
|
`no waiver lane, every one gates`,
|
|
260
263
|
);
|
|
261
264
|
}
|
|
265
|
+
// The wave-3 additive block: the evolution classification and its affected
|
|
266
|
+
// identities, appended after every existing line so an older report's lines
|
|
267
|
+
// stay byte-identical. Rendered only when the payload carries the fields —
|
|
268
|
+
// a payload that predates them renders exactly what it always did. An empty
|
|
269
|
+
// classification list is not silence here: the closing claims above already
|
|
270
|
+
// state what was compared, and `classifications none` says plainly that no
|
|
271
|
+
// class applies.
|
|
272
|
+
if (delta.classifications !== undefined) {
|
|
273
|
+
sections.push(
|
|
274
|
+
`classifications ${
|
|
275
|
+
delta.classifications.length === 0 ? "none" : delta.classifications.join(", ")
|
|
276
|
+
}`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (delta.affected !== undefined) {
|
|
280
|
+
const affectedParts = [];
|
|
281
|
+
if (delta.affected.projects.length > 0) {
|
|
282
|
+
affectedParts.push(`projects: ${delta.affected.projects.join(", ")}`);
|
|
283
|
+
}
|
|
284
|
+
if (delta.affected.boundaries.length > 0) {
|
|
285
|
+
affectedParts.push(`boundaries: ${delta.affected.boundaries.join(", ")}`);
|
|
286
|
+
}
|
|
287
|
+
if (delta.affected.constraints.length > 0) {
|
|
288
|
+
affectedParts.push(`constraints: ${delta.affected.constraints.join(", ")}`);
|
|
289
|
+
}
|
|
290
|
+
if (delta.affected.decisions.length > 0) {
|
|
291
|
+
affectedParts.push(`decisions: ${delta.affected.decisions.join(", ")}`);
|
|
292
|
+
}
|
|
293
|
+
sections.push(
|
|
294
|
+
`affected ${affectedParts.length === 0 ? "none" : affectedParts.join(" · ")}`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
262
297
|
|
|
263
298
|
return sections.join("\n");
|
|
264
299
|
}
|