@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.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/cli.mjs +258 -20
  3. package/package.json +2 -2
  4. package/src/architecture-intent/judge.mjs +19 -6
  5. package/src/commands/adr.mjs +45 -4
  6. package/src/commands/change-intent.mjs +55 -8
  7. package/src/commands/change.mjs +332 -11
  8. package/src/commands/debt.mjs +26 -5
  9. package/src/commands/decisions.mjs +291 -0
  10. package/src/commands/delta-classify.mjs +257 -0
  11. package/src/commands/delta.mjs +269 -8
  12. package/src/commands/evolution.mjs +758 -5
  13. package/src/commands/explain.mjs +207 -1
  14. package/src/commands/history.mjs +81 -5
  15. package/src/commands/plan-context-command.mjs +163 -2
  16. package/src/commands/provenance-command.mjs +86 -17
  17. package/src/commands/provenance.mjs +60 -0
  18. package/src/commands/report.mjs +48 -1
  19. package/src/commands/trajectory.mjs +89 -3
  20. package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
  21. package/src/governance/adr-registry.mjs +252 -15
  22. package/src/governance/debt-ledger.mjs +261 -19
  23. package/src/governance/decision-fitness.mjs +213 -0
  24. package/src/governance/decision-graph.mjs +483 -0
  25. package/src/governance/decision-lineage.mjs +250 -0
  26. package/src/governance/evolution-event.mjs +470 -0
  27. package/src/governance/evolution-store.mjs +362 -0
  28. package/src/governance/provenance-record.mjs +150 -0
  29. package/src/providers/native/model.mjs +18 -4
  30. package/src/report/adr-text.mjs +109 -4
  31. package/src/report/change-text.mjs +21 -3
  32. package/src/report/debt-text.mjs +42 -6
  33. package/src/report/decisions-text.mjs +164 -0
  34. package/src/report/delta-text.mjs +36 -1
  35. package/src/report/evolution-text.mjs +231 -2
  36. package/src/report/explain-text.mjs +122 -1
  37. package/src/report/history-text.mjs +9 -3
  38. package/src/report/plan-context-text.mjs +94 -0
  39. package/src/report/provenance-text.mjs +67 -1
  40. package/src/report/report-text.mjs +53 -18
  41. package/src/report/snapshot-text.mjs +35 -1
  42. package/src/report/trajectory-text.mjs +30 -1
@@ -155,7 +155,13 @@ function fitnessLines(fitness) {
155
155
  */
156
156
  function decisionLines(decisions) {
157
157
  const lines = [surfaceLine("decisions", decisions)];
158
- if (decisions.registry.count !== null) {
158
+ // "does not resolve" is a claim about the REF — that nothing in the
159
+ // workspace answers to it. When the registry itself could not be read, that
160
+ // claim is not one this run established: it could not look. The two get
161
+ // different sentences, for the same reason `unknown` and `not_applicable`
162
+ // do (`count: null` is how the command says the registry was unreadable).
163
+ const registryUnread = decisions.registry.count === null;
164
+ if (!registryUnread) {
159
165
  lines.push(
160
166
  ` ${decisions.registry.count} record${decisions.registry.count === 1 ? "" : "s"} in ` +
161
167
  `${decisions.registry.dir}/`,
@@ -165,29 +171,58 @@ function decisionLines(decisions) {
165
171
  record.bindings.length > 0
166
172
  ? `binds ${record.bindings.join(", ")}`
167
173
  : "binds nothing — not yet enforceable";
174
+ // The record row is byte-identical to the pre-wave-2 line; the fitness
175
+ // and "stands on" lines below it are additive surface (contract items
176
+ // 3 + F/B), never a rewrite of what a longer-maintained reader expects.
168
177
  lines.push(` ${record.id} (${record.status}) ${binds}`);
178
+ if (record.fitness !== undefined) {
179
+ const fit = record.fitness;
180
+ const reasonString =
181
+ typeof fit.reason === "string"
182
+ ? ` — ${fit.reason}`
183
+ : fit.verified
184
+ ? " — verified true: bound constraints resolve and pass"
185
+ : "";
186
+ lines.push(` fitness: ${fit.level}${reasonString}`);
187
+ }
188
+ if (record.authority) {
189
+ if (record.constraints.length > 0) {
190
+ const refs = record.constraints.map((c) => `${c.kind} ${c.label}`).join(", ");
191
+ lines.push(` stands on: ${refs}`);
192
+ } else {
193
+ lines.push(" stands on: no governed row cites this decision");
194
+ }
195
+ }
169
196
  }
170
197
  }
198
+ // The citations render even when the registry could not be read: that is
199
+ // exactly the case where "does not resolve" must NOT be claimed and the
200
+ // registry-unreadable sentence must. Skipping them here would print the
201
+ // "unknown" verdict with no citation lines to back it — the silent direction.
171
202
  if (decisions.citations.length === 0) {
172
203
  lines.push(" no governed row cites a decisionRef");
173
- return lines;
204
+ } else {
205
+ for (const citation of decisions.citations) {
206
+ const target =
207
+ citation.resolution === "adr" && citation.adr !== null
208
+ ? `${citation.adr.id} (${citation.adr.status})`
209
+ : citation.resolution === "fitness"
210
+ ? `${citation.decisionRef} — a fitness rule this law declares`
211
+ : registryUnread
212
+ ? `${citation.decisionRef} — unresolved: the decision registry could not be read`
213
+ : `${citation.decisionRef} — does not resolve`;
214
+ lines.push(` ${citation.resolution.padEnd(10)}${citation.label} → ${target}`);
215
+ }
174
216
  }
175
- // "does not resolve" is a claim about the REF — that nothing in the
176
- // workspace answers to it. When the registry itself could not be read, that
177
- // claim is not one this run established: it could not look. The two get
178
- // different sentences, for the same reason `unknown` and `not_applicable`
179
- // do (`count: null` is how the command says the registry was unreadable).
180
- const registryUnread = decisions.registry.count === null;
181
- for (const citation of decisions.citations) {
182
- const target =
183
- citation.resolution === "adr" && citation.adr !== null
184
- ? `${citation.adr.id} (${citation.adr.status})`
185
- : citation.resolution === "fitness"
186
- ? `${citation.decisionRef} — a fitness rule this law declares`
187
- : registryUnread
188
- ? `${citation.decisionRef} — unresolved: the decision registry could not be read`
189
- : `${citation.decisionRef} — does not resolve`;
190
- lines.push(` ${citation.resolution.padEnd(10)}${citation.label} → ${target}`);
217
+ if (
218
+ !registryUnread &&
219
+ Array.isArray(decisions.unresolvedDecisionRefs) &&
220
+ decisions.unresolvedDecisionRefs.length > 0
221
+ ) {
222
+ lines.push(" unresolved decisionRefs:");
223
+ for (const ref of decisions.unresolvedDecisionRefs) {
224
+ lines.push(` ${ref.kind} ${ref.label} → ${ref.reason}`);
225
+ }
191
226
  }
192
227
  return lines;
193
228
  }
@@ -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
  }