@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,123 @@
1
+ /**
2
+ * The terminal report for the `health` command: per-metric verdicts with the
3
+ * number each was decided over, and the trend when a snapshot directory was
4
+ * given.
5
+ *
6
+ * Each metric renders as a verdict word (`ok` / `findings` /
7
+ * `not_applicable` / `unknown`) and — exactly when the metric MEASURED
8
+ * something — the number behind it. A metric whose verdict is
9
+ * `not_applicable` or `unknown` carries no number, because a number with no
10
+ * evidence would read as a measured zero (`../../../../AGENTS.md`: an empty
11
+ * result is a claim, not a shrug). The report states what each
12
+ * `not_applicable` and each `unknown` could not measure, so nothing reads as a
13
+ * silent gap.
14
+ *
15
+ * Determinism: the metric order is fixed, and the trend rows are the snapshots
16
+ * in byte-sort order (`../commands/history.mjs` orders them). This module
17
+ * decides nothing — a formatter that filtered would be a rule wearing a
18
+ * formatter's name (`../README.md`).
19
+ *
20
+ * Three pieces below are exported rather than private, because `report`
21
+ * renders the same metrics inside a larger document (`./report-text.mjs`) and
22
+ * a second copy of "how a metric renders" is how the two faces would come to
23
+ * disagree about the same verdict — the rule against stating a rule twice
24
+ * (`../../../../AGENTS.md`), applied to a format. The metric ORDER travels
25
+ * with them for the same reason: the order is part of the determinism claim,
26
+ * not a per-renderer taste.
27
+ */
28
+
29
+ /**
30
+ * The metric line: the verdict word aligned to a column, the number exactly
31
+ * when the metric measured one, and the note that says what an unmeasured
32
+ * metric could not look at.
33
+ *
34
+ * `metric.value` is present ONLY for `ok`/`findings`
35
+ * (`../governance/metrics.mjs` fixes that), so this function never has to
36
+ * decide whether a number may be shown — it shows whatever the metric
37
+ * carries, and a `not_applicable`/`unknown` metric carries none.
38
+ *
39
+ * @param {string} label The metric's display name.
40
+ * @param {{verdict: string, value?: number, note?: string}} metric
41
+ * @returns {string}
42
+ */
43
+ export function formatMetricLine(label, metric) {
44
+ const value = metric.value === undefined ? "" : ` ${metric.value}`;
45
+ const note = metric.note ? ` (${metric.note})` : "";
46
+ return ` ${metric.verdict.padEnd(16)}${label}${value}${note}`;
47
+ }
48
+
49
+ /**
50
+ * The fixed metric order: which key of the metrics object each line renders,
51
+ * and under what label. Fixed is the point — two runs over an unchanged tree
52
+ * produce byte-identical reports only if nothing decides this order at render
53
+ * time.
54
+ *
55
+ * @type {readonly {readonly key: string, readonly label: string}[]}
56
+ */
57
+ export const HEALTH_METRIC_ORDER = Object.freeze([
58
+ Object.freeze({ key: "projects", label: "projects" }),
59
+ Object.freeze({ key: "edges", label: "edges" }),
60
+ Object.freeze({ key: "coverage", label: "coverage" }),
61
+ Object.freeze({ key: "violations", label: "violations" }),
62
+ Object.freeze({ key: "waiverSurface", label: "waiver surface" }),
63
+ Object.freeze({ key: "cycles", label: "cycles" }),
64
+ Object.freeze({ key: "edgeDensity", label: "edge density" }),
65
+ Object.freeze({ key: "debt", label: "debt rows" }),
66
+ Object.freeze({ key: "fitness", label: "intent fitness" }),
67
+ ]);
68
+
69
+ /**
70
+ * The coverage headline: what the run inspected, and — when it could not
71
+ * inspect everything — that the metrics which needed the missing evidence read
72
+ * `unknown` rather than zero.
73
+ *
74
+ * `subject` names the command the headline belongs to, so `report` states its
75
+ * own coverage in the same words `health` does without a second copy of them.
76
+ *
77
+ * @param {{complete: boolean, imports: number, analyzedFiles: number,
78
+ * projects: number, notAnalyzed: object[]}} coverage
79
+ * @param {string} [subject]
80
+ * @returns {string}
81
+ */
82
+ export function formatCoverageHeadline(coverage, subject = "health") {
83
+ const inspected =
84
+ `${coverage.imports} import${coverage.imports === 1 ? "" : "s"} in ` +
85
+ `${coverage.analyzedFiles} file${coverage.analyzedFiles === 1 ? "" : "s"} across ` +
86
+ `${coverage.projects} project${coverage.projects === 1 ? "" : "s"}`;
87
+
88
+ if (coverage.complete) return `✔ ${subject} over complete coverage (${inspected})`;
89
+
90
+ const notAnalyzedCount = coverage.notAnalyzed.length;
91
+ return (
92
+ `✖ ${subject} over incomplete coverage — ${notAnalyzedCount} file${notAnalyzedCount === 1 ? "" : "s"} ` +
93
+ `could not be analyzed, so the metrics that needed them read unknown (${inspected})`
94
+ );
95
+ }
96
+
97
+ /**
98
+ * The whole health report.
99
+ *
100
+ * @param {{metrics: object, trends: object|null, coverage: object}} input
101
+ * @returns {string}
102
+ */
103
+ export function formatHealthReport({ metrics, trends, coverage }) {
104
+ const sections = [formatCoverageHeadline(coverage, "health")];
105
+
106
+ for (const { key, label } of HEALTH_METRIC_ORDER) {
107
+ sections.push(formatMetricLine(label, metrics[key]));
108
+ }
109
+
110
+ if (trends) {
111
+ sections.push(
112
+ `trends ${trends.snapshots.length} snapshot${trends.snapshots.length === 1 ? "" : "s"}`,
113
+ );
114
+ for (const snapshot of trends.snapshots) {
115
+ sections.push(
116
+ ` ${snapshot.name} ${snapshot.projects} projects, ${snapshot.dependencies} edges`,
117
+ );
118
+ }
119
+ for (const note of trends.notes) sections.push(` ${note}`);
120
+ }
121
+
122
+ return sections.join("\n");
123
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * The terminal report for the `history` command: snapshots in history order,
3
+ * each transition classified, and what the whole record can and cannot say.
4
+ *
5
+ * The summary line states what the record is a claim about — how many
6
+ * snapshots, how many transitions, and the shape of the history the directory
7
+ * actually holds. Counts end every section, so a reader is never left deciding
8
+ * whether an omission is content or silence.
9
+ *
10
+ * This module decides nothing. A formatter that filtered would be a rule
11
+ * wearing a formatter's name (`../README.md`).
12
+ */
13
+
14
+ /**
15
+ * Neutralises control and terminal-escape sequences in a name or value before
16
+ * it is printed, so a crafted project/tag/edge name cannot inject escape
17
+ * sequences into a consumer's terminal (`SECURITY.md`). Real project names are
18
+ * ordinary characters and pass through untouched; only C0 control characters
19
+ * (which includes the ESC byte) and DEL become visible escapes.
20
+ *
21
+ * @param {string} text
22
+ * @returns {string}
23
+ */
24
+ function sanitize(text) {
25
+ // eslint-disable-next-line no-control-regex
26
+ return String(text).replace(/[\x00-\x1F\x7F]/g, (c) => {
27
+ if (c === "\n") return "\\n";
28
+ if (c === "\t") return "\\t";
29
+ if (c === "\r") return "\\r";
30
+ return `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`;
31
+ });
32
+ }
33
+
34
+ /**
35
+ * One project as a line, same shape as `graph-text.mjs`.
36
+ *
37
+ * @param {{name: string, root: string, tags: string[]}} project
38
+ * @returns {string}
39
+ */
40
+ function formatProject(project) {
41
+ const tags =
42
+ project.tags.length > 0 ? ` [${project.tags.map((t) => sanitize(t)).join(", ")}]` : "";
43
+ return ` ${sanitize(project.name)} ${sanitize(project.root)}${tags}`;
44
+ }
45
+
46
+ /**
47
+ * One edge as a line, same shape as `graph-text.mjs`.
48
+ *
49
+ * @param {{source: string, target: string, type: string}} edge
50
+ * @returns {string}
51
+ */
52
+ function formatEdge(edge) {
53
+ return ` ${sanitize(edge.source)} → ${sanitize(edge.target)} (${sanitize(edge.type)})`;
54
+ }
55
+
56
+ /**
57
+ * One metadata change as a line, same shape as `diff-text.mjs`.
58
+ *
59
+ * @param {{field: string, baseline: *, head: *}} change
60
+ * @returns {string}
61
+ */
62
+ function formatChange(change) {
63
+ const formatValue = (v) => {
64
+ if (Array.isArray(v)) return v.length > 0 ? v.map((x) => sanitize(x)).join(", ") : "(none)";
65
+ if (v === null || v === undefined) return "(none)";
66
+ return sanitize(String(v));
67
+ };
68
+ return ` ${change.field} ${formatValue(change.baseline)} → ${formatValue(change.head)}`;
69
+ }
70
+
71
+ /**
72
+ * How the architecture actually changed between two snapshots: the added and
73
+ * removed projects and edges rendered as one line each. Changed projects
74
+ * render their changed fields beneath the project line, like `diff`.
75
+ *
76
+ * @param {object} changes The `computeDiff` payload.
77
+ * @returns {string[]}
78
+ */
79
+ function formatChanges(changes) {
80
+ const lines = [];
81
+ if (changes.addedProjects.length > 0) {
82
+ const word = changes.addedProjects.length === 1 ? "project" : "projects";
83
+ lines.push(`+ ${changes.addedProjects.length} added ${word}`);
84
+ for (const project of changes.addedProjects) lines.push(formatProject(project));
85
+ }
86
+ if (changes.removedProjects.length > 0) {
87
+ const word = changes.removedProjects.length === 1 ? "project" : "projects";
88
+ lines.push(`- ${changes.removedProjects.length} removed ${word}`);
89
+ for (const project of changes.removedProjects) lines.push(formatProject(project));
90
+ }
91
+ if (changes.changedProjects.length > 0) {
92
+ const word = changes.changedProjects.length === 1 ? "project" : "projects";
93
+ lines.push(`~ ${changes.changedProjects.length} changed ${word}`);
94
+ for (const project of changes.changedProjects) {
95
+ lines.push(` ${project.name}`);
96
+ for (const change of project.changes) lines.push(formatChange(change));
97
+ }
98
+ }
99
+ if (changes.addedEdges.length > 0) {
100
+ const word = changes.addedEdges.length === 1 ? "edge" : "edges";
101
+ lines.push(`+ ${changes.addedEdges.length} added ${word}`);
102
+ for (const edge of changes.addedEdges) lines.push(formatEdge(edge));
103
+ }
104
+ if (changes.removedEdges.length > 0) {
105
+ const word = changes.removedEdges.length === 1 ? "edge" : "edges";
106
+ lines.push(`- ${changes.removedEdges.length} removed ${word}`);
107
+ for (const edge of changes.removedEdges) lines.push(formatEdge(edge));
108
+ }
109
+ return lines;
110
+ }
111
+
112
+ /**
113
+ * Classifies one transition into the short "kind" a reader skims for.
114
+ *
115
+ * @param {{architectureChanged: boolean, codeDrift: boolean, policyChanged: boolean|null,
116
+ * providerChanged: boolean}} transition
117
+ * @returns {string}
118
+ */
119
+ function transitionKind(transition) {
120
+ if (transition.architectureChanged) return "architecture";
121
+ if (transition.providerChanged) return "provider";
122
+ if (transition.policyChanged === true) return "policy";
123
+ if (transition.codeDrift) return "code drift";
124
+ return "unchanged";
125
+ }
126
+
127
+ /**
128
+ * The whole history report.
129
+ *
130
+ * @param {{evolution: {dir: string, captured: object|null,
131
+ * snapshots: {name: string, id: string}[],
132
+ * transitions: {from: string, to: string, architectureChanged: boolean,
133
+ * changes: object|null, policyChanged: boolean|null, providerChanged: boolean,
134
+ * codeDrift: boolean, notes: string[]}[]}, coverage: object}} input
135
+ * @returns {string}
136
+ */
137
+ export function formatHistoryReport({ evolution, coverage }) {
138
+ const sections = [];
139
+
140
+ const transitionWord = evolution.transitions.length === 1 ? "transition" : "transitions";
141
+ const snapshotWord = evolution.snapshots.length === 1 ? "snapshot" : "snapshots";
142
+ const inspected = `${coverage.imports} import${
143
+ coverage.imports === 1 ? "" : "s"
144
+ } in ${coverage.analyzedFiles} file${
145
+ coverage.analyzedFiles === 1 ? "" : "s"
146
+ } across ${coverage.projects} project${coverage.projects === 1 ? "" : "s"}`;
147
+
148
+ sections.push(`history ${evolution.dir}`);
149
+ sections.push(
150
+ `${evolution.snapshots.length} ${snapshotWord}, ${evolution.transitions.length} ${transitionWord} (${inspected})`,
151
+ );
152
+
153
+ if (evolution.captured) {
154
+ sections.push(
155
+ evolution.captured.duplicate
156
+ ? `capture ${evolution.captured.name} — already the last snapshot, no new file written`
157
+ : `capture ${evolution.captured.name} written`,
158
+ );
159
+ }
160
+
161
+ for (const [i, snapshot] of evolution.snapshots.entries()) {
162
+ sections.push(`${i} ${snapshot.name} ${snapshot.id.slice(0, 8)}`);
163
+ }
164
+
165
+ // The footer counts true architectural change only — a transition classified
166
+ // as policy or provider is a change to the record's interpretation, not to
167
+ // the architecture, so a policy-only history must not read as "N transitions
168
+ // recorded an architectural change".
169
+ let changed = 0;
170
+ for (const transition of evolution.transitions) {
171
+ if (transition.architectureChanged) changed += 1;
172
+ const kind = transitionKind(transition);
173
+ sections.push(`~ ${transition.from} → ${transition.to} (${kind})`);
174
+ if (transition.changes) {
175
+ for (const line of formatChanges(transition.changes)) sections.push(` ${line}`);
176
+ }
177
+ for (const note of transition.notes) {
178
+ sections.push(` ${note}`);
179
+ }
180
+ }
181
+
182
+ if (evolution.transitions.length === 0) {
183
+ // A single snapshot is a claim about a history of length one — it says so
184
+ // rather than reporting "no changes", which would be ambiguous.
185
+ sections.push(
186
+ "✔ one snapshot, no transitions yet — capture again after an architectural change",
187
+ );
188
+ } else if (changed === 0) {
189
+ const anySignal = evolution.transitions.some(
190
+ (t) => t.policyChanged === true || t.providerChanged || t.codeDrift,
191
+ );
192
+ sections.push(
193
+ anySignal
194
+ ? "✔ no architectural change recorded across the snapshots (only policy, provider, or drift signals)"
195
+ : "✔ no architectural change recorded across the snapshots",
196
+ );
197
+ } else {
198
+ sections.push(
199
+ `${changed} transition${changed === 1 ? "" : "s"} recorded an architectural change`,
200
+ );
201
+ }
202
+
203
+ return sections.join("\n");
204
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The terminal report for the `impact` command: reverse reachability from the
3
+ * project graph.
4
+ *
5
+ * Each dependent project is listed as a single line, grouped under the target
6
+ * project. A project with no dependents renders the header and a "0" count
7
+ * rather than an omitted section, because "nothing depends on this" and "the
8
+ * renderer forgot" look identical otherwise — the same reasoning as
9
+ * `./graph-text.mjs`'s `(no dependencies)` line.
10
+ *
11
+ * When the impact carries a `constraintImpact` field (computed when a boundary
12
+ * config was provided), a constraint-context section follows the dependent
13
+ * listing: each dependent is shown with a ✔ or ✖ marker, the constraint rows
14
+ * that govern its edge, and any violations. When there are no dependents, the
15
+ * section states that explicitly. This section is absent when no config was
16
+ * given, so an impact without `--config` is unchanged from its prior output.
17
+ *
18
+ * The coverage claim sits ABOVE the listing, not below it, so the reader knows
19
+ * whether the result is complete before reading any entry — an incomplete
20
+ * impact set printed in full would have the "this may under-represent" warning
21
+ * buried at the bottom.
22
+ *
23
+ * This module decides nothing. A formatter that filtered would be a rule
24
+ * wearing a formatter's name (`../README.md`).
25
+ */
26
+
27
+ /**
28
+ * The constraint context for one dependent, as text.
29
+ *
30
+ * @param {{project: string, edges: object[], constraintRows: object[], violations: object[]}} entry
31
+ * @returns {string}
32
+ */
33
+ function formatConstraintEntry(entry) {
34
+ const lines = [];
35
+
36
+ if (entry.constraintRows.length === 0) {
37
+ lines.push(` (no matching constraint rows — untagged or unmatched tags)`);
38
+ } else {
39
+ for (const row of entry.constraintRows) {
40
+ const tag = row.sourceTag ?? row.allSourceTags?.join("+") ?? "?";
41
+ const parts = [];
42
+ if (row.onlyDependOnLibsWithTags)
43
+ parts.push(`only [${row.onlyDependOnLibsWithTags.join(", ")}]`);
44
+ if (row.notDependOnLibsWithTags)
45
+ parts.push(`not [${row.notDependOnLibsWithTags.join(", ")}]`);
46
+ lines.push(` [${tag}] ${parts.join(", ")}`);
47
+ if (row.description) lines.push(` description ${row.description}`);
48
+ if (row.remediation) lines.push(` remediation ${row.remediation}`);
49
+ }
50
+ }
51
+
52
+ for (const v of entry.violations) {
53
+ lines.push(` ✖ ${v.source} → ${v.target} ${v.messageId}`);
54
+ }
55
+
56
+ return lines.join("\n");
57
+ }
58
+
59
+ /**
60
+ * The whole impact report.
61
+ *
62
+ * @param {{impact: {project: string, direct: string[], transitive: string[],
63
+ * dependents: string[], constraintImpact?: object[]}, coverage: object}} input
64
+ * @returns {string}
65
+ */
66
+ export function formatImpactReport({ impact, coverage }) {
67
+ const sections = [];
68
+
69
+ // Coverage claim goes FIRST — above the listing — so the reader knows
70
+ // whether the impact set is complete before reading any entry.
71
+ const inspected =
72
+ `${coverage.imports} import${coverage.imports === 1 ? "" : "s"} in ` +
73
+ `${coverage.analyzedFiles} file${coverage.analyzedFiles === 1 ? "" : "s"} across ` +
74
+ `${coverage.projects} project${coverage.projects === 1 ? "" : "s"}`;
75
+
76
+ if (coverage.complete) {
77
+ sections.push(`✔ impact complete (${inspected})`);
78
+ } else {
79
+ const notAnalyzedCount = coverage.notAnalyzed.length;
80
+ sections.push(
81
+ `✖ impact incomplete — ${notAnalyzedCount} file${notAnalyzedCount === 1 ? "" : "s"} ` +
82
+ `could not be analyzed, so this impact set may under-represent the real architecture (${inspected})`,
83
+ );
84
+ }
85
+
86
+ // Header names the target project
87
+ sections.push(`Impact of ${impact.project}`);
88
+
89
+ // List dependents — each on its own line
90
+ if (impact.dependents.length > 0) {
91
+ for (const name of impact.dependents) {
92
+ sections.push(` ${name}`);
93
+ }
94
+ }
95
+
96
+ // Constraint-impact section: when the boundary config was provided, show
97
+ // which constraint rows govern each dependent's edge and whether it violates.
98
+ // The section appears whenever `constraintImpact` is present — even when
99
+ // empty (no dependents) — so a reader can always tell "config provided, no
100
+ // dependents to judge" from "no config".
101
+ if (impact.constraintImpact) {
102
+ sections.push("Constraint context");
103
+ if (impact.constraintImpact.length === 0) {
104
+ sections.push(" (no dependents to judge against constraint table)");
105
+ }
106
+ for (const entry of impact.constraintImpact) {
107
+ const hasViolations = entry.violations.length > 0;
108
+ const marker = hasViolations ? "✖" : "✔";
109
+ sections.push(` ${marker} ${entry.project}`);
110
+ sections.push(formatConstraintEntry(entry));
111
+ }
112
+ }
113
+
114
+ // Summary line: total, with direct and transitive broken out.
115
+ // A project with 0 dependents states "0 project(s) depend on" — an
116
+ // explicit claim, never silence.
117
+ const total = impact.dependents.length;
118
+ const directCount = impact.direct.length;
119
+ const transitiveCount = impact.transitive.length;
120
+
121
+ const projectWord = total === 1 ? "project" : "projects";
122
+ const verbWord = total === 1 ? "depends" : "depend";
123
+ sections.push(
124
+ `${total} ${projectWord} ${verbWord} on ${impact.project} (direct: ${directCount}, transitive: ${transitiveCount})`,
125
+ );
126
+
127
+ return sections.join("\n");
128
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The versioned JSON envelope every command's `--format json` wraps its
3
+ * result in — one wrapper, six commands, so a consumer writes one parser
4
+ * rather than one per command. `../../../../docs/reference/json-output.md` is the
5
+ * published contract this module builds; this file is where the contract's
6
+ * three consistency rules are enforced in code rather than left to a
7
+ * docs page a later command author might not read.
8
+ *
9
+ * **This module knows no command's payload.** `jsonEnvelope` takes `result` as
10
+ * an opaque object and writes it through unchanged — `../../cli.mjs`'s `check`
11
+ * builds its own `result.violations`/`result.goWork`/`result.tsconfigPaths`,
12
+ * and a later command builds its own. Payload shape lives with the command
13
+ * that produces it, which is what keeps this file untouched by every command
14
+ * after the first (`../commands/README.md` states the same split one layer
15
+ * down).
16
+ *
17
+ * The three assertions below are the empty-result invariant
18
+ * (`../../../../AGENTS.md`) in executable form, not merely documented: a caller
19
+ * that got one of them wrong would otherwise ship a JSON file claiming a clean
20
+ * run over a tree this tool never finished reading, and nothing downstream
21
+ * would ever see the mistake. They throw rather than degrade on purpose — a
22
+ * mismatch here is a bug in the command that built the envelope, not a fact
23
+ * about the workspace being judged, so it gets a programming error rather than
24
+ * a diagnostic.
25
+ *
26
+ * The optional `decision` field rides the same posture. It carries the
27
+ * canonical 4-state verdict (`../governance/verdict.mjs`) that every
28
+ * governance capability emits and consumes, and its `verdict` must agree
29
+ * with the envelope's `status` the same way `status` and `exitCode` must:
30
+ * `"ok"` implies `"pass"`, `"findings"` implies `"fail"`, `"no-verdict"`
31
+ * implies `"unknown"`. A decision that contradicts its status would make one
32
+ * of them a lie — the exact class of disagreement this module refuses to
33
+ * ship. The same refusal covers the invariant the uncertain verdict carries:
34
+ * an `"unknown"` decision must name the reason no verdict was reached, so a
35
+ * hand-built decision cannot slip a reason-less "no verdict" past the last
36
+ * boundary it crosses. The field is OPTIONAL (absent on every envelope that
37
+ * does not opt in), which is what keeps SCHEMA_VERSION at 2: additive,
38
+ * byte-compatible with every consumer that reads the envelope today.
39
+ */
40
+ import { verdictForStatus } from "../governance/verdict.mjs";
41
+ import { createRequire } from "node:module";
42
+
43
+ const require = createRequire(import.meta.url);
44
+ /** @type {{name: string, version: string}} */
45
+ const { name: TOOL_NAME, version: TOOL_VERSION } = require("../../package.json");
46
+
47
+ /**
48
+ * The schema version this build writes. An integer, per
49
+ * `docs/reference/json-output.md`'s stability promise: it only moves for a
50
+ * breaking change to the envelope, and a consumer that reads a
51
+ * `schemaVersion` it does not recognise should refuse to parse the rest of
52
+ * the envelope rather than guess — an unrecognised version is a caller
53
+ * reading a contract from the future, not a workspace fact. Nothing in this
54
+ * package reads its own envelope back, so that refusal is advice to the
55
+ * consumer, not a mechanism enforced here.
56
+ */
57
+ export const SCHEMA_VERSION = 2;
58
+
59
+ /** The one `status`↔`exitCode` mapping every command's envelope must agree with. */
60
+ const EXIT_CODE_FOR_STATUS = Object.freeze({ ok: 0, findings: 1, "no-verdict": 3 });
61
+
62
+ /**
63
+ * Builds the envelope, asserting the three invariants that keep it from ever
64
+ * claiming more than a run actually established.
65
+ *
66
+ * @param {{command: string, context: {root: string, provider: "nx"|"native"|"moon", marker: string,
67
+ * provenance?: {commit: string, remote: string|null, dirty: boolean}|null},
68
+ * status: "ok"|"findings"|"no-verdict", exitCode: 0|1|3,
69
+ * coverage: {complete: boolean, projects: number, analyzedFiles: number, imports: number,
70
+ * notAnalyzed: object[], blindSpots: object[], notes: string[], coverageGaps?: object[]},
71
+ * result: object,
72
+ * decision?: {verdict: string, reason?: string, notApplicableReason?: string,
73
+ * sampleTime?: string}}} run
74
+ * @returns {object} The envelope `docs/reference/json-output.md` documents.
75
+ * @throws {Error} when `status` claims `"ok"` over incomplete coverage, when
76
+ * `status` and `exitCode` disagree, when `coverage.complete` disagrees
77
+ * with whether `coverage.notAnalyzed` is empty, or when the optional
78
+ * `decision.verdict` contradicts the envelope's `status`.
79
+ */
80
+ export function jsonEnvelope({ command, context, status, exitCode, coverage, result, decision }) {
81
+ if (status === "ok" && coverage.complete !== true) {
82
+ throw new Error(
83
+ `archkeep: refusing to build a JSON envelope claiming status "ok" over incomplete coverage ` +
84
+ `(coverage.complete: false) — a caller cannot report a clean run over a tree it could not ` +
85
+ `fully read. This is a bug in the command that built this envelope, not a fact about the ` +
86
+ `workspace being judged.`,
87
+ );
88
+ }
89
+ if (EXIT_CODE_FOR_STATUS[status] !== exitCode) {
90
+ throw new Error(
91
+ `archkeep: refusing to build a JSON envelope where status "${status}" and exitCode ${exitCode} ` +
92
+ `disagree — status "${status}" must carry exitCode ${EXIT_CODE_FOR_STATUS[status]}. A ` +
93
+ `consumer reading a file written by --output has only these two fields to trust; letting ` +
94
+ `them disagree would make one of them a lie.`,
95
+ );
96
+ }
97
+ if (coverage.complete !== (coverage.notAnalyzed.length === 0)) {
98
+ throw new Error(
99
+ `archkeep: refusing to build a JSON envelope where coverage.complete (${coverage.complete}) ` +
100
+ `disagrees with coverage.notAnalyzed (${coverage.notAnalyzed.length} entr${coverage.notAnalyzed.length === 1 ? "y" : "ies"}) ` +
101
+ `— the two must always agree, or a reader checking only one of them could mistake a partial ` +
102
+ `run for a complete one.`,
103
+ );
104
+ }
105
+ // A bare `null` decision is the same programming error as a hand-built
106
+ // decision that contradicts its status: refuse it with the named message
107
+ // rather than crash on `decision.verdict` with a raw TypeError.
108
+ if (decision !== undefined && (decision === null || typeof decision !== "object")) {
109
+ throw new Error(
110
+ `archkeep: refusing to build a JSON envelope where decision is ${decision === null ? "null" : `a ${typeof decision}`} ` +
111
+ `rather than an object — a decision must carry a verdict that agrees with its status. ` +
112
+ `This is a bug in the command that built the envelope.`,
113
+ );
114
+ }
115
+ if (decision != null) {
116
+ const implied = verdictForStatus(status);
117
+ if (decision.verdict !== implied) {
118
+ throw new Error(
119
+ `archkeep: refusing to build a JSON envelope where decision.verdict "${decision.verdict}" ` +
120
+ `contradicts status "${status}" — status implies ${implied}, and a ` +
121
+ `decision that disagrees with its own status would make one of the two a lie. ` +
122
+ `This is a bug in the command that built the envelope.`,
123
+ );
124
+ }
125
+ // I3, enforced again at this boundary — `src/governance/evidence.mjs`
126
+ // already guarantees it for the engine path, but a hand-built decision
127
+ // would otherwise ship an "unknown" without the reason I3 requires and
128
+ // only this boundary would ever see the mistake. I2 (findings on "fail")
129
+ // gets no latch here for the same reason I4 gets none: the other
130
+ // invariants already pin every route a "fail" can take — `verdictForStatus`
131
+ // only returns "fail" for a "findings" status, and this module knows no
132
+ // command's payload, so counting findings would mean reaching into
133
+ // `result`. A hand-built "fail" with no findings is still a loud lie
134
+ // (status "findings", exitCode 1, verdict "fail"), never the silent
135
+ // direction.
136
+ if (decision.verdict === "unknown" && decision.reason === undefined) {
137
+ throw new Error(
138
+ `archkeep: refusing to build a JSON envelope where an "unknown" decision has no reason — ` +
139
+ `I3: an unknown verdict must say why no verdict was reached, or it reads as a shrug. ` +
140
+ `This is a bug in the command that built the envelope.`,
141
+ );
142
+ }
143
+ }
144
+
145
+ return {
146
+ schemaVersion: SCHEMA_VERSION,
147
+ tool: { name: TOOL_NAME, version: TOOL_VERSION },
148
+ command,
149
+ workspace: {
150
+ root: context.root,
151
+ provider: context.provider,
152
+ marker: context.marker,
153
+ provenance: context.provenance ?? null,
154
+ },
155
+ status,
156
+ exitCode,
157
+ coverage,
158
+ result,
159
+ ...(decision == null ? {} : { decision }),
160
+ };
161
+ }
162
+
163
+ /**
164
+ * The envelope as bytes: two-space indent and a trailing newline, so a file
165
+ * written by `--output` reads the same as every other file this tool writes
166
+ * and diffs cleanly in a pull request.
167
+ *
168
+ * @param {object} envelope From `jsonEnvelope`.
169
+ * @returns {string}
170
+ */
171
+ export function renderJson(envelope) {
172
+ return `${JSON.stringify(envelope, null, 2)}\n`;
173
+ }