@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 `reconcile` command: the declared intended model compared against the
3
+ * observed architecture — the two-sided mirror of `drift`.
4
+ *
5
+ * Drift asks one question: which intended rows does reality violate? Reconcile
6
+ * asks the other: element by element, what does the model say about reality,
7
+ * and what would it take to make the two agree? It is READ-ONLY by design.
8
+ * `--propose` emits a RANKED CANDIDATE LIST of model edits — add-only,
9
+ * removal, tag-change, boundary-change — each marked as a proposal with the
10
+ * evidence that supports it and an explicit `proposed: true` /
11
+ * `notAuthoritative` marker. The feature never writes back into
12
+ * `architecture-intent.json`: writing back is a manual, reviewable step the
13
+ * operator performs, and authority stays with the intentional human or agent.
14
+ *
15
+ * ## Two faces, one verdict
16
+ *
17
+ * Like `drift`, `reconcile` is descriptive: it completes with status `"ok"` /
18
+ * exit 0 whether the model matches reality or diverges from it, and it never
19
+ * exits 1: divergence is described, never gated. The divergence lives in the result's scored
20
+ * elements and, with `--propose`, in the ranked candidate list — both of which
21
+ * a reader acts on deliberately rather than a CI gate failing over.
22
+ *
23
+ * ## Fail-closed — the empty-result invariant's reconcile face
24
+ *
25
+ * This command refuses loudly on every path that cannot reach a verdict,
26
+ * the same four refusals `drift` makes:
27
+ *
28
+ * - the intent file cannot be read or parsed (strict JSON, validated) → exit 3;
29
+ * - the observed side is incomplete (whole-file analysis failures) → exit 3 —
30
+ * every `absent` score would be ambiguous between "gone" and "never seen";
31
+ * - an Nx workspace has polyglot manifests but the plugin is not registered →
32
+ * exit 3, the same refusal `graph`/`diff`/`drift` make;
33
+ * - a boundary or row side matched no observed project → exit 3, because a
34
+ * score over an unresolved row would claim a verdict the judge never reached.
35
+ *
36
+ * An `unknown` score is only ever produced by a whole-file failure the command
37
+ * already refused on — but `reconcileScores` marks it regardless, so the
38
+ * scoring module can never render a partial read as a claim on its own.
39
+ *
40
+ * ## Determinism
41
+ *
42
+ * Every scored element and every candidate is keyed and sorted by plain string
43
+ * comparison (never `localeCompare`), so two runs over an unchanged tree and
44
+ * intent produce byte-identical text and JSON.
45
+ */
46
+ import { isWholeFileFailure } from "../analysis/source-util.mjs";
47
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
48
+ import { resolveProvenance } from "./provenance.mjs";
49
+ import { judgeIntent } from "../architecture-intent/judge.mjs";
50
+ import { computeIntentFingerprint } from "../architecture-intent/intent-fingerprint.mjs";
51
+ import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
52
+ import { buildObserved, refuseIncompleteGraph } from "./drift.mjs";
53
+ import { reconcileScores } from "../governance/reconcile-score.mjs";
54
+ import { buildRankedCandidates } from "../governance/reconcile-candidates.mjs";
55
+ import { formatReconcileReport } from "../report/reconcile-text.mjs";
56
+
57
+ /**
58
+ * The number of intent rows the scored set is a claim about — the same count
59
+ * `drift` reports, so "no divergence" always reads as a claim about a specific
60
+ * contract.
61
+ *
62
+ * @param {object} intent The normalized intent model.
63
+ * @returns {number}
64
+ */
65
+ function intentRows(intent) {
66
+ return (
67
+ (intent.boundaries?.length ?? 0) +
68
+ (intent.allowed?.length ?? 0) +
69
+ (intent.forbidden?.length ?? 0) +
70
+ (intent.projects?.required?.length ?? 0) +
71
+ (intent.projects?.forbidden?.length ?? 0) +
72
+ (intent.dependencies?.allowed?.length ?? 0) +
73
+ (intent.dependencies?.forbidden?.length ?? 0) +
74
+ (intent.forbiddenTags?.length ?? 0)
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Runs the `reconcile` command: loads the intent, refuses incomplete coverage,
80
+ * scores every observed element and every intent row, and optionally emits a
81
+ * ranked candidate list of model edits — never writing back.
82
+ *
83
+ * @param {object} commandContext From `resolveCommandContext`.
84
+ * @param {{loadIntentOverride?: (root: string) => Promise<object>}} [io]
85
+ * Injectable intent loader for tests.
86
+ * @param {{propose?: boolean}} [options] `--propose` adds the ranked candidate list.
87
+ * @returns {Promise<{status: "ok", reconcile: object, coverage: object,
88
+ * report: {text: string, json: string}}>}
89
+ * @throws {Error} on every condition the header lists, all exit-3 class.
90
+ */
91
+ export async function reconcileCommand(commandContext, io = {}, options = {}) {
92
+ const { root, provider, marker, analysis } = commandContext;
93
+
94
+ refuseIncompleteGraph(commandContext);
95
+
96
+ // A reconcile verdict cannot be established over a tree it could not fully
97
+ // read — the same fail-closed condition `drift` enforces.
98
+ const notAnalyzed = analysis.failures
99
+ .filter(isWholeFileFailure)
100
+ .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
101
+
102
+ if (notAnalyzed.length > 0) {
103
+ throw new Error(
104
+ `archkeep: reconcile has incomplete coverage — ${notAnalyzed.length} file` +
105
+ `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so every "absent" score ` +
106
+ `would be ambiguous between "gone" and "never seen". Fix the unanalyzed files and re-run.`,
107
+ );
108
+ }
109
+
110
+ const intent = await (io.loadIntentOverride ?? loadIntent)(root, {
111
+ tracked: commandContext.tracked,
112
+ });
113
+ if (intent === undefined) {
114
+ throw new Error(
115
+ `archkeep: reconcile requires a tracked ${INTENT_FILE} at the workspace root, but none ` +
116
+ `is present — a workspace without an intended architecture cannot be reconciled`,
117
+ );
118
+ }
119
+ const observed = buildObserved(commandContext);
120
+ const verdict = judgeIntent(intent, {
121
+ nodes: commandContext.graph.nodes,
122
+ dependencies: commandContext.graph.dependencies,
123
+ });
124
+
125
+ // An intent whose boundary or row side matched no observed project reached no
126
+ // verdict on that row — a score over it would claim a comparison the judge
127
+ // never made. Refuse loudly, exactly as `drift` does.
128
+ if (verdict.unresolved.length > 0) {
129
+ throw new Error(
130
+ `archkeep: cannot compare the observed architecture to ${INTENT_FILE} — ` +
131
+ verdict.unresolved
132
+ .map(({ boundary, issue }) => `boundary/row ${boundary}: ${issue}`)
133
+ .join("; ") +
134
+ `. An intent that cannot be verified is not a clean one; fix the intent or the graph and re-run.`,
135
+ );
136
+ }
137
+
138
+ const scores = reconcileScores(intent, verdict, observed, analysis);
139
+
140
+ const coverage = {
141
+ complete: true,
142
+ projects: observed.projects.length,
143
+ analyzedFiles: analysis.analyzed,
144
+ imports: analysis.imports.length,
145
+ notAnalyzed: [],
146
+ // Reconcile reads only the graph — provider failures are the same blind
147
+ // spots every other command reports, and a blind spot never prevents a
148
+ // verdict.
149
+ blindSpots: analysis.failures
150
+ .filter((failure) => !isWholeFileFailure(failure))
151
+ .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
152
+ // Coverage notes (e.g. an `optional: true` allowed row the team has not
153
+ // built yet) ride here so "optional and absent" never reads as "never
154
+ // checked".
155
+ notes: verdict.notes,
156
+ };
157
+
158
+ const context = { root, provider, marker, provenance: resolveProvenance(root) };
159
+ const result = {
160
+ intent: {
161
+ file: INTENT_FILE,
162
+ fingerprint: computeIntentFingerprint(intent),
163
+ rows: intentRows(intent),
164
+ },
165
+ observed: {
166
+ projects: observed.projects.length,
167
+ edges: observed.edges.length,
168
+ implicitEdges: observed.implicitEdges,
169
+ },
170
+ scores: {
171
+ projects: scores.projects,
172
+ edges: scores.edges,
173
+ tags: scores.tags,
174
+ boundaries: scores.boundaries,
175
+ intentRows: scores.intentRows,
176
+ },
177
+ unknownFiles: scores.unknownFiles,
178
+ };
179
+
180
+ if (options.propose) {
181
+ result.candidates = buildRankedCandidates(scores);
182
+ result.proposed = true;
183
+ result.notAuthoritative = true;
184
+ }
185
+
186
+ // Reconcile is descriptive — always status "ok" when it completes, never
187
+ // "findings". Divergence is described and (with --propose) proposed, not
188
+ // judged: a descriptive command never claims a violation's exit code.
189
+ const status = "ok";
190
+ const exitCode = 0;
191
+
192
+ const envelope = jsonEnvelope({
193
+ command: "reconcile",
194
+ context,
195
+ status,
196
+ exitCode,
197
+ coverage,
198
+ result,
199
+ });
200
+
201
+ return {
202
+ status,
203
+ reconcile: result,
204
+ coverage,
205
+ report: {
206
+ text: formatReconcileReport({
207
+ scores,
208
+ candidates: options.propose ? result.candidates : null,
209
+ intent: { fingerprint: result.intent.fingerprint, rows: result.intent.rows },
210
+ observed: {
211
+ projects: result.observed.projects,
212
+ edges: result.observed.edges,
213
+ implicitEdges: result.observed.implicitEdges,
214
+ },
215
+ }),
216
+ json: renderJson(envelope),
217
+ },
218
+ };
219
+ }