@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.
- package/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- 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
|
+
}
|