@ecoma-io/archkeep 0.17.0 → 0.18.1
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/cli.mjs +161 -22
- package/package.json +2 -2
- package/src/architecture-intent/judge.mjs +19 -6
- 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/delta-classify.mjs +257 -0
- package/src/commands/delta.mjs +269 -8
- package/src/commands/diff.mjs +15 -7
- package/src/commands/evolution.mjs +758 -5
- package/src/commands/explain.mjs +82 -1
- package/src/commands/history.mjs +81 -5
- package/src/commands/plan-context-command.mjs +163 -2
- package/src/commands/rules.mjs +3 -1
- package/src/commands/trajectory.mjs +89 -3
- package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
- package/src/governance/debt-ledger.mjs +261 -19
- 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/report/change-text.mjs +21 -3
- package/src/report/debt-text.mjs +42 -6
- package/src/report/delta-text.mjs +36 -1
- package/src/report/evolution-text.mjs +231 -2
- package/src/report/explain-text.mjs +45 -0
- package/src/report/history-text.mjs +9 -3
- package/src/report/plan-context-text.mjs +94 -0
- 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
|
+
}
|
|
@@ -152,6 +152,45 @@ function formatDecisionEntry(entry) {
|
|
|
152
152
|
}
|
|
153
153
|
return lines;
|
|
154
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
|
+
|
|
155
194
|
/**
|
|
156
195
|
* The whole explain report.
|
|
157
196
|
*
|
|
@@ -233,6 +272,12 @@ export function formatExplainReport({ explanation, coverage }) {
|
|
|
233
272
|
} else {
|
|
234
273
|
sections.push(`${DETAIL}verdict allowed — no constraint was violated`);
|
|
235
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
|
+
}
|
|
236
281
|
|
|
237
282
|
// The "why does this constraint exist" chain — one block per governing
|
|
238
283
|
// decision the matched rows name. Additive: an explanation whose rows
|
|
@@ -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) {
|
|
@@ -111,13 +111,47 @@ export function formatChanges(changes) {
|
|
|
111
111
|
* Classifies one transition into the short "kind" a reader skims for.
|
|
112
112
|
*
|
|
113
113
|
* @param {{architectureChanged: boolean, codeDrift: boolean, policyChanged: boolean|null,
|
|
114
|
+
* policyOneSided: boolean, provenanceChanged: boolean|null,
|
|
114
115
|
* providerChanged: boolean}} transition
|
|
115
|
-
* @returns {string}
|
|
116
116
|
*/
|
|
117
117
|
export function transitionKind(transition) {
|
|
118
118
|
if (transition.architectureChanged) return "architecture";
|
|
119
119
|
if (transition.providerChanged) return "provider";
|
|
120
120
|
if (transition.policyChanged === true) return "policy";
|
|
121
121
|
if (transition.codeDrift) return "code drift";
|
|
122
|
+
// A pair whose policy could not be compared — one side records the law
|
|
123
|
+
// (`policyOneSided`) or neither does while the commit advanced
|
|
124
|
+
// (F-HIST-1) — is never rendered "unchanged": that label claims every
|
|
125
|
+
// comparable signal was compared and equal. The record carries these as
|
|
126
|
+
// fields, never parsed out of its notes.
|
|
127
|
+
if (
|
|
128
|
+
transition.policyChanged === null &&
|
|
129
|
+
(transition.policyOneSided || transition.provenanceChanged === true)
|
|
130
|
+
) {
|
|
131
|
+
return "not comparable";
|
|
132
|
+
}
|
|
122
133
|
return "unchanged";
|
|
123
134
|
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* One transition's canonical evolution classes as a renderable line. Shared
|
|
138
|
+
* by every reporter that renders a transition record (`history` today;
|
|
139
|
+
* `evolution` will render the same record), so the JSON field and its prose
|
|
140
|
+
* cannot disagree about what a classification is — one spelling, one home,
|
|
141
|
+
* beside the sibling `transitionKind` label.
|
|
142
|
+
*
|
|
143
|
+
* The line appears only when the record carries classifications: an
|
|
144
|
+
* unclassified transition is labelled by its kind, and the empty-classification
|
|
145
|
+
* statement — where it applies — is a note on the record, never a second line
|
|
146
|
+
* this helper has to invent.
|
|
147
|
+
*
|
|
148
|
+
* @param {string[]|undefined} classifications The record's `classifications`
|
|
149
|
+
* array; `undefined` when the caller's record predates the field.
|
|
150
|
+
* @returns {string[]} The line, or `[]` when there is nothing to state.
|
|
151
|
+
*/
|
|
152
|
+
export function formatClassifications(classifications) {
|
|
153
|
+
if (!Array.isArray(classifications) || classifications.length === 0) {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
return [`classifications: ${classifications.map((c) => sanitize(c)).join(", ")}`];
|
|
157
|
+
}
|
|
@@ -96,7 +96,8 @@ function formatAxis(axis, withChanged) {
|
|
|
96
96
|
* transitions: {count: number, architecture: number, policy: number,
|
|
97
97
|
* provider: number, codeDrift: number, incomparable: number, unchanged: number},
|
|
98
98
|
* disclosures: {policyOneSided: number, provenanceOneSided: number, crossRepo: number},
|
|
99
|
-
* projects: object, edges: object
|
|
99
|
+
* projects: object, edges: object,
|
|
100
|
+
* trends: object|null}, coverage: object}} input
|
|
100
101
|
* @returns {string}
|
|
101
102
|
*/
|
|
102
103
|
export function formatTrajectoryReport({ trajectory, coverage }) {
|
|
@@ -135,6 +136,34 @@ export function formatTrajectoryReport({ trajectory, coverage }) {
|
|
|
135
136
|
`dirty captures ${observations.dirtyProvenance} · with provenance ${observations.withProvenance}`,
|
|
136
137
|
);
|
|
137
138
|
|
|
139
|
+
// The trend facts, from the SAME comparable transitions the signals line
|
|
140
|
+
// counts. `null` prints as n/a — an insufficient or fully-incomparable
|
|
141
|
+
// history never reads as zero change — and the basis line names exactly
|
|
142
|
+
// what the counts are a claim about.
|
|
143
|
+
if (trajectory.trends === null) {
|
|
144
|
+
sections.push(
|
|
145
|
+
trajectory.available
|
|
146
|
+
? "trends n/a — no comparable transition classifications"
|
|
147
|
+
: "trends n/a",
|
|
148
|
+
);
|
|
149
|
+
} else {
|
|
150
|
+
const byClass = trajectory.trends.byClass;
|
|
151
|
+
sections.push(
|
|
152
|
+
`trends CHANGE ${byClass.CHANGE} · DRIFT ${byClass.DRIFT} · VIOLATION ${byClass.VIOLATION} · ` +
|
|
153
|
+
`REPAIR ${byClass.REPAIR} · DECISION_CHANGE ${byClass.DECISION_CHANGE} · ` +
|
|
154
|
+
`violations introduced ${trajectory.trends.violationsIntroduced} · resolved ` +
|
|
155
|
+
`${trajectory.trends.violationsResolved}`,
|
|
156
|
+
);
|
|
157
|
+
sections.push(
|
|
158
|
+
`trends basis ${trajectory.trends.comparableTransitions} comparable transition${
|
|
159
|
+
trajectory.trends.comparableTransitions === 1 ? "" : "s"
|
|
160
|
+
} (${trajectory.trends.basis})`,
|
|
161
|
+
);
|
|
162
|
+
if (typeof trajectory.trends.note === "string" && trajectory.trends.note !== "") {
|
|
163
|
+
sections.push(sanitize(trajectory.trends.note));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
138
167
|
for (const note of coverage.notes) {
|
|
139
168
|
sections.push(sanitize(note));
|
|
140
169
|
}
|