@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,219 @@
1
+ /**
2
+ * The terminal report for the `diff` command: two graph snapshots compared.
3
+ *
4
+ * Each section — added projects, removed projects, changed projects, added
5
+ * edges, removed edges — is printed only when it has content, and always ends
6
+ * with a count. A section with zero entries is absent from the report, and the
7
+ * summary line then names "no changes" rather than "0 added, 0 removed" — the
8
+ * two must never look identical, because "no changes" is a claim about a
9
+ * complete comparison while "0 added, 0 removed" would be ambiguous over a
10
+ * partial one (`../../../../AGENTS.md`).
11
+ *
12
+ * The changed-projects section lists projects that exist in both baseline and
13
+ * head but whose metadata (tags, type, root) changed. Each changed project
14
+ * shows the field name and its before → after values.
15
+ *
16
+ * When the diff carries a `ruleImpact` field (computed when a boundary config
17
+ * was provided), a rule-impact section lists violations introduced and
18
+ * resolved by the diff, plus a "no boundary-rule impact" line when the config
19
+ * was provided but no violations changed. This section appears whenever
20
+ * `ruleImpact` is present — even when there are no structural changes — so a
21
+ * reader can always tell "config provided, no impact" from "no config". The
22
+ * section is absent when no config was given, so a diff without `--config` is
23
+ * unchanged from its prior output.
24
+ *
25
+ * `coverage.notes` — provider-mismatch, cross-repository, one-sided-policy,
26
+ * and rule-impact-scope warnings `../commands/diff.mjs` pushes there — fold
27
+ * into the summary line the same way `check`'s text face folds `notes` into
28
+ * its own "inspected" line (`../report/text.mjs`'s `formatReport`): appended
29
+ * as `; note`, matching the presentation `check`'s text face already uses.
30
+ * Before this, a note pushed onto `coverage.notes` — a provider migration
31
+ * across baseline and head, say — rode the JSON envelope but never reached
32
+ * the text a terminal user actually reads.
33
+ *
34
+ * This module decides nothing. A formatter that filtered would be a rule
35
+ * wearing a formatter's name (`../README.md`).
36
+ */
37
+
38
+ /**
39
+ * One metadata change as a line.
40
+ *
41
+ * @param {{field: string, baseline: *, head: *}} change
42
+ * @returns {string}
43
+ */
44
+ function formatChange(change) {
45
+ const formatValue = (v) => {
46
+ if (Array.isArray(v)) return v.length > 0 ? v.join(", ") : "(none)";
47
+ if (v === null || v === undefined) return "(none)";
48
+ return String(v);
49
+ };
50
+ return ` ${change.field} ${formatValue(change.baseline)} → ${formatValue(change.head)}`;
51
+ }
52
+
53
+ /**
54
+ * One project as a line, same shape as `graph-text.mjs`.
55
+ *
56
+ * @param {{name: string, root: string, tags: string[]}} project
57
+ * @returns {string}
58
+ */
59
+ function formatProject(project) {
60
+ const tags = project.tags.length > 0 ? ` [${project.tags.join(", ")}]` : "";
61
+ return ` ${project.name} ${project.root}${tags}`;
62
+ }
63
+
64
+ /**
65
+ * One edge as a line, same shape as `graph-text.mjs`.
66
+ *
67
+ * @param {{source: string, target: string, type: string}} edge
68
+ * @returns {string}
69
+ */
70
+ function formatEdge(edge) {
71
+ return ` ${edge.source} → ${edge.target} (${edge.type})`;
72
+ }
73
+
74
+ /**
75
+ * One constraint-impact violation as a line.
76
+ *
77
+ * @param {{messageId: string, constraint: object|null, source: string, target: string}} violation
78
+ * @returns {string}
79
+ */
80
+ function formatImpactViolation(violation) {
81
+ const tag =
82
+ violation.constraint?.sourceTag ??
83
+ violation.constraint?.allSourceTags?.join("+") ??
84
+ "(no matching constraint)";
85
+ return ` ${violation.source} → ${violation.target} ${violation.messageId} [${tag}]`;
86
+ }
87
+
88
+ /**
89
+ * The whole diff report.
90
+ *
91
+ * @param {{diff: object, coverage: object}} input
92
+ * @returns {string}
93
+ */
94
+ export function formatDiffReport({ diff, coverage }) {
95
+ const sections = [];
96
+
97
+ // Baseline summary
98
+ sections.push(
99
+ `baseline ${diff.baseline.path} — ${diff.baseline.projects} projects, ` +
100
+ `${diff.baseline.edges} edges`,
101
+ );
102
+ sections.push(`head ${diff.head.projects} projects, ${diff.head.edges} edges`);
103
+
104
+ // Policy mismatch warning: when the boundary law changed between the
105
+ // baseline and head runs, rule-impact results may reflect the policy change
106
+ // rather than a structural change.
107
+ if (diff.policyMismatch) {
108
+ sections.push(
109
+ "⚠ policy changed between baseline and head — rule-impact results may reflect the policy change, not a structural change",
110
+ );
111
+ }
112
+
113
+ const hasChanges =
114
+ diff.addedProjects.length > 0 ||
115
+ diff.removedProjects.length > 0 ||
116
+ (diff.changedProjects && diff.changedProjects.length > 0) ||
117
+ diff.addedEdges.length > 0 ||
118
+ diff.removedEdges.length > 0;
119
+
120
+ if (hasChanges) {
121
+ if (diff.addedProjects.length > 0) {
122
+ const word = diff.addedProjects.length === 1 ? "project" : "projects";
123
+ sections.push(`+ ${diff.addedProjects.length} added ${word}`);
124
+ for (const project of diff.addedProjects) {
125
+ sections.push(formatProject(project));
126
+ }
127
+ }
128
+
129
+ if (diff.removedProjects.length > 0) {
130
+ const word = diff.removedProjects.length === 1 ? "project" : "projects";
131
+ sections.push(`- ${diff.removedProjects.length} removed ${word}`);
132
+ for (const project of diff.removedProjects) {
133
+ sections.push(formatProject(project));
134
+ }
135
+ }
136
+
137
+ if (diff.changedProjects && diff.changedProjects.length > 0) {
138
+ const word = diff.changedProjects.length === 1 ? "project" : "projects";
139
+ sections.push(`~ ${diff.changedProjects.length} changed ${word}`);
140
+ for (const project of diff.changedProjects) {
141
+ sections.push(` ${project.name}`);
142
+ for (const change of project.changes) {
143
+ sections.push(formatChange(change));
144
+ }
145
+ }
146
+ }
147
+
148
+ if (diff.addedEdges.length > 0) {
149
+ const word = diff.addedEdges.length === 1 ? "edge" : "edges";
150
+ sections.push(`+ ${diff.addedEdges.length} added ${word}`);
151
+ for (const edge of diff.addedEdges) {
152
+ sections.push(formatEdge(edge));
153
+ }
154
+ }
155
+
156
+ if (diff.removedEdges.length > 0) {
157
+ const word = diff.removedEdges.length === 1 ? "edge" : "edges";
158
+ sections.push(`- ${diff.removedEdges.length} removed ${word}`);
159
+ for (const edge of diff.removedEdges) {
160
+ sections.push(formatEdge(edge));
161
+ }
162
+ }
163
+ }
164
+
165
+ // Rule-impact section: violations introduced or resolved by this diff.
166
+ // This section appears whenever the boundary config was provided, so a
167
+ // reader can always tell "config provided, no impact" from "no config".
168
+ if (diff.ruleImpact) {
169
+ const { introduced, resolved } = diff.ruleImpact;
170
+
171
+ if (introduced.length > 0) {
172
+ const word = introduced.length === 1 ? "violation" : "violations";
173
+ sections.push(`⚠ ${introduced.length} boundary ${word} introduced`);
174
+ for (const v of introduced) {
175
+ sections.push(formatImpactViolation(v));
176
+ }
177
+ }
178
+
179
+ if (resolved.length > 0) {
180
+ const word = resolved.length === 1 ? "violation" : "violations";
181
+ sections.push(`✔ ${resolved.length} boundary ${word} resolved`);
182
+ for (const v of resolved) {
183
+ sections.push(formatImpactViolation(v));
184
+ }
185
+ }
186
+
187
+ if (introduced.length === 0 && resolved.length === 0) {
188
+ sections.push("✔ no boundary-rule impact");
189
+ }
190
+ }
191
+
192
+ // The summary line states what was compared, so "no changes" reads as a
193
+ // claim about coverage, not as silence. `coverage.notes` — the same
194
+ // provider/cross-repo/policy/rule-impact-scope warnings the JSON envelope
195
+ // already carries — ride here too, exactly the way `check`'s text face
196
+ // folds its own `notes` into its "inspected" line: appended, never a
197
+ // section a caller could silently omit.
198
+ const inspected =
199
+ `${coverage.imports} import${coverage.imports === 1 ? "" : "s"} in ` +
200
+ `${coverage.analyzedFiles} file${coverage.analyzedFiles === 1 ? "" : "s"} across ` +
201
+ `${coverage.projects} project${coverage.projects === 1 ? "" : "s"}` +
202
+ (coverage.notes && coverage.notes.length > 0 ? `; ${coverage.notes.join("; ")}` : "");
203
+
204
+ if (!hasChanges) {
205
+ sections.push(`✔ no changes between baseline and head (${inspected})`);
206
+ } else {
207
+ const totalChanges =
208
+ diff.addedProjects.length +
209
+ diff.removedProjects.length +
210
+ (diff.changedProjects ? diff.changedProjects.length : 0) +
211
+ diff.addedEdges.length +
212
+ diff.removedEdges.length;
213
+ sections.push(
214
+ `${totalChanges} change${totalChanges === 1 ? "" : "s"} between baseline and head (${inspected})`,
215
+ );
216
+ }
217
+
218
+ return sections.join("\n");
219
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The terminal report for the `discover` command: observed facts, and under
3
+ * `--propose` the candidate architecture those facts imply.
4
+ *
5
+ * The coverage claim sits ABOVE everything — the reader knows whether the
6
+ * observations are complete before reading any entry, exactly like
7
+ * `./graph-text.mjs`'s report. The proposal, when present, is rendered below
8
+ * the observations with the proposal-only banner (`proposed`, `not
9
+ * authoritative`) repeated on every line of every candidate, so a reader who
10
+ * scans the report cannot mistake a candidate for a decision.
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
+ /** The three confidence markers, in the order the legend prints them. */
17
+ const CONFIDENCE_ORDER = ["high", "medium", "low"];
18
+
19
+ /** One project as a line — the same shape `./graph-text.mjs` prints. */
20
+ function formatProject(project) {
21
+ const type = project.type ? ` ${project.type}` : "";
22
+ const tags = project.tags.length > 0 ? ` [${project.tags.join(", ")}]` : "";
23
+ return ` ${project.name} ${project.root}${type}${tags}`;
24
+ }
25
+
26
+ /** One edge as a line — the same shape `./graph-text.mjs` prints. */
27
+ function formatEdge(edge) {
28
+ return ` → ${edge.target} (${edge.type})`;
29
+ }
30
+
31
+ const PROPOSAL_BANNER = " [proposed — not authoritative] ";
32
+
33
+ /**
34
+ * One proposal candidate as one or more lines, every line carrying the
35
+ * proposal-only banner — never a bare candidate that could be read as a
36
+ * decision.
37
+ *
38
+ * The four candidate lists hold different item shapes, so each is formatted by
39
+ * the function that knows its shape rather than by one switch over a flat
40
+ * array — a candidate's `kind` names its role inside its own list
41
+ * (`"component"` means a shared-directory assertion inside
42
+ * `boundaryAssertions.items`, a `"component"` component inside
43
+ * `components.items`), which is why the lists are never concatenated.
44
+ */
45
+
46
+ /** @param {{name: string, projects: string[], confidence: string}} item */
47
+ function formatComponent(item) {
48
+ const confidence = item.confidence ? ` (${item.confidence} confidence)` : "";
49
+ return [
50
+ `${PROPOSAL_BANNER}component ${item.name}${confidence}`,
51
+ ` members: ${item.projects.join(", ")}`,
52
+ ];
53
+ }
54
+
55
+ /** @param {{component: string|undefined, source: string, target: string, confidence: string}} item */
56
+ function formatBoundaryAssertion(item) {
57
+ const confidence = item.confidence ? ` (${item.confidence} confidence)` : "";
58
+ return item.component !== undefined
59
+ ? [`${PROPOSAL_BANNER}boundary: projects of ${item.component} share a role${confidence}`]
60
+ : [
61
+ `${PROPOSAL_BANNER}boundary: ${item.source} and ${item.target} belong to different components${confidence}`,
62
+ ` evidence: edge ${item.source} → ${item.target}`,
63
+ ];
64
+ }
65
+
66
+ /** @param {{axis: string|undefined, values: string[]|undefined, tag: string, members: string[], confidence: string}} item */
67
+ function formatTag(item) {
68
+ const confidence = item.confidence ? ` (${item.confidence} confidence)` : "";
69
+ return item.axis !== undefined
70
+ ? [
71
+ `${PROPOSAL_BANNER}tag axis ${item.axis}${confidence}`,
72
+ ` values: ${(item.values ?? []).join(", ")}`,
73
+ ]
74
+ : [
75
+ `${PROPOSAL_BANNER}tag ${item.tag}${confidence}`,
76
+ ` members: ${(item.members ?? []).join(", ")}`,
77
+ ];
78
+ }
79
+
80
+ /** @param {{kind: "noDependency"|"boundary", source: string, target: string, component: string, evidence: object[], confidence: string}} item */
81
+ function formatRule(item) {
82
+ const confidence = item.confidence ? ` (${item.confidence} confidence)` : "";
83
+ return item.kind === "noDependency"
84
+ ? [`${PROPOSAL_BANNER}rule: ${item.source} must not depend on ${item.target}${confidence}`]
85
+ : [
86
+ `${PROPOSAL_BANNER}rule: declare a boundary around ${item.component}${confidence}`,
87
+ ` members: ${item.evidence
88
+ .filter((e) => e.kind === "shared-directory")
89
+ .map((e) => e.project)
90
+ .join(", ")}`,
91
+ ];
92
+ }
93
+
94
+ /**
95
+ * The whole discover report.
96
+ *
97
+ * @param {{discovery: {projects: object[], edges: object[], tags: string[]},
98
+ * proposal: object|null,
99
+ * coverage: object}} input
100
+ * @returns {string}
101
+ */
102
+ export function formatDiscoverReport({ discovery, proposal, coverage }) {
103
+ const sections = [];
104
+
105
+ const inspected =
106
+ `${coverage.imports} import${coverage.imports === 1 ? "" : "s"} in ` +
107
+ `${coverage.analyzedFiles} file${coverage.analyzedFiles === 1 ? "" : "s"} across ` +
108
+ `${coverage.projects} project${coverage.projects === 1 ? "" : "s"}`;
109
+
110
+ if (coverage.complete) {
111
+ sections.push(`✔ discovery complete (${inspected})`);
112
+ } else {
113
+ const notAnalyzedCount = coverage.notAnalyzed.length;
114
+ sections.push(
115
+ `✖ discovery incomplete — ${notAnalyzedCount} file${notAnalyzedCount === 1 ? "" : "s"} ` +
116
+ `could not be analyzed, so these observations may under-represent the workspace (${inspected})`,
117
+ );
118
+ }
119
+
120
+ const projectWord = discovery.projects.length === 1 ? "project" : "projects";
121
+ sections.push(`${discovery.projects.length} ${projectWord}`);
122
+
123
+ const edgesBySource = new Map();
124
+ for (const edge of discovery.edges) {
125
+ let list = edgesBySource.get(edge.source);
126
+ if (!list) {
127
+ list = [];
128
+ edgesBySource.set(edge.source, list);
129
+ }
130
+ list.push(edge);
131
+ }
132
+
133
+ for (const project of discovery.projects) {
134
+ sections.push(formatProject(project));
135
+ const edges = edgesBySource.get(project.name);
136
+ if (edges && edges.length > 0) {
137
+ for (const edge of edges) {
138
+ sections.push(formatEdge(edge));
139
+ }
140
+ } else {
141
+ sections.push(" (no dependencies)");
142
+ }
143
+ }
144
+
145
+ const edgeWord = discovery.edges.length === 1 ? "edge" : "edges";
146
+ sections.push(`${discovery.edges.length} ${edgeWord}`);
147
+
148
+ if (discovery.tags.length > 0) {
149
+ sections.push(`tags ${discovery.tags.join(", ")}`);
150
+ } else {
151
+ sections.push("tags (none observed)");
152
+ }
153
+
154
+ if (proposal) {
155
+ sections.push("");
156
+ sections.push("proposed architecture — NOT authoritative, never written");
157
+ if (proposal.unknown) {
158
+ sections.push(" no observed projects — nothing to propose");
159
+ } else {
160
+ const counts = [
161
+ `${proposal.components.total} component${proposal.components.total === 1 ? "" : "s"}`,
162
+ `${proposal.boundaryAssertions.total} boundary ` +
163
+ `assertion${proposal.boundaryAssertions.total === 1 ? "" : "s"}`,
164
+ `${proposal.tagVocabulary.total} tag candidate${proposal.tagVocabulary.total === 1 ? "" : "s"}`,
165
+ `${proposal.rules.total} rule${proposal.rules.total === 1 ? "" : "s"}`,
166
+ ];
167
+ const confidenceLegend = CONFIDENCE_ORDER.map(
168
+ (level) => `${level} (${proposal.uncertainty[level]})`,
169
+ ).join(" · ");
170
+ sections.push(` ${counts.join(", ")}`);
171
+ sections.push(` confidence: ${confidenceLegend}`);
172
+
173
+ const items = [
174
+ ...proposal.components.items.map(formatComponent),
175
+ ...proposal.boundaryAssertions.items.map(formatBoundaryAssertion),
176
+ ...proposal.tagVocabulary.items.map(formatTag),
177
+ ...proposal.rules.items.map(formatRule),
178
+ ];
179
+ for (const lines of items) {
180
+ sections.push(...lines);
181
+ }
182
+ }
183
+ }
184
+
185
+ return sections.join("\n");
186
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The terminal report for the `drift` command: the observed architecture
3
+ * compared against the declared intended one.
4
+ *
5
+ * Each section — one per finding kind, in a fixed taxonomy order — is printed
6
+ * only when it has content, and always ends with a count. A section with zero
7
+ * entries is absent from the report, and the summary line then names "no
8
+ * drift" rather than "0 findings": the two must never look identical, because
9
+ * "no drift" is a claim about a complete comparison while "0 findings" would
10
+ * be ambiguous over a partial one (`../../../../AGENTS.md`). The header
11
+ * states what was compared — the intent fingerprint, the row count, and the
12
+ * observed projects and edges, including how many `implicit` edges were
13
+ * excluded — so "no drift" always reads as a claim about the exact tree and
14
+ * contract the run judged.
15
+ *
16
+ * The taxonomy is the judge's own ten-verb catalogue
17
+ * (`../architecture-intent/judge.mjs`) — the message ids `findings` carry in
18
+ * their `rule`. This report is deterministic as long as the taxonomy list is,
19
+ * and it never re-sorts: the judge already orders findings by total key.
20
+ *
21
+ * `notes` — coverage warnings the judge attaches (`../architecture-intent/judge.mjs`'s
22
+ * `verdict.notes`; today an `optional: true` allowed row the team has not
23
+ * built yet) — fold into the "observed" summary line the same way `check`'s
24
+ * text face folds its own `notes` into its "inspected" line
25
+ * (`../report/text.mjs`'s `formatReport`): appended as `; note`, never a
26
+ * section a caller could silently drop.
27
+ *
28
+ * This module decides nothing. A formatter that filtered would be a rule
29
+ * wearing a formatter's name (`./README.md`).
30
+ */
31
+
32
+ /** The taxonomy in report order. Every rule `judgeIntent` can emit appears here. */
33
+ const TAXONOMY = [
34
+ "intentForbiddenEdge",
35
+ "intentAllowedMissing",
36
+ "projectMissing",
37
+ "projectPresent",
38
+ "projectTagMissing",
39
+ "dependencyForbidden",
40
+ "dependencyNotAllowed",
41
+ "tagDependencyForbidden",
42
+ "intentUnknownProject",
43
+ "intentUnknownTag",
44
+ ];
45
+
46
+ /** A one-line heading per finding kind, for the grouped section. */
47
+ const LEAVE_LABEL = new Map([
48
+ ["intentForbiddenEdge", "dependencies the intended architecture forbids exist"],
49
+ ["intentAllowedMissing", "dependencies the intended architecture allows are not being built"],
50
+ ["projectMissing", "projects the intent requires are missing"],
51
+ ["projectPresent", "projects the intent forbids are present"],
52
+ ["projectTagMissing", "required projects lack required tags"],
53
+ ["dependencyForbidden", "dependencies the intent forbids exist"],
54
+ ["dependencyNotAllowed", "dependencies that are not allowed exist"],
55
+ ["tagDependencyForbidden", "tag-forbidden dependencies exist"],
56
+ ["intentUnknownProject", "intent rows name projects the architecture does not have"],
57
+ ["intentUnknownTag", "tag rules name tags no project carries"],
58
+ ]);
59
+
60
+ function formatFinding(finding) {
61
+ // Edge findings are compact — the pair is the fact. Presence and tag
62
+ // findings carry no source/target pair (the canonical judge sets both to
63
+ // null there), so their message — which names the project or tag — is the
64
+ // fact.
65
+ switch (finding.rule) {
66
+ case "intentForbiddenEdge":
67
+ case "intentAllowedMissing":
68
+ case "dependencyForbidden":
69
+ case "dependencyNotAllowed":
70
+ case "tagDependencyForbidden":
71
+ return ` ${finding.source} → ${finding.target}`;
72
+ default:
73
+ return ` ${finding.message}`;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The whole drift report.
79
+ *
80
+ * @param {{findings: {rule: string, source: string|null, target: string|null,
81
+ * message: string}[],
82
+ * intent: {fingerprint: string, rows: number},
83
+ * observed: {projects: number, edges: number, implicitEdges: number},
84
+ * notes?: string[],
85
+ * unresolvedDecisionRefs?: {kind: string, decisionRef: string}[],
86
+ * decisionRefsChecked?: number}} input
87
+ * `unresolvedDecisionRefs` — rows whose `decisionRef` cites no ADR, rule, or
88
+ * fitness record this workspace's registry knows — is a documentation fact,
89
+ * not a drift finding: it is rendered in its own section and never folds
90
+ * into the finding count or the "no drift" claim below. `decisionRefsChecked`
91
+ * is how many rows carry a `decisionRef` at all (resolved or not) — the
92
+ * same "no fact, no claim" distinction `formatGoWork` states: a section
93
+ * appears when the axis was exercised, silence only when it was not, so
94
+ * "every citation resolves" is never confused with "nothing uses the field".
95
+ * `notes` — coverage warnings (an `optional: true` allowed row not yet
96
+ * built) — fold into the "observed" line; empty/absent changes nothing.
97
+ * @returns {string}
98
+ */
99
+ export function formatDriftReport({
100
+ findings,
101
+ intent,
102
+ observed,
103
+ notes = [],
104
+ unresolvedDecisionRefs = [],
105
+ decisionRefsChecked = unresolvedDecisionRefs.length,
106
+ }) {
107
+ const sections = [];
108
+
109
+ sections.push(
110
+ `intent ${intent.fingerprint} — ${intent.rows} row${intent.rows === 1 ? "" : "s"}`,
111
+ );
112
+ const excluded =
113
+ observed.implicitEdges > 0
114
+ ? ` (${observed.implicitEdges} implicit edge${observed.implicitEdges === 1 ? "" : "s"} excluded)`
115
+ : "";
116
+ // `notes` folds in here, the same "; note" convention `check`'s text face
117
+ // uses for its own coverage notes — appended, not a section a caller could
118
+ // silently drop.
119
+ const notesSuffix = notes.length > 0 ? `; ${notes.join("; ")}` : "";
120
+ sections.push(
121
+ `observed ${observed.projects} project${observed.projects === 1 ? "" : "s"}, ` +
122
+ `${observed.edges} edge${observed.edges === 1 ? "" : "s"}${excluded}${notesSuffix}`,
123
+ );
124
+
125
+ const byId = new Map();
126
+ for (const finding of findings) {
127
+ if (!byId.has(finding.rule)) byId.set(finding.rule, []);
128
+ byId.get(finding.rule).push(finding);
129
+ }
130
+
131
+ // `total` is taken from `findings.length` itself, never accumulated only
132
+ // over the rules this walk visits: a finding whose `rule` the taxonomy does
133
+ // not know must still be counted and rendered, under its own heading,
134
+ // rather than silently dropped from both the report and the "no drift"
135
+ // claim below (the invariant this module is judged against,
136
+ // `../../../../AGENTS.md`).
137
+ const total = findings.length;
138
+ for (const rule of TAXONOMY) {
139
+ const group = byId.get(rule) ?? [];
140
+ if (group.length === 0) continue;
141
+ const word = group.length === 1 ? "finding" : "findings";
142
+ sections.push(`⚠ ${group.length} ${word}: ${LEAVE_LABEL.get(rule)}`);
143
+ for (const finding of group) {
144
+ sections.push(formatFinding(finding));
145
+ }
146
+ }
147
+
148
+ const knownRules = new Set(TAXONOMY);
149
+ const unclassified = findings.filter((finding) => !knownRules.has(finding.rule));
150
+ if (unclassified.length > 0) {
151
+ const word = unclassified.length === 1 ? "finding" : "findings";
152
+ sections.push(
153
+ `⚠ ${unclassified.length} unclassified ${word}: rule id not in this report's taxonomy`,
154
+ );
155
+ for (const finding of unclassified) {
156
+ sections.push(` [${finding.rule}] ${formatFinding(finding).trimStart()}`);
157
+ }
158
+ }
159
+
160
+ const inspected =
161
+ `${observed.projects} project${observed.projects === 1 ? "" : "s"}` +
162
+ ` and ${observed.edges} edge${observed.edges === 1 ? "" : "s"}` +
163
+ (observed.implicitEdges > 0 ? ` (${observed.implicitEdges} implicit excluded)` : "");
164
+
165
+ if (total === 0) {
166
+ sections.push(`✔ no drift — the observed architecture matches the intended one (${inspected})`);
167
+ } else {
168
+ sections.push(`${total} drift finding${total === 1 ? "" : "s"} (${inspected})`);
169
+ }
170
+
171
+ // A separate, non-verdict axis — rendered last, after the drift verdict
172
+ // itself, so a clean "no drift" line never reads as though it also vouches
173
+ // for an unresolvable decisionRef sitting above it. "No fact, no claim": a
174
+ // workspace whose rows carry no decisionRef gets no section at all, but one
175
+ // that DOES use the field and finds every citation clean still gets a
176
+ // stated line — silence there would be indistinguishable from never
177
+ // having checked, the same reasoning `formatGoWork` states for its own axis.
178
+ if (unresolvedDecisionRefs.length > 0) {
179
+ sections.push(
180
+ [
181
+ `⚠ ${unresolvedDecisionRefs.length} intent row${unresolvedDecisionRefs.length === 1 ? "" : "s"} ` +
182
+ `cite${unresolvedDecisionRefs.length === 1 ? "s" : ""} a decisionRef that does not resolve ` +
183
+ `to a known ADR, rule, or fitness record:`,
184
+ ...unresolvedDecisionRefs.map(({ kind, decisionRef }) => ` ${kind} — "${decisionRef}"`),
185
+ ].join("\n"),
186
+ );
187
+ } else if (decisionRefsChecked > 0) {
188
+ sections.push(
189
+ `✔ every decisionRef citation (${decisionRefsChecked}) resolves to a known ADR, rule, or fitness record`,
190
+ );
191
+ }
192
+
193
+ return sections.join("\n");
194
+ }