@ecoma-io/archkeep 0.13.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 (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The terminal report for the agent architecture planning context: the
3
+ * deterministic facts an agent reasons over before changing a project.
4
+ *
5
+ * The layout mirrors what `context`, `impact`, `graph` and `check` each print,
6
+ * so a reader who has seen those commands recognises the same shapes here. The
7
+ * coverage claim sits ABOVE everything — the reader must know whether the
8
+ * result is complete before reading a single entry, the same reasoning as
9
+ * `./impact-text.mjs`.
10
+ *
11
+ * This module decides nothing. A formatter that filtered would be a rule
12
+ * wearing a formatter's name (`../README.md`). Every fact here comes from the
13
+ * `result` object the command built; nothing is recomputed, and nothing is
14
+ * guessed. Sections that are empty state so rather than printing nothing — a
15
+ * plan with no violations and a plan the renderer forgot are not the same
16
+ * document (`AGENTS.md`: "an empty result is a claim, not a shrug").
17
+ */
18
+
19
+ import { formatConstraint } from "./text.mjs";
20
+
21
+ /** Two spaces of indent for detail lines. */
22
+ const DETAIL = " ";
23
+
24
+ /**
25
+ * The whole planning-context report.
26
+ *
27
+ * @param {{project: object, coverage: object, unresolvedDecisionRefs?: Set<string>}} input
28
+ * The `result` object the plan command built, plus its coverage.
29
+ * `unresolvedDecisionRefs` — `decisionRef` values a matched constraint row
30
+ * cites that do not resolve to any ADR, rule, or fitness record — is
31
+ * forwarded to `formatConstraint`, the same seam `context`'s report uses.
32
+ * @returns {string}
33
+ */
34
+ export function formatPlanContextReport({ project, coverage, unresolvedDecisionRefs }) {
35
+ const sections = [];
36
+
37
+ const inspected =
38
+ `${coverage.imports} import${coverage.imports === 1 ? "" : "s"} in ` +
39
+ `${coverage.analyzedFiles} file${coverage.analyzedFiles === 1 ? "" : "s"} across ` +
40
+ `${coverage.projects} project${coverage.projects === 1 ? "" : "s"}`;
41
+
42
+ if (coverage.complete) {
43
+ sections.push(`✔ planning context complete (${inspected})`);
44
+ } else {
45
+ const notAnalyzedCount = coverage.notAnalyzed.length;
46
+ sections.push(
47
+ `✖ planning context incomplete — ${notAnalyzedCount} file${notAnalyzedCount === 1 ? "" : "s"} ` +
48
+ `could not be analyzed, so these facts may be against an incomplete graph (${inspected})`,
49
+ );
50
+ }
51
+
52
+ const plan = project.plan;
53
+
54
+ // Target project, tags, policy fingerprint.
55
+ const tagsText = project.tags.length > 0 ? project.tags.join(", ") : "none";
56
+ sections.push(`Project ${project.project}`);
57
+ sections.push(`Tags ${tagsText}`);
58
+ sections.push(`Policy fingerprint ${plan.policyFingerprint}`);
59
+
60
+ // Intent-bearing constraints.
61
+ if (project.constraints.length > 0) {
62
+ const count = project.constraints.length;
63
+ sections.push(`Constraints (${count} row${count === 1 ? "" : "s"} govern this project):`);
64
+ for (const constraint of project.constraints) {
65
+ sections.push(`${DETAIL}${formatConstraint(constraint, unresolvedDecisionRefs)}`);
66
+ if (constraint.description) {
67
+ sections.push(`${DETAIL} description ${constraint.description}`);
68
+ }
69
+ if (constraint.remediation) {
70
+ sections.push(`${DETAIL} remediation ${constraint.remediation}`);
71
+ }
72
+ }
73
+ } else {
74
+ sections.push(
75
+ "Constraints (no matching constraint rows — this project's tags match no depConstraints entry)",
76
+ );
77
+ }
78
+
79
+ // Affected projects.
80
+ sections.push("Architecture");
81
+ sections.push(
82
+ `${DETAIL}affected projects ${plan.architecture.targets.length > 0 ? plan.architecture.targets.join(", ") : "(none given — the whole workspace is in scope)"}`,
83
+ );
84
+ sections.push(`${DETAIL}projects ${plan.architecture.projects.length}`);
85
+ sections.push(`${DETAIL}dependencies ${plan.architecture.dependencies.length}`);
86
+
87
+ // Impact, capped with overflow note.
88
+ sections.push("Impact");
89
+ for (const entry of plan.impact) {
90
+ const capNote = entry.hasMore
91
+ ? ` (… and ${entry.dependentsTotal - entry.dependents.length} more)`
92
+ : "";
93
+ const word = entry.dependentsTotal === 1 ? "project" : "projects";
94
+ sections.push(
95
+ `${DETAIL}${entry.project}: ${entry.dependentsTotal} ${word} depend${entry.dependentsTotal === 1 ? "s" : ""} on it` +
96
+ ` (direct ${entry.direct.length}, transitive ${entry.transitive.length})${capNote}`,
97
+ );
98
+ }
99
+
100
+ // Violations (scoped reporting, whole-tree verdict).
101
+ sections.push(`Violations (${plan.violations.length} in scope of this change)`);
102
+ if (plan.violations.length === 0) {
103
+ sections.push(
104
+ `${DETAIL}none — the full-workspace rule-engine verdict found no violations in scope`,
105
+ );
106
+ } else {
107
+ for (const violation of plan.violations) {
108
+ const edge =
109
+ `${violation.sourceProject ?? "(no project)"} → ` +
110
+ `${violation.targetProject ?? "(unresolved)"}`;
111
+ sections.push(`${DETAIL}${edge} ${violation.messageId} ${violation.sourceFile}`);
112
+ }
113
+ }
114
+
115
+ // Drift.
116
+ sections.push("Drift");
117
+ sections.push(`${DETAIL}go.work ${driftText(plan.drift.goWork)}`);
118
+ sections.push(`${DETAIL}tsconfig paths ${driftText(plan.drift.tsconfigPaths)}`);
119
+
120
+ // Canonical Architecture Intent — the same fold `check` and `drift` report.
121
+ const intentCount = plan.intent?.findings.length ?? 0;
122
+ const intentUnresolved = plan.intent?.unresolved.length ?? 0;
123
+ sections.push(`Intent (${plan.intent ? "verified" : "no intent declared"})`);
124
+ if (!plan.intent) {
125
+ sections.push(
126
+ `${DETAIL}towards architecture-intent.json — the workspace declares no intent, so none is judged`,
127
+ );
128
+ } else {
129
+ const verdict =
130
+ intentCount > 0
131
+ ? `${intentCount} finding${intentCount === 1 ? "" : "s"}`
132
+ : intentUnresolved > 0
133
+ ? `no-verdict (${intentUnresolved} unresolvable)`
134
+ : "ok";
135
+ sections.push(`${DETAIL}${plan.intent.verdict}: ${verdict} (${plan.intent.rows} rows)`);
136
+ }
137
+
138
+ // Verification commands.
139
+ sections.push("Verify after the change");
140
+ for (const command of plan.verify) {
141
+ sections.push(`${DETAIL}${command}`);
142
+ }
143
+
144
+ return sections.join("\n");
145
+ }
146
+
147
+ /**
148
+ * One drift section as text: a finding count, or the honest `null` answer —
149
+ * "not judged", which is NOT "clean" (`AGENTS.md`).
150
+ *
151
+ * @param {{checked: boolean, findings: object[]}|null} drift
152
+ * @returns {string}
153
+ */
154
+ function driftText(drift) {
155
+ if (drift === null) return "not judged (no manifest to read)";
156
+ return drift.findings.length === 0
157
+ ? "checked, no drift"
158
+ : `${drift.findings.length} finding${drift.findings.length === 1 ? "" : "s"}`;
159
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The terminal report for the `provenance` command: where this run's facts
3
+ * came from, and which governance rows carry an origin.
4
+ *
5
+ * Two lines state what was inspected before any verdict — the repo state (or
6
+ * its absence) and the row count — so "every row carries an origin" always
7
+ * reads as a claim about a specific, complete surface rather than a bare
8
+ * count. Determinism: every line derives from the static facts
9
+ * `../commands/provenance-command.mjs` already resolved — no wall-clock time
10
+ * and no `localeCompare` enter here, matching every other report renderer.
11
+ *
12
+ * This module decides nothing. A formatter that filtered would be a rule
13
+ * wearing a formatter's name (`./README.md`).
14
+ */
15
+
16
+ /**
17
+ * @param {{establishment: boolean,
18
+ * repo: {commit: string|null, remote: string|null, dirty: boolean|null}|null,
19
+ * rowsTotal: number,
20
+ * unattested: {kind: string, label: string, note: string}[],
21
+ * decisionRefTotal: number,
22
+ * unresolvedDecisionRefs: {kind: string, label: string, decisionRef: string, note: string}[]}} input
23
+ * `decisionRefTotal` is how many governance rows cite a `decisionRef` at
24
+ * all — the resolution section renders only when it is non-zero, the same
25
+ * "no fact, no claim" bargain every optional axis in this tool states.
26
+ * @returns {string}
27
+ */
28
+ export function formatProvenanceReport({
29
+ establishment,
30
+ repo,
31
+ rowsTotal,
32
+ unattested,
33
+ decisionRefTotal,
34
+ unresolvedDecisionRefs,
35
+ }) {
36
+ const attestedCount = rowsTotal - unattested.length;
37
+ const text = [];
38
+ text.push(
39
+ establishment
40
+ ? `repo ${repo.commit}${repo.dirty ? " (dirty)" : ""}` +
41
+ (repo.remote ? ` — ${repo.remote}` : "")
42
+ : "repo provenance unavailable — not a git repository or git not installed",
43
+ );
44
+ text.push(
45
+ `rows ${rowsTotal} governance row${rowsTotal === 1 ? "" : "s"}, ` +
46
+ `${attestedCount} with an origin, ${unattested.length} without`,
47
+ );
48
+ if (unattested.length > 0) {
49
+ text.push("unattested (no origin recorded — cannot attest):");
50
+ for (const row of unattested) {
51
+ text.push(` ${row.kind}`);
52
+ }
53
+ text.push(`${unattested.length} of them carry no decision behind the rule`);
54
+ } else {
55
+ text.push(
56
+ `✔ every governance row carries an origin — each names who decided ` +
57
+ `on it and with what tool`,
58
+ );
59
+ }
60
+ if (decisionRefTotal > 0) {
61
+ if (unresolvedDecisionRefs.length > 0) {
62
+ text.push("unresolved decisionRefs (cite no known ADR, rule, or fitness record):");
63
+ for (const row of unresolvedDecisionRefs) {
64
+ text.push(` ${row.kind} — "${row.decisionRef}"`);
65
+ }
66
+ text.push(
67
+ `${unresolvedDecisionRefs.length} of ${decisionRefTotal} decisionRef citation` +
68
+ `${decisionRefTotal === 1 ? "" : "s"} do not resolve`,
69
+ );
70
+ } else {
71
+ text.push(
72
+ `✔ every decisionRef citation (${decisionRefTotal}) resolves to a known ADR, ` +
73
+ `rule, or fitness record`,
74
+ );
75
+ }
76
+ }
77
+ return text.join("\n");
78
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The terminal report for the `reconcile` command: the intended model and the
3
+ * observed architecture compared element by element, then — with `--propose` —
4
+ * the ranked candidate list of model edits.
5
+ *
6
+ * Every section states what it is a claim about: the intent fingerprint and
7
+ * row count, the observed project and edge counts (implicit edges excluded and
8
+ * counted), then the divergence in fixed plane order — projects, edges, tags,
9
+ * boundaries, intent rows — each only when it has content and each ending with
10
+ * a count. A clean reconciliation prints "✔ model and reality agree" — never
11
+ * "0 divergences", which would be ambiguous over a partial comparison
12
+ * (`../../../../AGENTS.md`). An `unknown` score prints as such, never as a
13
+ * match.
14
+ *
15
+ * The `--propose` face ends with the ranked candidate list: one line per
16
+ * candidate in list order (severity, then plane, then name — the order
17
+ * `buildRankedCandidates` returned), each carrying its `proposed` /
18
+ * `notAuthoritative` marker explicitly so a reader can never mistake the
19
+ * description of an edit for an applied one.
20
+ *
21
+ * This module decides nothing. A formatter that filtered would be a rule
22
+ * wearing a formatter's name (`../README.md`).
23
+ */
24
+
25
+ /**
26
+ * Neutralises control and terminal-escape sequences in a name or value before
27
+ * it is printed — the same `sanitize` `history-text.mjs` uses, so a crafted
28
+ * project/tag/edge name cannot inject escape sequences into a consumer's
29
+ * terminal (`../../../../SECURITY.md`).
30
+ *
31
+ * @param {string} text
32
+ * @returns {string}
33
+ */
34
+ function sanitize(text) {
35
+ // eslint-disable-next-line no-control-regex
36
+ return String(text).replace(/[\x00-\x1F\x7F]/g, (c) => {
37
+ if (c === "\n") return "\\n";
38
+ if (c === "\t") return "\\t";
39
+ if (c === "\r") return "\\r";
40
+ return `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`;
41
+ });
42
+ }
43
+
44
+ /** The planes in report order. */
45
+ const PLANE_ORDER = ["projects", "edges", "tags", "boundaries", "intentRows"];
46
+
47
+ /** A one-line label per plane, for the diverging section. */
48
+ const PLANE_LABEL = new Map([
49
+ ["projects", "observed projects the model does not match"],
50
+ ["edges", "observed edges the model does not match"],
51
+ ["tags", "required-tag and tag-rule divergence"],
52
+ ["boundaries", "boundary membership divergence"],
53
+ ["intentRows", "intent rows whose statement is not observed"],
54
+ ]);
55
+
56
+ /** The state symbol a diverging element prints. */
57
+ const STATE_SYMBOL = new Map([
58
+ ["unexpected", "+"],
59
+ ["absent", "-"],
60
+ ["unknown", "?"],
61
+ ]);
62
+
63
+ /**
64
+ * One diverging element as a line.
65
+ *
66
+ * @param {import("../governance/reconcile-score.mjs").ScoredElement} element
67
+ * @returns {string}
68
+ */
69
+ function formatElement(element) {
70
+ const symbol = STATE_SYMBOL.get(element.state) ?? " ";
71
+ return ` ${symbol} ${sanitize(element.name)} (${sanitize(element.classification)})`;
72
+ }
73
+
74
+ /**
75
+ * One candidate as a line.
76
+ *
77
+ * @param {import("../governance/reconcile-candidates.mjs").CandidateEdit} candidate
78
+ * @returns {string}
79
+ */
80
+ function formatCandidate(candidate) {
81
+ const edit = candidate.edit;
82
+ const where = edit.section ? `${edit.action} in ${edit.section}` : edit.action;
83
+ return ` ${candidate.kind} ${sanitize(candidate.name)} (${sanitize(where)} — ${sanitize(edit.reason)})`;
84
+ }
85
+
86
+ /**
87
+ * The whole reconcile report.
88
+ *
89
+ * @param {object} input
90
+ * @param {object} input.scores From `reconcileScores`.
91
+ * @param {import("../governance/reconcile-candidates.mjs").CandidateEdit[]|null} input.candidates
92
+ * The ranked candidate list, `null` unless `--propose`.
93
+ * @param {{fingerprint: string, rows: number}} input.intent
94
+ * @param {{projects: number, edges: number, implicitEdges: number}} input.observed
95
+ * @returns {string}
96
+ */
97
+ export function formatReconcileReport({ scores, candidates, intent, observed }) {
98
+ const sections = [];
99
+
100
+ sections.push(
101
+ `intent ${intent.fingerprint} — ${intent.rows} row${intent.rows === 1 ? "" : "s"}`,
102
+ );
103
+ const excluded =
104
+ observed.implicitEdges > 0
105
+ ? ` (${observed.implicitEdges} implicit edge${observed.implicitEdges === 1 ? "" : "s"} excluded)`
106
+ : "";
107
+ sections.push(
108
+ `observed ${observed.projects} project${observed.projects === 1 ? "" : "s"}, ` +
109
+ `${observed.edges} edge${observed.edges === 1 ? "" : "s"}${excluded}`,
110
+ );
111
+
112
+ // Iterate every key `scores` actually carries, not only the five this
113
+ // module names in `PLANE_ORDER` — `total` and every rendered section come
114
+ // from that walk, so a plane the label map does not know is named and
115
+ // counted rather than silently dropped from the report and from the
116
+ // "no divergence" claim below (the invariant this module is judged
117
+ // against, `../../../../AGENTS.md`). Known planes still render first, in
118
+ // their fixed order; anything else follows, in the order `scores` carries it.
119
+ const knownPlanes = new Set(PLANE_ORDER);
120
+ const planes = [...PLANE_ORDER, ...Object.keys(scores).filter((key) => !knownPlanes.has(key))];
121
+
122
+ let total = 0;
123
+ for (const plane of planes) {
124
+ const group = scores[plane] ?? [];
125
+ const diverging = group.filter((element) => element.state !== "match");
126
+ if (diverging.length === 0) continue;
127
+ total += diverging.length;
128
+ const word = diverging.length === 1 ? "element" : "elements";
129
+ const label = PLANE_LABEL.get(plane) ?? `unknown plane "${plane}"`;
130
+ sections.push(`⚠ ${diverging.length} ${word}: ${label}`);
131
+ for (const element of diverging) sections.push(formatElement(element));
132
+ }
133
+
134
+ const inspected =
135
+ `${observed.projects} project${observed.projects === 1 ? "" : "s"}` +
136
+ ` and ${observed.edges} edge${observed.edges === 1 ? "" : "s"}` +
137
+ (observed.implicitEdges > 0 ? ` (${observed.implicitEdges} implicit excluded)` : "");
138
+
139
+ if (total === 0) {
140
+ sections.push(
141
+ `✔ no divergence — the observed architecture matches the intended model (${inspected})`,
142
+ );
143
+ } else {
144
+ sections.push(`${total} divergence${total === 1 ? "" : "s"} (${inspected})`);
145
+ }
146
+
147
+ if (candidates) {
148
+ const listLabel = candidates.length === 1 ? "1 candidate" : `${candidates.length} candidates`;
149
+ sections.push(`proposal ${listLabel}, ranked — proposed, not authoritative, never written`);
150
+ for (const candidate of candidates) sections.push(formatCandidate(candidate));
151
+ if (candidates.length > 0) {
152
+ sections.push(
153
+ " — apply none of these without review; architecture-intent.json is untouched",
154
+ );
155
+ }
156
+ }
157
+
158
+ return sections.join("\n");
159
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The terminal report for the `report` command: the architecture governance
3
+ * document — the health metrics, the waiver surface, the fitness gates, the
4
+ * recorded decisions each governed row cites, the run's provenance, and last
5
+ * the evidence the run could not inspect.
6
+ *
7
+ * Three rules this renderer holds, all of them the empty-result invariant
8
+ * (`../../../../AGENTS.md`) wearing a formatter's clothes:
9
+ *
10
+ * - **A surface that could not be established never renders as a clean
11
+ * zero.** Its verdict word is `unknown` and the reason it carries is
12
+ * printed beside it, and the same fact appears again in the closing
13
+ * `could not inspect` block. A reader skimming either one sees the gap.
14
+ * - **`not_applicable` is loud too, and says why.** "The workspace declared
15
+ * none" and "the run could not look" are different sentences and must never
16
+ * read alike.
17
+ * - **A citation that resolves to nothing is `unknown`, never a pass.** A
18
+ * governed row linking to an ADR the registry knows prints that record and
19
+ * its status; a row citing something unresolvable prints `unknown` with the
20
+ * ref that failed.
21
+ *
22
+ * The metric block is rendered through `./health-text.mjs`'s own
23
+ * `formatMetricLine`/`HEALTH_METRIC_ORDER` rather than a second copy of them,
24
+ * so `health` and `report` can never disagree about how a verdict looks or in
25
+ * what order the metrics come.
26
+ *
27
+ * Determinism: every list here arrives already ordered by the command
28
+ * (`../commands/report.mjs` states which order and why), and this module adds
29
+ * no sort, no clock and no locale-sensitive comparison. It decides nothing —
30
+ * a formatter that filtered would be a rule wearing a formatter's name
31
+ * (`./README.md`).
32
+ *
33
+ * Plain text, not Markdown, deliberately: every identity in this document —
34
+ * a project name, a waiver's glob, an ADR id, a fitness function's name —
35
+ * comes from the workspace, and a Markdown rendering would have to escape
36
+ * each one or silently render it wrong (a `_` in a project name, a `*` in a
37
+ * suppression path). A document that quietly mis-renders the name of the
38
+ * thing it is reporting on is the same class of defect as one that reports
39
+ * nothing.
40
+ */
41
+ import { HEALTH_METRIC_ORDER, formatCoverageHeadline, formatMetricLine } from "./health-text.mjs";
42
+
43
+ /** A section heading, with a blank line before it. */
44
+ function heading(title) {
45
+ return `\n${title}`;
46
+ }
47
+
48
+ /**
49
+ * The verdict word for a whole surface, aligned to the same column the metric
50
+ * lines use, with the reason a non-measuring verdict must carry.
51
+ *
52
+ * @param {string} label
53
+ * @param {{verdict: string, note?: string|null}} surface
54
+ * @returns {string}
55
+ */
56
+ function surfaceLine(label, surface) {
57
+ const note = surface.note ? ` (${surface.note})` : "";
58
+ return ` ${surface.verdict.padEnd(16)}${label}${note}`;
59
+ }
60
+
61
+ /**
62
+ * The provenance block: where this run's facts came from. An unestablished
63
+ * origin prints as a stated absence, never as a blank line a reader could
64
+ * mistake for a clean commit.
65
+ *
66
+ * @param {object} provenance
67
+ * @param {{root: string, provider: string, marker: string}} context
68
+ * @returns {string[]}
69
+ */
70
+ function provenanceLines(provenance, context) {
71
+ const lines = [
72
+ ` root ${context.root}`,
73
+ ` provider ${context.provider} (${context.marker})`,
74
+ ` policy ${provenance.policySource ?? "none declared — no boundary law governs this run"}`,
75
+ ];
76
+ if (provenance.established) {
77
+ lines.push(` commit ${provenance.repo.commit}${provenance.repo.dirty ? " (dirty)" : ""}`);
78
+ lines.push(` remote ${provenance.repo.remote ?? "none configured"}`);
79
+ } else {
80
+ lines.push(" commit repo provenance unavailable — this report carries no origin claim");
81
+ }
82
+ const { total, unattested } = provenance.rows;
83
+ lines.push(
84
+ ` rows ${total} governed row${total === 1 ? "" : "s"}, ` +
85
+ `${unattested.length} with no origin recorded`,
86
+ );
87
+ for (const row of unattested) {
88
+ lines.push(` unattested ${row.label} — no origin recorded, cannot attest`);
89
+ }
90
+ return lines;
91
+ }
92
+
93
+ /**
94
+ * The waiver block: the counts, then one line per suppression on the table.
95
+ *
96
+ * @param {object} waivers
97
+ * @returns {string[]}
98
+ */
99
+ function waiverLines(waivers) {
100
+ // "waivers", not "waiver surface": the metric block above already carries a
101
+ // line by that name, and the two are different facts — the metric counts
102
+ // the violations a suppression currently covers, this section describes the
103
+ // table those suppressions live in. Two labels reading alike is how a
104
+ // reader comes to believe one number answers the other's question.
105
+ const lines = [surfaceLine("waivers", waivers)];
106
+ if (waivers.counts === null) return lines;
107
+ const c = waivers.counts;
108
+ lines.push(
109
+ ` ${c.waivers} waiver${c.waivers === 1 ? "" : "s"} ` +
110
+ `(${c.expired} expired, ${c.stale} covering nothing), ` +
111
+ `${c.suppressions} permanent suppression${c.suppressions === 1 ? "" : "s"} ` +
112
+ `hiding ${c.suppressed} violation${c.suppressed === 1 ? "" : "s"}`,
113
+ );
114
+ for (const row of waivers.rows) {
115
+ const expiry = row.expiresAt === null ? "" : ` until ${row.expiresAt}`;
116
+ lines.push(
117
+ ` ${row.status.padEnd(10)}${row.path}${expiry} — covers ${row.covered} ` +
118
+ `violation${row.covered === 1 ? "" : "s"}`,
119
+ );
120
+ if (row.reason !== null) lines.push(` reason: ${row.reason}`);
121
+ }
122
+ return lines;
123
+ }
124
+
125
+ /**
126
+ * The fitness block: one line per declared gate, with the ADR(s) binding it.
127
+ *
128
+ * `adrs: null` means the registry could not be read, which is a different
129
+ * statement from `[]` ("no recorded decision binds this gate") and is printed
130
+ * as such.
131
+ *
132
+ * @param {object} fitness
133
+ * @returns {string[]}
134
+ */
135
+ function fitnessLines(fitness) {
136
+ const lines = [surfaceLine("fitness gates", fitness)];
137
+ for (const fn of fitness.functions) {
138
+ const bound =
139
+ fn.adrs === null
140
+ ? " bound by: unknown — the decision registry could not be read"
141
+ : fn.adrs.length > 0
142
+ ? ` bound by: ${fn.adrs.join(", ")}`
143
+ : " bound by: no recorded decision";
144
+ lines.push(` ${fn.verdict.padEnd(16)}${fn.name}${bound}`);
145
+ if (fn.verdict !== "pass" && fn.message !== null) lines.push(` ${fn.message}`);
146
+ }
147
+ return lines;
148
+ }
149
+
150
+ /**
151
+ * The decisions block: the registry, and every governed row that cites one.
152
+ *
153
+ * @param {object} decisions
154
+ * @returns {string[]}
155
+ */
156
+ function decisionLines(decisions) {
157
+ const lines = [surfaceLine("decisions", decisions)];
158
+ if (decisions.registry.count !== null) {
159
+ lines.push(
160
+ ` ${decisions.registry.count} record${decisions.registry.count === 1 ? "" : "s"} in ` +
161
+ `${decisions.registry.dir}/`,
162
+ );
163
+ for (const record of decisions.records) {
164
+ const binds =
165
+ record.bindings.length > 0
166
+ ? `binds ${record.bindings.join(", ")}`
167
+ : "binds nothing — not yet enforceable";
168
+ lines.push(` ${record.id} (${record.status}) ${binds}`);
169
+ }
170
+ }
171
+ if (decisions.citations.length === 0) {
172
+ lines.push(" no governed row cites a decisionRef");
173
+ return lines;
174
+ }
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}`);
191
+ }
192
+ return lines;
193
+ }
194
+
195
+ /**
196
+ * The whole governance report.
197
+ *
198
+ * @param {{coverage: object, metrics: object, trends: object|null,
199
+ * waivers: object, fitness: object, decisions: object, provenance: object,
200
+ * uninspectable: {surface: string, reason: string}[],
201
+ * context: {root: string, provider: string, marker: string}}} input
202
+ * @returns {string}
203
+ */
204
+ export function formatGovernanceReport({
205
+ coverage,
206
+ metrics,
207
+ trends,
208
+ waivers,
209
+ fitness,
210
+ decisions,
211
+ provenance,
212
+ uninspectable,
213
+ context,
214
+ }) {
215
+ const sections = [];
216
+
217
+ // The document's own verdict, first: whether it could be established at
218
+ // all. A reader who stops after one line must not read "no verdict" as
219
+ // "healthy".
220
+ sections.push(
221
+ uninspectable.length === 0
222
+ ? "architecture governance report — every surface reached a verdict"
223
+ : `architecture governance report — NO VERDICT: ${uninspectable.length} ` +
224
+ `surface${uninspectable.length === 1 ? "" : "s"} could not be inspected`,
225
+ );
226
+
227
+ sections.push(heading("provenance"));
228
+ sections.push(...provenanceLines(provenance, context));
229
+
230
+ sections.push(heading("health"));
231
+ sections.push(formatCoverageHeadline(coverage, "report"));
232
+ for (const { key, label } of HEALTH_METRIC_ORDER) {
233
+ sections.push(formatMetricLine(label, metrics[key]));
234
+ }
235
+
236
+ sections.push(heading("governance surfaces"));
237
+ sections.push(...waiverLines(waivers));
238
+ sections.push(...fitnessLines(fitness));
239
+ sections.push(...decisionLines(decisions));
240
+
241
+ if (trends) {
242
+ sections.push(heading("trends"));
243
+ sections.push(
244
+ ` ${trends.snapshots.length} snapshot${trends.snapshots.length === 1 ? "" : "s"}`,
245
+ );
246
+ for (const snapshot of trends.snapshots) {
247
+ sections.push(
248
+ ` ${snapshot.name} ${snapshot.projects} projects, ${snapshot.dependencies} edges`,
249
+ );
250
+ }
251
+ for (const note of trends.notes) sections.push(` ${note}`);
252
+ }
253
+
254
+ // Last, and unconditional: an empty block is the claim "every surface was
255
+ // inspectable", which is exactly what it must mean.
256
+ sections.push(heading("could not inspect"));
257
+ if (uninspectable.length === 0) {
258
+ sections.push(" nothing — every surface in this report reached a verdict");
259
+ } else {
260
+ for (const gap of uninspectable) sections.push(` ${gap.surface}: ${gap.reason}`);
261
+ }
262
+
263
+ return sections.join("\n");
264
+ }