@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.
@@ -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, providerChanged: boolean,
26
- * codeDrift: boolean, notes: string[]}[]}, coverage: object}} input
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, providerChanged: boolean,
24
- * codeDrift: boolean, notes: string[]}[]}, coverage: object}} input
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}, coverage: object}} input
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
  }