@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
|
@@ -22,8 +22,10 @@ import { formatChanges, sanitize, transitionKind } from "./snapshot-text.mjs";
|
|
|
22
22
|
* @param {{result: {base: string, head: string,
|
|
23
23
|
* revisions: {commit: string, id: string}[],
|
|
24
24
|
* transitions: {from: string, to: string, architectureChanged: boolean,
|
|
25
|
-
* changes: object|null, policyChanged: boolean|null,
|
|
26
|
-
*
|
|
25
|
+
* changes: object|null, policyChanged: boolean|null, policyOneSided: boolean,
|
|
26
|
+
* provenanceChanged: boolean|null, providerChanged: boolean,
|
|
27
|
+
* codeDrift: boolean, notes: string[], comparison?: object}[],
|
|
28
|
+
* summary?: object}, coverage: object}} input
|
|
27
29
|
* @returns {string}
|
|
28
30
|
*/
|
|
29
31
|
export function formatEvolutionReport({ result, coverage }) {
|
|
@@ -62,6 +64,17 @@ export function formatEvolutionReport({ result, coverage }) {
|
|
|
62
64
|
for (const note of transition.notes) {
|
|
63
65
|
sections.push(` ${note}`);
|
|
64
66
|
}
|
|
67
|
+
// The per-transition comparison report — the eight questions answered on
|
|
68
|
+
// this one revision pair (design §6), from the envelope fields the command
|
|
69
|
+
// already computed. An incomparable axis is printed with its reason, never
|
|
70
|
+
// folded into silence.
|
|
71
|
+
if (transition.comparison) {
|
|
72
|
+
sections.push(``);
|
|
73
|
+
sections.push(
|
|
74
|
+
` comparison for ${sanitize(transition.from.slice(0, 12))} → ${sanitize(transition.to.slice(0, 12))}`,
|
|
75
|
+
);
|
|
76
|
+
sections.push(...formatComparisonSection(transition.comparison));
|
|
77
|
+
}
|
|
65
78
|
}
|
|
66
79
|
|
|
67
80
|
if (changed === 0) {
|
|
@@ -79,5 +92,221 @@ export function formatEvolutionReport({ result, coverage }) {
|
|
|
79
92
|
);
|
|
80
93
|
}
|
|
81
94
|
|
|
95
|
+
// The range-wide summary — the same eight questions answered over the whole
|
|
96
|
+
// record, so a reader sees both the per-pair detail and the verdict carried
|
|
97
|
+
// by all of them together (design §8). An axis that any pair could not
|
|
98
|
+
// compare is reported as n/a with the reason, never as a fabricated clean
|
|
99
|
+
// aggregate.
|
|
100
|
+
if (result.summary) {
|
|
101
|
+
sections.push(``);
|
|
102
|
+
sections.push(`summary`);
|
|
103
|
+
sections.push(...formatSummarySection(result.summary));
|
|
104
|
+
}
|
|
105
|
+
|
|
82
106
|
return sections.join("\n");
|
|
83
107
|
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Renders an incomparable axis marker as `n/a — <reason>` (design §6). An
|
|
111
|
+
* axis that could not be answered is never folded into a clean "none".
|
|
112
|
+
*
|
|
113
|
+
* @param {{available: false, reason?: string}|undefined} axis
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
function naWithReason(axis) {
|
|
117
|
+
return `n/a — ${axis?.reason ?? "not comparable"}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Renders one verdict value: a verdict string as-is, an incomparable marker
|
|
122
|
+
* as `n/a — <reason>`, and an absent value as `n/a` with a fallback reason.
|
|
123
|
+
*
|
|
124
|
+
* @param {string|{available: false, reason?: string}|undefined} value
|
|
125
|
+
* @param {string} [fallbackReason]
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
function renderVerdict(value, fallbackReason) {
|
|
129
|
+
if (value === undefined || value === null) return `n/a — ${fallbackReason ?? "unknown"}`;
|
|
130
|
+
if (typeof value === "object" && value.available === false) {
|
|
131
|
+
return `n/a — ${value.reason ?? "not comparable"}`;
|
|
132
|
+
}
|
|
133
|
+
return String(value);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Names an edge for the report: `source→target:type` for the object shape the
|
|
138
|
+
* diff produces, plain text otherwise.
|
|
139
|
+
*
|
|
140
|
+
* @param {object|string} edge
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
function edgeName(edge) {
|
|
144
|
+
return edge && typeof edge === "object"
|
|
145
|
+
? `${edge.source}→${edge.target}${edge.type ? `:${edge.type}` : ""}`
|
|
146
|
+
: String(edge);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The report section for one transition's comparison (design §6) — the eight
|
|
151
|
+
* questions answered for that single revision pair. Reads the envelope fields
|
|
152
|
+
* the command computed; incomparable axes print `n/a — <reason>`, never a
|
|
153
|
+
* fabricated clean answer.
|
|
154
|
+
*
|
|
155
|
+
* @param {object} comparison One `result.transitions[i].comparison`.
|
|
156
|
+
* @returns {string[]} Report lines.
|
|
157
|
+
*/
|
|
158
|
+
function formatComparisonSection(comparison) {
|
|
159
|
+
const out = [];
|
|
160
|
+
const observed = comparison.observed ?? {};
|
|
161
|
+
const projects = observed.projects ?? {};
|
|
162
|
+
const edges = observed.edges ?? {};
|
|
163
|
+
const projectName = (p) => (typeof p === "string" ? p : (p?.name ?? JSON.stringify(p)));
|
|
164
|
+
const listLine = (label, items) =>
|
|
165
|
+
items.length === 0 ? [] : [` ${label}: ${items.join(", ")}`];
|
|
166
|
+
|
|
167
|
+
out.push(` disposition: ${comparison.disposition ?? "unknown"}`);
|
|
168
|
+
const cls = comparison.classifications ?? [];
|
|
169
|
+
out.push(` classifications: ${cls.length === 0 ? "none" : cls.join(", ")}`);
|
|
170
|
+
out.push(` architecture changed: ${observed.architectureChanged ? "yes" : "no"}`);
|
|
171
|
+
out.push(...listLine("projects added", (projects.added ?? []).map(projectName)));
|
|
172
|
+
out.push(...listLine("projects removed", (projects.removed ?? []).map(projectName)));
|
|
173
|
+
out.push(...listLine("projects changed", (projects.changed ?? []).map(projectName)));
|
|
174
|
+
out.push(...listLine("edges added", (edges.added ?? []).map(edgeName)));
|
|
175
|
+
out.push(...listLine("edges removed", (edges.removed ?? []).map(edgeName)));
|
|
176
|
+
if (observed.policyChanged === true || observed.policyChanged === false) {
|
|
177
|
+
out.push(` policy changed: ${observed.policyChanged ? "yes" : "no"}`);
|
|
178
|
+
}
|
|
179
|
+
if (observed.policyOneSided === true) out.push(` policy changed: one-sided`);
|
|
180
|
+
if (observed.providerChanged === true || observed.providerChanged === false) {
|
|
181
|
+
out.push(` provider changed: ${observed.providerChanged ? "yes" : "no"}`);
|
|
182
|
+
}
|
|
183
|
+
if (observed.provenanceChanged === true) out.push(` provenance changed: yes`);
|
|
184
|
+
|
|
185
|
+
const findings = comparison.findings;
|
|
186
|
+
if (findings?.available === false) out.push(` drift findings: ${naWithReason(findings)}`);
|
|
187
|
+
else if (findings) {
|
|
188
|
+
out.push(
|
|
189
|
+
` drift findings: introduced ${findings.introduced.length}, resolved ${findings.resolved.length}` +
|
|
190
|
+
(findings.unknown?.length ? `, unknown ${findings.unknown.length}` : ""),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const debt = comparison.debt;
|
|
195
|
+
if (debt?.available === false) out.push(` debt: ${naWithReason(debt)}`);
|
|
196
|
+
else if (debt)
|
|
197
|
+
out.push(` debt: introduced ${debt.introduced.length}, resolved ${debt.resolved.length}`);
|
|
198
|
+
|
|
199
|
+
const fitness = comparison.fitness;
|
|
200
|
+
if (fitness?.available === false) out.push(` fitness: ${naWithReason(fitness)}`);
|
|
201
|
+
else if (fitness?.verdictDeltas) {
|
|
202
|
+
out.push(` fitness:`);
|
|
203
|
+
for (const delta of fitness.verdictDeltas) {
|
|
204
|
+
out.push(` ${delta.id}: ${renderVerdict(delta.base)} → ${renderVerdict(delta.head)}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const cov = comparison.coverage;
|
|
209
|
+
if (cov) {
|
|
210
|
+
out.push(
|
|
211
|
+
` coverage: base ${cov.base?.projects ?? 0} project${cov.base?.projects === 1 ? "" : "s"} / ` +
|
|
212
|
+
`${cov.base?.analyzedFiles ?? 0} files / ${cov.base?.imports ?? 0} imports; ` +
|
|
213
|
+
`head ${cov.head?.projects ?? 0} / ${cov.head?.analyzedFiles ?? 0} / ${cov.head?.imports ?? 0}`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const affected = comparison.affected;
|
|
218
|
+
if (affected) {
|
|
219
|
+
if (affected.projects?.length) out.push(` affected projects: ${affected.projects.join(", ")}`);
|
|
220
|
+
if (affected.boundaries?.length)
|
|
221
|
+
out.push(` affected boundaries: ${affected.boundaries.join(", ")}`);
|
|
222
|
+
if (affected.constraints?.length)
|
|
223
|
+
out.push(` affected constraints: ${affected.constraints.join(", ")}`);
|
|
224
|
+
if (affected.decisions?.length)
|
|
225
|
+
out.push(` affected decisions: ${affected.decisions.join(", ")}`);
|
|
226
|
+
if (
|
|
227
|
+
!["projects", "boundaries", "constraints", "decisions", "lineage"].some(
|
|
228
|
+
(key) => affected[key]?.length,
|
|
229
|
+
)
|
|
230
|
+
) {
|
|
231
|
+
out.push(` affected: none`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (const note of comparison.notes ?? []) out.push(` note: ${note}`);
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The report section for the whole-range summary (design §8) — the same eight
|
|
241
|
+
* questions answered over every transition together. Any axis with an
|
|
242
|
+
* incomparable transition prints `n/a — <reason>` naming that transition.
|
|
243
|
+
*
|
|
244
|
+
* @param {object} summary `result.summary`.
|
|
245
|
+
* @returns {string[]} Report lines.
|
|
246
|
+
*/
|
|
247
|
+
function formatSummarySection(summary) {
|
|
248
|
+
const out = [];
|
|
249
|
+
out.push(` transitions: ${summary.transitions}`);
|
|
250
|
+
out.push(` disposition: ${summary.disposition ?? "unknown"}`);
|
|
251
|
+
const cls = summary.classifications ?? [];
|
|
252
|
+
out.push(` classifications: ${cls.length === 0 ? "none" : cls.join(", ")}`);
|
|
253
|
+
|
|
254
|
+
const observed = summary.observed ?? {};
|
|
255
|
+
const projects = observed.projects ?? {};
|
|
256
|
+
const edges = observed.edges ?? {};
|
|
257
|
+
const listLine = (label, items) =>
|
|
258
|
+
items.length === 0 ? [] : [` ${label}: ${items.join(", ")}`];
|
|
259
|
+
out.push(
|
|
260
|
+
` architecture changed in ${observed.architectureChanged ?? 0} transition${
|
|
261
|
+
observed.architectureChanged === 1 ? "" : "s"
|
|
262
|
+
}`,
|
|
263
|
+
);
|
|
264
|
+
out.push(...listLine("projects added", projects.added ?? []));
|
|
265
|
+
out.push(...listLine("projects removed", projects.removed ?? []));
|
|
266
|
+
out.push(...listLine("projects changed", projects.changed ?? []));
|
|
267
|
+
out.push(...listLine("edges added", (edges.added ?? []).map(edgeName)));
|
|
268
|
+
out.push(...listLine("edges removed", (edges.removed ?? []).map(edgeName)));
|
|
269
|
+
const policyChanged = observed.policyChanged;
|
|
270
|
+
if (policyChanged !== undefined && policyChanged !== null) {
|
|
271
|
+
out.push(
|
|
272
|
+
typeof policyChanged === "object" && policyChanged.available === false
|
|
273
|
+
? ` policy: ${naWithReason(policyChanged)}`
|
|
274
|
+
: ` policy changed: ${policyChanged}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const findings = summary.findings;
|
|
278
|
+
if (findings?.available === false) out.push(` drift findings: ${naWithReason(findings)}`);
|
|
279
|
+
else if (findings) {
|
|
280
|
+
out.push(
|
|
281
|
+
` drift findings: introduced ${findings.introduced.length}, resolved ${findings.resolved.length}`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const debt = summary.debt;
|
|
286
|
+
if (debt?.available === false) out.push(` debt: ${naWithReason(debt)}`);
|
|
287
|
+
else if (debt)
|
|
288
|
+
out.push(` debt: introduced ${debt.introduced.length}, resolved ${debt.resolved.length}`);
|
|
289
|
+
|
|
290
|
+
const fitness = summary.fitness;
|
|
291
|
+
if (fitness?.available === false) out.push(` fitness: ${naWithReason(fitness)}`);
|
|
292
|
+
else if (fitness?.verdictDeltas) {
|
|
293
|
+
out.push(` fitness:`);
|
|
294
|
+
for (const delta of fitness.verdictDeltas) {
|
|
295
|
+
out.push(` ${delta.id}: ${renderVerdict(delta.base)} → ${renderVerdict(delta.head)}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const affected = summary.affected;
|
|
300
|
+
if (affected) {
|
|
301
|
+
if (affected.projects?.length) out.push(` affected projects: ${affected.projects.join(", ")}`);
|
|
302
|
+
if (affected.boundaries?.length)
|
|
303
|
+
out.push(` affected boundaries: ${affected.boundaries.join(", ")}`);
|
|
304
|
+
if (affected.constraints?.length)
|
|
305
|
+
out.push(` affected constraints: ${affected.constraints.join(", ")}`);
|
|
306
|
+
if (affected.decisions?.length)
|
|
307
|
+
out.push(` affected decisions: ${affected.decisions.join(", ")}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
for (const note of summary.notes ?? []) out.push(` note: ${note}`);
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* shape `./text.mjs` uses for violations. Everything after it is indented,
|
|
8
8
|
* so the position line stands alone.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
10
|
+
* Eight things are printed, each with a reader in mind:
|
|
11
11
|
*
|
|
12
12
|
* - the import specifier and its kind (what was written)
|
|
13
13
|
* - the source project and its tags (who wrote it)
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
* and a remediation line that is the author's declared guidance verbatim —
|
|
20
20
|
* or, when none is declared, an explicit pointer at the constraint row and
|
|
21
21
|
* its `decisionRef`, never a fix this renderer composed
|
|
22
|
+
* - for a matched row carrying a `decisionRef`: the governing decision's
|
|
23
|
+
* status and authority, its context/rationale prose, and its supersession
|
|
24
|
+
* lineage — or, when the ref or the registry cannot be resolved, a loud
|
|
25
|
+
* UNRESOLVED line naming the reason (an empty result here would read as
|
|
26
|
+
* "no decision behind this row", which is a different claim)
|
|
22
27
|
* - coverage information (whether this explanation is complete)
|
|
23
28
|
*
|
|
24
29
|
* This module decides nothing. A formatter that filtered would be a rule
|
|
@@ -84,6 +89,108 @@ function formatMatchedConstraint(constraint) {
|
|
|
84
89
|
return formatConstraint(constraint);
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The status line for one governing decision: `id (status — authority)`.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} entry A resolved `"adr"` chain entry.
|
|
96
|
+
* @returns {string}
|
|
97
|
+
*/
|
|
98
|
+
function decisionStatusLine(entry) {
|
|
99
|
+
const authority = entry.authority ? " — has authority" : " — no authority";
|
|
100
|
+
return `${DETAIL}decision ${entry.record.id} (${entry.record.status}${authority})`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The first non-empty line of a prose field, trimmed to a display width.
|
|
105
|
+
* The excerpt is a pointer, never a paraphrase — a reader who needs the whole
|
|
106
|
+
* context opens the ADR.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} text
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
function proseExcerpt(text) {
|
|
112
|
+
const first = text.split("\n").find((line) => line.trim() !== "") ?? "";
|
|
113
|
+
const trimmed = first.trim();
|
|
114
|
+
return trimmed.length > 120 ? `${trimmed.slice(0, 117)}…` : trimmed;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One decision-chain entry as report lines. The chain never renders blank:
|
|
119
|
+
* a ref the registry cannot resolve, and a ref that cannot be read at all,
|
|
120
|
+
* both render as UNRESOLVED naming the reason (AGENTS.md: "an empty result
|
|
121
|
+
* is a claim, not a shrug").
|
|
122
|
+
*
|
|
123
|
+
* @param {object} entry One `explanation.decisions` entry.
|
|
124
|
+
* @returns {string[]}
|
|
125
|
+
*/
|
|
126
|
+
function formatDecisionEntry(entry) {
|
|
127
|
+
if (entry.resolution === "unknown") {
|
|
128
|
+
return [`${DETAIL}decisionRef ${entry.ref} (UNRESOLVED — ${entry.reason})`];
|
|
129
|
+
}
|
|
130
|
+
if (entry.resolution === "fitness") {
|
|
131
|
+
return [`${DETAIL}decisionRef ${entry.ref} — a fitness rule this law declares`];
|
|
132
|
+
}
|
|
133
|
+
const lines = [decisionStatusLine(entry)];
|
|
134
|
+
if (typeof entry.record.context === "string") {
|
|
135
|
+
lines.push(`${DETAIL}context ${proseExcerpt(entry.record.context)}`);
|
|
136
|
+
}
|
|
137
|
+
if (typeof entry.record.rationale === "string") {
|
|
138
|
+
lines.push(`${DETAIL}rationale ${proseExcerpt(entry.record.rationale)}`);
|
|
139
|
+
}
|
|
140
|
+
const supersedes = Array.isArray(entry.record.supersedes) ? entry.record.supersedes : [];
|
|
141
|
+
const supersededBy = Array.isArray(entry.record.supersededBy) ? entry.record.supersededBy : [];
|
|
142
|
+
if (supersedes.length > 0 || supersededBy.length > 0) {
|
|
143
|
+
const parts = [];
|
|
144
|
+
if (supersedes.length > 0) parts.push(`supersedes: ${supersedes.join(", ")}`);
|
|
145
|
+
if (supersededBy.length > 0) parts.push(`superseded by: ${supersededBy.join(", ")}`);
|
|
146
|
+
lines.push(`${DETAIL}lineage ${parts.join(" · ")}`);
|
|
147
|
+
} else {
|
|
148
|
+
lines.push(`${DETAIL}lineage none — no supersession chain is recorded for this decision`);
|
|
149
|
+
}
|
|
150
|
+
for (const gap of entry.lineage?.unresolved ?? []) {
|
|
151
|
+
lines.push(`${DETAIL}lineage UNRESOLVED — ${gap.reason}`);
|
|
152
|
+
}
|
|
153
|
+
return lines;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The lineage-comparison disclosure — `explain`'s optional base-registry
|
|
158
|
+
* seam, rendered only when the caller supplied a base registry and the
|
|
159
|
+
* resolved-site explanation carries a `decisionChange` field. The fact is
|
|
160
|
+
* stated either way — superseded or not — because "the lineage was compared
|
|
161
|
+
* and did not move" must read differently from "no comparison happened"
|
|
162
|
+
* (`../../../../AGENTS.md`: an empty result is a claim, never a shrug);
|
|
163
|
+
* every disclosure note rides appended lines. Appended only: an explanation
|
|
164
|
+
* that has no `decisionChange` field renders exactly as it did before.
|
|
165
|
+
*
|
|
166
|
+
* @param {{superseded: boolean, comparable: boolean, notes: string[]}} change
|
|
167
|
+
* A `detectDecisionChange` result. `superseded` is meaningful only when
|
|
168
|
+
* `comparable` is `true`; a non-comparable comparison (one-sided or
|
|
169
|
+
* unreadable) renders "could not compare", never "did not move", because
|
|
170
|
+
* the latter would assert a fact the comparison could not hold.
|
|
171
|
+
* @returns {string[]}
|
|
172
|
+
*/
|
|
173
|
+
function formatDecisionChange(change) {
|
|
174
|
+
const lines = [];
|
|
175
|
+
if (change.comparable === false) {
|
|
176
|
+
lines.push(
|
|
177
|
+
`${DETAIL}decisionChange could not compare — the decision lineage was not established between the compared registry states`,
|
|
178
|
+
);
|
|
179
|
+
} else if (change.superseded) {
|
|
180
|
+
lines.push(
|
|
181
|
+
`${DETAIL}decisionChange superseded — the decision lineage moved between the compared registry states`,
|
|
182
|
+
);
|
|
183
|
+
} else {
|
|
184
|
+
lines.push(
|
|
185
|
+
`${DETAIL}decisionChange none — the decision lineage did not move between the compared registry states`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
for (const note of change.notes) {
|
|
189
|
+
lines.push(`${DETAIL}decisionChange ${note}`);
|
|
190
|
+
}
|
|
191
|
+
return lines;
|
|
192
|
+
}
|
|
193
|
+
|
|
87
194
|
/**
|
|
88
195
|
* The whole explain report.
|
|
89
196
|
*
|
|
@@ -165,6 +272,20 @@ export function formatExplainReport({ explanation, coverage }) {
|
|
|
165
272
|
} else {
|
|
166
273
|
sections.push(`${DETAIL}verdict allowed — no constraint was violated`);
|
|
167
274
|
}
|
|
275
|
+
// The lineage-comparison disclosure — present only when the explanation
|
|
276
|
+
// carries it (the caller supplied a base registry); otherwise nothing
|
|
277
|
+
// here renders, and the report is byte-for-byte what it was.
|
|
278
|
+
if (explanation.decisionChange !== undefined) {
|
|
279
|
+
sections.push(...formatDecisionChange(explanation.decisionChange));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// The "why does this constraint exist" chain — one block per governing
|
|
283
|
+
// decision the matched rows name. Additive: an explanation whose rows
|
|
284
|
+
// carry no `decisionRef` has no `decisions` list, and renders exactly as
|
|
285
|
+
// it did before this section existed.
|
|
286
|
+
for (const entry of explanation.decisions ?? []) {
|
|
287
|
+
sections.push(...formatDecisionEntry(entry));
|
|
288
|
+
}
|
|
168
289
|
}
|
|
169
290
|
|
|
170
291
|
// Coverage — same shape as every other command's footer.
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* becomes prose, so the two commands cannot disagree about it.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { formatChanges, transitionKind } from "./snapshot-text.mjs";
|
|
15
|
+
import { formatChanges, formatClassifications, transitionKind } from "./snapshot-text.mjs";
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* The whole history report.
|
|
@@ -20,8 +20,9 @@ import { formatChanges, transitionKind } from "./snapshot-text.mjs";
|
|
|
20
20
|
* @param {{evolution: {dir: string, captured: object|null,
|
|
21
21
|
* snapshots: {name: string, id: string}[],
|
|
22
22
|
* transitions: {from: string, to: string, architectureChanged: boolean,
|
|
23
|
-
* changes: object|null, policyChanged: boolean|null,
|
|
24
|
-
*
|
|
23
|
+
* changes: object|null, policyChanged: boolean|null, policyOneSided: boolean,
|
|
24
|
+
* provenanceChanged: boolean|null, providerChanged: boolean,
|
|
25
|
+
* codeDrift: boolean, notes: string[], classifications?: string[]}[]}, coverage: object}} input
|
|
25
26
|
* @returns {string}
|
|
26
27
|
*/
|
|
27
28
|
export function formatHistoryReport({ evolution, coverage }) {
|
|
@@ -61,6 +62,11 @@ export function formatHistoryReport({ evolution, coverage }) {
|
|
|
61
62
|
if (transition.architectureChanged) changed += 1;
|
|
62
63
|
const kind = transitionKind(transition);
|
|
63
64
|
sections.push(`~ ${transition.from} → ${transition.to} (${kind})`);
|
|
65
|
+
// The canonical classes, appended as their own line so the kind label
|
|
66
|
+
// (a display shorthand) and the classification (a fact) both print.
|
|
67
|
+
for (const line of formatClassifications(transition.classifications)) {
|
|
68
|
+
sections.push(` ${line}`);
|
|
69
|
+
}
|
|
64
70
|
if (transition.changes) {
|
|
65
71
|
for (const line of formatChanges(transition.changes)) sections.push(` ${line}`);
|
|
66
72
|
}
|
|
@@ -76,6 +76,68 @@ export function formatPlanContextReport({ project, coverage, unresolvedDecisionR
|
|
|
76
76
|
);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// Decisions / ADRs in scope (resolved decision lineage).
|
|
80
|
+
if (plan.decisions && plan.decisions.length > 0) {
|
|
81
|
+
sections.push(
|
|
82
|
+
`Decisions (${plan.decisions.length} resolved reference${plan.decisions.length === 1 ? "" : "s"})`,
|
|
83
|
+
);
|
|
84
|
+
for (const d of plan.decisions) {
|
|
85
|
+
const title = d.record ? `${d.record.id}: ${d.record.title}` : d.decisionRef;
|
|
86
|
+
const status = d.record ? d.record.status : "unresolved";
|
|
87
|
+
sections.push(`${DETAIL}${title} (${status})`);
|
|
88
|
+
if (d.record?.context) {
|
|
89
|
+
sections.push(`${DETAIL} context: ${d.record.context}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Fitness.
|
|
95
|
+
if (plan.fitness) {
|
|
96
|
+
sections.push("Fitness");
|
|
97
|
+
if (!plan.fitness.verified) {
|
|
98
|
+
sections.push(`${DETAIL}no-verdict — ${plan.fitness.error ?? "could not be evaluated"}`);
|
|
99
|
+
} else {
|
|
100
|
+
sections.push(`${DETAIL}overall verdict: ${plan.fitness.overall.verdict}`);
|
|
101
|
+
if (plan.fitness.decisions.length > 0) {
|
|
102
|
+
for (const d of plan.fitness.decisions) {
|
|
103
|
+
sections.push(
|
|
104
|
+
`${DETAIL}${d.name ?? d.id}: ${d.verdict}${d.reason ? ` — ${d.reason}` : ""}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
sections.push("Fitness (no fitness functions declared in the boundary policy)");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Waivers.
|
|
114
|
+
sections.push("Waivers");
|
|
115
|
+
if (!plan.waivers.declared) {
|
|
116
|
+
sections.push(`${DETAIL}no suppressions declared — every boundary is enforced`);
|
|
117
|
+
} else {
|
|
118
|
+
const waiverCount = plan.waivers.waivers.length;
|
|
119
|
+
const permCount = plan.waivers.permanentSuppressions.length;
|
|
120
|
+
sections.push(
|
|
121
|
+
`${DETAIL}${waiverCount} waiver${waiverCount === 1 ? "" : "s"}, ` +
|
|
122
|
+
`${permCount} permanent suppression${permCount === 1 ? "" : "s"}, ` +
|
|
123
|
+
`${plan.waivers.covered} covering violations, ` +
|
|
124
|
+
`${plan.waivers.expired} expired, ` +
|
|
125
|
+
`${plan.waivers.stale} stale`,
|
|
126
|
+
);
|
|
127
|
+
if (waiverCount > 0) {
|
|
128
|
+
for (const w of plan.waivers.waivers) {
|
|
129
|
+
const term = w.expiresAt
|
|
130
|
+
? `expires ${w.expiresAt} (${w.status}, ${w.remainingMs}ms remaining)`
|
|
131
|
+
: "permanent";
|
|
132
|
+
sections.push(
|
|
133
|
+
`${DETAIL}${w.path} ${term} covers ${w.covered} violation${w.covered === 1 ? "" : "s"}`,
|
|
134
|
+
);
|
|
135
|
+
if (w.reason) sections.push(`${DETAIL} reason: ${w.reason}`);
|
|
136
|
+
if (w.origin) sections.push(`${DETAIL} origin: ${w.origin}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
79
141
|
// Affected projects.
|
|
80
142
|
sections.push("Architecture");
|
|
81
143
|
sections.push(
|
|
@@ -135,6 +197,38 @@ export function formatPlanContextReport({ project, coverage, unresolvedDecisionR
|
|
|
135
197
|
sections.push(`${DETAIL}${plan.intent.verdict}: ${verdict} (${plan.intent.rows} rows)`);
|
|
136
198
|
}
|
|
137
199
|
|
|
200
|
+
// Architecture debt.
|
|
201
|
+
if (plan.debt) {
|
|
202
|
+
sections.push("Architecture debt");
|
|
203
|
+
if (!plan.debt.available) {
|
|
204
|
+
sections.push(
|
|
205
|
+
`${DETAIL}not available — ${plan.debt.reason ?? "history directory unavailable"}`,
|
|
206
|
+
);
|
|
207
|
+
} else {
|
|
208
|
+
const entries = plan.debt.entries;
|
|
209
|
+
const open = plan.debt.total.open;
|
|
210
|
+
const resolved = plan.debt.total.resolved;
|
|
211
|
+
sections.push(
|
|
212
|
+
`${DETAIL}${entries.length} entry${entries.length === 1 ? "" : "s"} ` +
|
|
213
|
+
`(${open} open, ${resolved} resolved, ${plan.debt.total.total} total) ` +
|
|
214
|
+
`across ${plan.debt.snapshots} snapshot` +
|
|
215
|
+
`${plan.debt.snapshots === 1 ? "" : "s"}`,
|
|
216
|
+
);
|
|
217
|
+
if (plan.debt.byKind && Object.keys(plan.debt.byKind).length > 0) {
|
|
218
|
+
const kinds = Object.entries(plan.debt.byKind)
|
|
219
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
220
|
+
.map(([kind, count]) => `${kind}: ${count}`);
|
|
221
|
+
sections.push(`${DETAIL}by kind: ${kinds.join(", ")}`);
|
|
222
|
+
}
|
|
223
|
+
if (plan.debt.agings && plan.debt.agings.meanDays !== null) {
|
|
224
|
+
sections.push(
|
|
225
|
+
`${DETAIL}mean age: ${plan.debt.agings.meanDays.toFixed(1)} days` +
|
|
226
|
+
`${plan.debt.agings.maxDays !== null ? `, max ${plan.debt.agings.maxDays.toFixed(1)} days` : ""}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
138
232
|
// Verification commands.
|
|
139
233
|
sections.push("Verify after the change");
|
|
140
234
|
for (const command of plan.verify) {
|
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
* `../commands/provenance-command.mjs` already resolved — no wall-clock time
|
|
10
10
|
* and no `localeCompare` enter here, matching every other report renderer.
|
|
11
11
|
*
|
|
12
|
+
* A fourth section carries the decision lifecycle: every recorded decision's
|
|
13
|
+
* status, authority, committed timeline, lineage, and bindings, attributed
|
|
14
|
+
* with WHO recorded it (the record file's own git history). A decision with
|
|
15
|
+
* no attributable history is named `no origin recorded — cannot attest`,
|
|
16
|
+
* never silently passed; the section renders only when at least one decision
|
|
17
|
+
* exists, the same "no fact, no claim" bargain the row arms state.
|
|
18
|
+
*
|
|
12
19
|
* This module decides nothing. A formatter that filtered would be a rule
|
|
13
20
|
* wearing a formatter's name (`./README.md`).
|
|
14
21
|
*/
|
|
@@ -19,10 +26,17 @@
|
|
|
19
26
|
* rowsTotal: number,
|
|
20
27
|
* unattested: {kind: string, label: string, note: string}[],
|
|
21
28
|
* decisionRefTotal: number,
|
|
22
|
-
* unresolvedDecisionRefs: {kind: string, label: string, decisionRef: string, note: string}[]
|
|
29
|
+
* unresolvedDecisionRefs: {kind: string, label: string, decisionRef: string, note: string}[],
|
|
30
|
+
* decisionLifecycle?: {id: string, status: string, authority: boolean,
|
|
31
|
+
* created: string|null, updated: string|null, supersedes: string[],
|
|
32
|
+
* supersededBy: string[], bindings: string[],
|
|
33
|
+
* attribution: {createdBy: object|null, lastChangedBy: object|null}|null,
|
|
34
|
+
* attested: boolean, note: string|null}[]}} input
|
|
23
35
|
* `decisionRefTotal` is how many governance rows cite a `decisionRef` at
|
|
24
36
|
* all — the resolution section renders only when it is non-zero, the same
|
|
25
37
|
* "no fact, no claim" bargain every optional axis in this tool states.
|
|
38
|
+
* `decisionLifecycle` (optional, default `[]`) is the decision-lifecycle
|
|
39
|
+
* section — it renders only when non-empty.
|
|
26
40
|
* @returns {string}
|
|
27
41
|
*/
|
|
28
42
|
export function formatProvenanceReport({
|
|
@@ -32,6 +46,7 @@ export function formatProvenanceReport({
|
|
|
32
46
|
unattested,
|
|
33
47
|
decisionRefTotal,
|
|
34
48
|
unresolvedDecisionRefs,
|
|
49
|
+
decisionLifecycle = [],
|
|
35
50
|
}) {
|
|
36
51
|
const attestedCount = rowsTotal - unattested.length;
|
|
37
52
|
const text = [];
|
|
@@ -74,5 +89,56 @@ export function formatProvenanceReport({
|
|
|
74
89
|
);
|
|
75
90
|
}
|
|
76
91
|
}
|
|
92
|
+
// PR E — the decision lifecycle. "No fact, no claim", like the resolution
|
|
93
|
+
// arm: the section renders only when the registry holds at least one
|
|
94
|
+
// decision, and a decision with no attributable history is listed under a
|
|
95
|
+
// cannot-attest heading, never silently passed.
|
|
96
|
+
const decidedCount = decisionLifecycle.length;
|
|
97
|
+
if (decidedCount > 0) {
|
|
98
|
+
const attributedCount = decisionLifecycle.filter((d) => d.attested).length;
|
|
99
|
+
const unattributedCount = decidedCount - attributedCount;
|
|
100
|
+
text.push(
|
|
101
|
+
`decisions ${decidedCount} recorded — ${attributedCount} attributed, ` +
|
|
102
|
+
`${unattributedCount} without attribution`,
|
|
103
|
+
);
|
|
104
|
+
for (const decision of decisionLifecycle) {
|
|
105
|
+
if (!decision.attested) continue;
|
|
106
|
+
const createdBy = decision.attribution?.createdBy ?? null;
|
|
107
|
+
const lastChangedBy = decision.attribution?.lastChangedBy ?? null;
|
|
108
|
+
const facts = [
|
|
109
|
+
createdBy === null
|
|
110
|
+
? "created — no origin recorded — cannot attest"
|
|
111
|
+
: `created by ${createdBy.by} on ${createdBy.on}`,
|
|
112
|
+
lastChangedBy === null
|
|
113
|
+
? "changed — no origin recorded — cannot attest"
|
|
114
|
+
: `changed by ${lastChangedBy.by} on ${lastChangedBy.on}`,
|
|
115
|
+
];
|
|
116
|
+
if (decision.supersedes.length > 0) {
|
|
117
|
+
facts.push(`supersedes ${decision.supersedes.join(", ")}`);
|
|
118
|
+
}
|
|
119
|
+
if (decision.supersededBy.length > 0) {
|
|
120
|
+
facts.push(`superseded by ${decision.supersededBy.join(", ")}`);
|
|
121
|
+
}
|
|
122
|
+
if (decision.bindings.length > 0) {
|
|
123
|
+
facts.push(`binds ${decision.bindings.join(", ")}`);
|
|
124
|
+
}
|
|
125
|
+
if (decision.created !== null || decision.updated !== null) {
|
|
126
|
+
facts.push(`timeline ${decision.created ?? "?"} → ${decision.updated ?? "?"}`);
|
|
127
|
+
}
|
|
128
|
+
text.push(` ${decision.status.padEnd(11)} ${decision.id} ${facts.join("; ")}`);
|
|
129
|
+
}
|
|
130
|
+
if (unattributedCount > 0) {
|
|
131
|
+
text.push("unattributed lifecycle (no origin recorded — cannot attest):");
|
|
132
|
+
for (const decision of decisionLifecycle) {
|
|
133
|
+
if (!decision.attested) text.push(` ${decision.id}`);
|
|
134
|
+
}
|
|
135
|
+
text.push(`${unattributedCount} of them carry no recorded origin behind their lifecycle`);
|
|
136
|
+
} else {
|
|
137
|
+
text.push(
|
|
138
|
+
`✔ every decision's lifecycle is attributed — each change names who ` +
|
|
139
|
+
`recorded it and with what tool`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
77
143
|
return text.join("\n");
|
|
78
144
|
}
|