@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,226 @@
1
+ /**
2
+ * The `impact` command: reverse reachability from the project graph.
3
+ *
4
+ * Given a project name, `impact` lists every project that transitively depends
5
+ * on it — the set a developer needs to consider before changing that project.
6
+ * It is descriptive: it never exits 1, because a description of what depends
7
+ * on a project is never a finding.
8
+ *
9
+ * The result separates **direct** dependents (projects whose edges point
10
+ * straight at the target) from **transitive** ones (reachable only through
11
+ * another project), and the union of both is `dependents`. An empty
12
+ * `dependents` list is a claim — "nothing depends on this project" — not a
13
+ * shrug, and it is worded that way in the report so a reader never mistakes it
14
+ * for silence.
15
+ *
16
+ * When a boundary config is provided (via `--config` or the workspace's own
17
+ * declaration), `impact` also returns the **constraint context** for each
18
+ * dependent: which `depConstraints` rows govern that edge and whether it
19
+ * currently violates them. This is narrower than `check` — it checks only
20
+ * `depConstraints` (tag-based), not npm/circular/lazy-load rules that need
21
+ * import-site details. A consumer who needs the complete verdict should run
22
+ * `check`.
23
+ *
24
+ * What it needs from its caller is a `CommandContext` — the preamble every
25
+ * command shares (`./context.mjs`). What it gives back is a `status`, the
26
+ * payload for both the text and the JSON renderers, and enough coverage
27
+ * information to build a correct envelope. It does not print, and it does not
28
+ * decide the process's exit code — `../../cli.mjs` owns those
29
+ * (`./README.md`).
30
+ *
31
+ * ## The unregistered-plugin refusal
32
+ *
33
+ * Same as `graph` and `diff`: on an Nx workspace whose `nx.json` does not
34
+ * register this plugin but whose tracked files include polyglot manifests
35
+ * under project roots, `impact` refuses loudly rather than returning a result
36
+ * whose dependents silently under-represent the real architecture.
37
+ */
38
+ import { isWholeFileFailure } from "../analysis/source-util.mjs";
39
+ import { UsageError } from "../errors.mjs";
40
+ import { computeImpactConstraints } from "./edge-constraints.mjs";
41
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
42
+ import { formatImpactReport } from "../report/impact-text.mjs";
43
+ import { resolveProvenance } from "./provenance.mjs";
44
+
45
+ /**
46
+ * Computes the impact set: every project that transitively depends on
47
+ * `projectName`.
48
+ *
49
+ * Builds a reverse adjacency map from the graph's `dependencies`, then walks
50
+ * it breadth-first starting from the target project. The walk does NOT include
51
+ * the target project itself in the dependent set — a project does not depend
52
+ * on itself — but the returned `dependents` array is the union of `direct`
53
+ * and `transitive`, and the report header names the target separately.
54
+ *
55
+ * @param {string} projectName The project whose impact is being queried.
56
+ * @param {object} graph The project graph: `{nodes, dependencies}`.
57
+ * @returns {{project: string, direct: string[], transitive: string[], dependents: string[]}}
58
+ * @throws {UsageError} when `projectName` is not in the graph.
59
+ */
60
+ export function computeImpact(projectName, graph) {
61
+ const nodes = graph.nodes;
62
+ const deps = graph.dependencies;
63
+
64
+ if (!Object.hasOwn(nodes, projectName)) {
65
+ throw new UsageError(
66
+ `archkeep: no project named '${projectName}' in the graph — ` +
67
+ `available projects: ${Object.keys(nodes)
68
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
69
+ .join(", ")}`,
70
+ );
71
+ }
72
+
73
+ // Build reverse adjacency: target → [sources that depend on it]
74
+ const reverseAdj = Object.create(null);
75
+ for (const source of Object.keys(deps)) {
76
+ if (!Object.hasOwn(deps, source)) continue;
77
+ const targets = deps[source];
78
+ for (const edge of targets) {
79
+ if (!Object.hasOwn(reverseAdj, edge.target)) {
80
+ reverseAdj[edge.target] = [];
81
+ }
82
+ reverseAdj[edge.target].push(source);
83
+ }
84
+ }
85
+
86
+ // Direct dependents: projects whose edges point straight at the target.
87
+ // Deduplicate (multiple edges between same pair are possible) and sort.
88
+ const directSet = new Set(reverseAdj[projectName] ?? []);
89
+ const direct = [...directSet].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
90
+
91
+ // BFS through reverse edges to find transitive dependents.
92
+ const visited = new Set(directSet);
93
+ const queue = [...directSet];
94
+ while (queue.length > 0) {
95
+ const current = queue.shift();
96
+ const parents = reverseAdj[current];
97
+ if (parents === undefined) continue;
98
+ for (const parent of parents) {
99
+ if (!visited.has(parent)) {
100
+ visited.add(parent);
101
+ queue.push(parent);
102
+ }
103
+ }
104
+ }
105
+
106
+ // Transitive dependents: reachable through another project, but not direct.
107
+ const transitive = [...visited]
108
+ .filter((name) => !directSet.has(name))
109
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
110
+
111
+ // All dependents: direct + transitive, sorted.
112
+ const dependents = [...visited].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
113
+
114
+ return { project: projectName, direct, transitive, dependents };
115
+ }
116
+
117
+ /**
118
+ * Runs the `impact` command: resolves the command context, checks the
119
+ * unregistered-plugin condition, and computes the impact set.
120
+ *
121
+ * @param {string} projectName The project whose dependents to list.
122
+ * @param {object} commandContext From `resolveCommandContext`.
123
+ * @param {object} [config] The loaded boundary config. When provided,
124
+ * constraint context and violations for each dependent edge are computed.
125
+ * @returns {{status: "ok"|"no-verdict", impact: object, coverage: object,
126
+ * report: {text: string, json: string}}}
127
+ * @throws {Error} when an Nx workspace has polyglot manifests but the plugin
128
+ * is not registered, or when the named project does not exist in the graph,
129
+ * or when the graph has incomplete coverage.
130
+ */
131
+ export function impactCommand(projectName, commandContext, config = null) {
132
+ const { root, provider, marker, graph, pluginGap } = commandContext;
133
+
134
+ // Descriptive commands refuse when the graph is known to be incomplete.
135
+ if (provider === "nx" && !pluginGap.registered && pluginGap.manifests.length > 0) {
136
+ throw new Error(
137
+ `archkeep: refusing to compute impact for an Nx workspace where this plugin is ` +
138
+ `not registered but polyglot manifests exist under project roots ` +
139
+ `(${pluginGap.manifests.join(", ")}). The graph would carry no polyglot edges, ` +
140
+ `so the impact set would silently under-represent the real architecture. ` +
141
+ `Register the plugin in nx.json: ` +
142
+ `"plugins": [{ "plugin": "@ecoma-io/archkeep/nx" }], or remove the polyglot manifests ` +
143
+ `if they are not in use.`,
144
+ );
145
+ }
146
+
147
+ // Validate the project exists before looking at coverage — a non-existent
148
+ // project is a caller error, not a workspace fact, and it should name the
149
+ // project before the run invests in anything else.
150
+ const impact = computeImpact(projectName, graph);
151
+
152
+ const notAnalyzed = commandContext.analysis.failures
153
+ .filter(isWholeFileFailure)
154
+ .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
155
+
156
+ if (notAnalyzed.length > 0) {
157
+ throw new Error(
158
+ `archkeep: the graph has incomplete coverage — ${notAnalyzed.length} file` +
159
+ `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so the impact set may ` +
160
+ `under-represent the real architecture. Fix the unanalyzed files and re-run.`,
161
+ );
162
+ }
163
+
164
+ const complete = true;
165
+ const status = "ok";
166
+ const exitCode = 0;
167
+
168
+ const coverage = {
169
+ complete,
170
+ projects: Object.keys(graph.nodes).length,
171
+ analyzedFiles: commandContext.analysis.analyzed,
172
+ imports: commandContext.analysis.imports.length,
173
+ notAnalyzed: [],
174
+ blindSpots: commandContext.analysis.failures
175
+ .filter((f) => !isWholeFileFailure(f))
176
+ .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
177
+ notes: [
178
+ "per-edge violations cover only depConstraints (3 of 15 violation types). " +
179
+ "A dependent with no violations here may still violate npm-ban, circular-dependency, " +
180
+ "lazy-load, or other rules that require import-site details. Run `check` for the " +
181
+ "complete verdict.",
182
+ ],
183
+ };
184
+
185
+ const context = { root, provider, marker, provenance: resolveProvenance(root) };
186
+ const result = {
187
+ project: impact.project,
188
+ direct: impact.direct,
189
+ transitive: impact.transitive,
190
+ dependents: impact.dependents,
191
+ };
192
+
193
+ // Rule-impact analysis: when the boundary config is available, identify
194
+ // which constraint rows govern each dependent's edge to the target and
195
+ // whether the edge currently violates those rows. This is narrower than
196
+ // the full rule engine: it covers `depConstraints`, not checks that need
197
+ // import-site details such as npm bans or lazy loading.
198
+ if (config && config.depConstraints) {
199
+ result.constraintImpact = computeImpactConstraints(
200
+ projectName,
201
+ impact.dependents,
202
+ graph.nodes,
203
+ graph.dependencies,
204
+ config.depConstraints,
205
+ );
206
+ }
207
+
208
+ const envelope = jsonEnvelope({
209
+ command: "impact",
210
+ context,
211
+ status,
212
+ exitCode,
213
+ coverage,
214
+ result,
215
+ });
216
+
217
+ return {
218
+ status,
219
+ impact: result,
220
+ coverage,
221
+ report: {
222
+ text: formatImpactReport({ impact: result, coverage }),
223
+ json: renderJson(envelope),
224
+ },
225
+ };
226
+ }