@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,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic architecture-health metrics: the numbers that describe a run's
|
|
3
|
+
* `{ projects, dependencies, analysis, coverage, intent }` without a service,
|
|
4
|
+
* a dashboard, or a datastore beyond the snapshot directory `history` already
|
|
5
|
+
* keeps (`../commands/history.mjs`). North-star doctrine refuses the hosted
|
|
6
|
+
* surface outright (`../../../../docs/doctrine/north-star.md`, § "What is not on
|
|
7
|
+
* that list"): the numbers are computed here, read-only, per run, and a
|
|
8
|
+
* maintainer acts on the trend across `history` snapshots instead.
|
|
9
|
+
*
|
|
10
|
+
* ## What "deterministic" means here
|
|
11
|
+
*
|
|
12
|
+
* - Every metric is a pure function of its inputs. Two identical runs —
|
|
13
|
+
* identical tree, identical config, identical intent — produce identical
|
|
14
|
+
* bytes (`../../../../AGENTS.md`). Nothing here reads the clock, the locale,
|
|
15
|
+
* the environment, or any process state.
|
|
16
|
+
* - No metric is a mutable counter. Each is re-derived from the run's own
|
|
17
|
+
* records every time; nothing is accumulated across runs (accumulation is
|
|
18
|
+
* exactly the mutable state the snapshot directory exists to avoid).
|
|
19
|
+
* - Array and map iteration is sorted by plain string comparison, never
|
|
20
|
+
* `localeCompare` — the same guarantee `../commands/graph.mjs` makes for the
|
|
21
|
+
* snapshot bytes.
|
|
22
|
+
*
|
|
23
|
+
* ## The empty-result invariant, at the metric level
|
|
24
|
+
*
|
|
25
|
+
* The repository's whole judgment stands on one sentence (`AGENTS.md`): *an
|
|
26
|
+
* empty result is a claim, not a shrug*. Applied to a metric, that sentence
|
|
27
|
+
* reads: **a metric whose evidence is unavailable is `unknown` or `excluded` —
|
|
28
|
+
* reading it as zero is the error.** Every metric's verdict is one of:
|
|
29
|
+
*
|
|
30
|
+
* - `ok` — the metric was measured over complete evidence and holds (e.g. zero
|
|
31
|
+
* violations over a fully-analyzed tree);
|
|
32
|
+
* - `findings` — measured and broken (violations, dead debt rows);
|
|
33
|
+
* - `not_applicable` — there is nothing for the metric to measure (no intent
|
|
34
|
+
* file, no boundary config, no edges, no projects of a type);
|
|
35
|
+
* - `unknown` — the run could not fully inspect the evidence the metric needs
|
|
36
|
+
* (unanalyzable files, a partial graph). `unknown` is never folded into a
|
|
37
|
+
* zero — a metric that cannot look must not read as a clean one.
|
|
38
|
+
*
|
|
39
|
+
* These four are the canonical verdict vocabulary the governance wave shares
|
|
40
|
+
* through E0's evidence envelope. `health` reports each metric's verdict
|
|
41
|
+
* through that decision shape; it never reports a metric as a bare number
|
|
42
|
+
* whose absent evidence could be mistaken for a clean zero.
|
|
43
|
+
*
|
|
44
|
+
* ## Reuse, and the limit the snapshots set
|
|
45
|
+
*
|
|
46
|
+
* Every metric here is derived from records the run already holds (the graph,
|
|
47
|
+
* the analysis envelope, the boundary config, the intent verdict) through the
|
|
48
|
+
* same functions the existing commands use — `buildProjects`,
|
|
49
|
+
* `buildDependencies`, `evaluate`, `judgeIntent` — so health performs **no new
|
|
50
|
+
* scans** and builds **no parallel store**. Trend data is read from the
|
|
51
|
+
* snapshot directory `historyCommand` already reads, which is what keeps one
|
|
52
|
+
* history instead of two. The snapshots are `graph` envelopes, so the trend
|
|
53
|
+
* metrics are limited to what a `graph` snapshot carries: a snapshot cannot
|
|
54
|
+
* recompute a violation count, because the constraint table and the import
|
|
55
|
+
* sites do not travel with it. The trend therefore reports the structural
|
|
56
|
+
* metrics (projects, edges, coverage) over the snapshots, and discloses that
|
|
57
|
+
* rule-impact cannot be re-derived from stored bytes (`../commands/history.mjs`
|
|
58
|
+
* states the same limit).
|
|
59
|
+
*/
|
|
60
|
+
import { suppressionCovers } from "../config.mjs";
|
|
61
|
+
import { referenceTime } from "./clock.mjs";
|
|
62
|
+
import { suppressionFate } from "./waiver.mjs";
|
|
63
|
+
import { evaluateWithSuppressions } from "../rules/index.mjs";
|
|
64
|
+
import { buildReachability } from "../rules/reachability.mjs";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The four canonical verdicts (see the module header). A metric's verdict,
|
|
68
|
+
* never a number that could read as a clean zero with its evidence missing.
|
|
69
|
+
*
|
|
70
|
+
* @typedef {"ok"|"findings"|"not_applicable"|"unknown"} MetricVerdict
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One metric's measured value and the verdict over it. The verdict is the
|
|
75
|
+
* decision E0's evidence envelope carries; `value` is the number it was
|
|
76
|
+
* decided over, with a hard rule: `value` is present ONLY when the verdict is
|
|
77
|
+
* `ok` or `findings` — a `not_applicable` or `unknown` metric carries no
|
|
78
|
+
* number, because a number with no evidence would read as a measured zero.
|
|
79
|
+
*
|
|
80
|
+
* @typedef {object} Metric
|
|
81
|
+
* @property {MetricVerdict} verdict
|
|
82
|
+
* @property {number} [value] The measured count, present exactly when the
|
|
83
|
+
* verdict is `ok` or `findings`.
|
|
84
|
+
* @property {string} [note] Why the verdict is what it is, for the text report.
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Wraps a measured count into a verdict-taking `Metric`. A count that reached
|
|
89
|
+
* zero over complete evidence is the modern `ok`; a count that reached zero
|
|
90
|
+
* over incomplete evidence is the ancient `unknown`, because "no violations"
|
|
91
|
+
* over a tree the run could not fully read is not a claim it can make
|
|
92
|
+
* (`AGENTS.md`).
|
|
93
|
+
*
|
|
94
|
+
* @param {number|null} value The count, or `null` when the evidence is
|
|
95
|
+
* unavailable or inapplicable.
|
|
96
|
+
* @param {{note?: string}} [options]
|
|
97
|
+
* @returns {Metric}
|
|
98
|
+
*/
|
|
99
|
+
function metric(value, { note } = {}) {
|
|
100
|
+
if (value === null)
|
|
101
|
+
return /** @type {Metric} */ ({ verdict: "unknown", ...(note ? { note } : {}) });
|
|
102
|
+
return /** @type {Metric} */ ({ verdict: value === 0 ? "ok" : "findings", value });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The one `unknown`-producing condition a metric record can carry: the run's
|
|
107
|
+
* evidence is incomplete. `not_applicable` is decided per metric (no intent
|
|
108
|
+
* file, no edges), never by this function — a condition that says "this metric
|
|
109
|
+
* has nothing to measure" and a condition that says "the run could not look"
|
|
110
|
+
* are different verdicts and must never collapse into each other
|
|
111
|
+
* (`AGENTS.md`: empty must mean empty).
|
|
112
|
+
*
|
|
113
|
+
* @typedef {object} MetricRecord
|
|
114
|
+
* @property {Metric} each The metric.
|
|
115
|
+
* @property {boolean} complete Whether the evidence the metric needed was
|
|
116
|
+
* complete. Incomplete evidence makes the metric `unknown`, not `ok`.
|
|
117
|
+
* @property {string[]} issues The reasons the evidence was incomplete, for a
|
|
118
|
+
* report that must say what it could not look at rather than silently
|
|
119
|
+
* degrade.
|
|
120
|
+
*/
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Derives the structural metrics from the graph and the run's coverage facts.
|
|
124
|
+
* Everything here is re-derived from records the run already holds — `graph`
|
|
125
|
+
* commands build the same projects/edges arrays for a snapshot — so the
|
|
126
|
+
* numbers agree byte-for-byte with what `graph --format json` would print.
|
|
127
|
+
*
|
|
128
|
+
* @param {{projects: object[], edges: object[]}} model Projects and edges as
|
|
129
|
+
* `buildProjects`/`buildDependencies` emit them.
|
|
130
|
+
* @param {{complete: boolean, analyzedFiles: number, imports: number,
|
|
131
|
+
* projects: number, notAnalyzed: object[], blindSpots: object[]}} coverage
|
|
132
|
+
* The run's coverage facts, the same shape every command's envelope carries.
|
|
133
|
+
* @param {boolean} [edgeComplete] Whether the graph's EDGES are complete.
|
|
134
|
+
* Defaults to `coverage.complete`; a graph whose polyglot edges were never
|
|
135
|
+
* drawn (unregistered Nx plugin) is incomplete for the edges metric even
|
|
136
|
+
* when the file read was complete.
|
|
137
|
+
* @returns {{projects: Metric, edges: Metric, coverage: Metric}}
|
|
138
|
+
*/
|
|
139
|
+
export function structuralMetrics(model, coverage, edgeComplete = coverage.complete) {
|
|
140
|
+
const complete = coverage.complete === true;
|
|
141
|
+
const edgesComplete = edgeComplete === true;
|
|
142
|
+
// Projects and edges are DESCRIPTIONS, not pass/fail: a workspace with 40
|
|
143
|
+
// projects is not "more broken" than one with 4. They carry `ok` with the
|
|
144
|
+
// measured count when the evidence is complete, and `unknown` — no number —
|
|
145
|
+
// when it is not.
|
|
146
|
+
/** @type {Metric} */
|
|
147
|
+
const projects = complete
|
|
148
|
+
? { verdict: "ok", value: model.projects.length }
|
|
149
|
+
: {
|
|
150
|
+
verdict: "unknown",
|
|
151
|
+
note: `${coverage.notAnalyzed.length} file(s) could not be analyzed`,
|
|
152
|
+
};
|
|
153
|
+
/** @type {Metric} */
|
|
154
|
+
const edges = edgesComplete
|
|
155
|
+
? { verdict: "ok", value: model.edges.length }
|
|
156
|
+
: { verdict: "unknown", note: "the graph may under-represent edges" };
|
|
157
|
+
// The fraction of ANALYZED files (not the whole tracked tree, which is mostly
|
|
158
|
+
// Markdown, JSON and images the tool is not pointed at). Unlike the
|
|
159
|
+
// projects/edges metrics — whose identified sets may under-count when the run
|
|
160
|
+
// was partial — both sides of this ratio are KNOWN facts even on a partial
|
|
161
|
+
// run (the analyzers count what they examined and what failed), so the
|
|
162
|
+
// coverage metric is MEASURED, not unknown: a tree with holes reports a ratio
|
|
163
|
+
// under 1 as findings, and a run that examined nothing reports `unknown`
|
|
164
|
+
// rather than 0/0 read as a clean 1.
|
|
165
|
+
const coverageRatio =
|
|
166
|
+
coverage.analyzedFiles + coverage.notAnalyzed.length > 0
|
|
167
|
+
? coverage.analyzedFiles / Math.max(coverage.analyzedFiles + coverage.notAnalyzed.length, 1)
|
|
168
|
+
: null;
|
|
169
|
+
const coverageMetric = metric(
|
|
170
|
+
coverageRatio === null
|
|
171
|
+
? null
|
|
172
|
+
: // The coverage metric reports OK only at full coverage — anything less
|
|
173
|
+
// is a finding, because a fraction under 1 means some file had no
|
|
174
|
+
// verdict (`AGENTS.md`: empty must mean empty). The ratio itself is the
|
|
175
|
+
// value behind the findings verdict.
|
|
176
|
+
coverageRatio === 1
|
|
177
|
+
? 0
|
|
178
|
+
: coverageRatio,
|
|
179
|
+
coverageRatio === null
|
|
180
|
+
? { note: "no analyzable files were examined" }
|
|
181
|
+
: {
|
|
182
|
+
note: `${coverage.analyzedFiles} of ${coverage.analyzedFiles + coverage.notAnalyzed.length} analyzable files examined`,
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
return { projects, edges, coverage: coverageMetric };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Derives the boundary metric: violations and the waiver surface.
|
|
190
|
+
*
|
|
191
|
+
* `violations` is `evaluate`'s full verdict — every boundary rule, over every
|
|
192
|
+
* import site the run analyzed. The metric reports `unknown` when the evidence
|
|
193
|
+
* is incomplete (a file with no analyzer, an unreadable file — a violation
|
|
194
|
+
* count over a tree the run could not fully read is not a claim) or when no
|
|
195
|
+
* boundary config exists at all (there is then no law to judge against, which
|
|
196
|
+
* is `not_applicable`, never a clean zero).
|
|
197
|
+
*
|
|
198
|
+
* `waiverSurface` is the number of violations a suppression covers — the
|
|
199
|
+
* surface of the boundary law that is waived. It is a METRIC, not a verdict:
|
|
200
|
+
* a waiver is a decision the workspace made about its own law, so a
|
|
201
|
+
* non-zero surface is a fact, not a finding. It reads `not_applicable` when
|
|
202
|
+
* there is no law or no violations, and `unknown` when the run could not fully
|
|
203
|
+
* inspect the files a waiver names — a suppressed violation over an
|
|
204
|
+
* unanalyzed file would under-count the waivers in force.
|
|
205
|
+
*
|
|
206
|
+
* @param {object[]} importSites The run's analysis `imports`.
|
|
207
|
+
* @param {object} graph The project graph `{nodes, dependencies}`.
|
|
208
|
+
* @param {{depConstraints: object[], options: object, suppressions?: object[], now?: string}|null} config
|
|
209
|
+
* The loaded boundary law, or `null` when the workspace provides none. `now`
|
|
210
|
+
* is the waiver-expiry reference the rule engine honours, threaded so the
|
|
211
|
+
* metrics agree with `check` about which waivers are still in force.
|
|
212
|
+
* @param {{complete: boolean}} coverage
|
|
213
|
+
* @returns {{violations: Metric, waiverSurface: Metric, underWavedCount: number}}
|
|
214
|
+
*/
|
|
215
|
+
export function boundaryMetrics(importSites, graph, config, coverage) {
|
|
216
|
+
if (config === null) {
|
|
217
|
+
return {
|
|
218
|
+
violations: {
|
|
219
|
+
verdict: "not_applicable",
|
|
220
|
+
note: "no boundary config; there is no law to judge against",
|
|
221
|
+
},
|
|
222
|
+
waiverSurface: {
|
|
223
|
+
verdict: "not_applicable",
|
|
224
|
+
note: "no boundary config; waivers attach to a law",
|
|
225
|
+
},
|
|
226
|
+
underWavedCount: 0,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const complete = coverage.complete === true;
|
|
230
|
+
const raws = evaluateWithSuppressions(importSites, graph, config);
|
|
231
|
+
if (!complete) {
|
|
232
|
+
return {
|
|
233
|
+
violations: { verdict: "unknown", note: "the boundary could not be fully inspected" },
|
|
234
|
+
waiverSurface: {
|
|
235
|
+
verdict: "unknown",
|
|
236
|
+
note: "the waiver surface could not be fully inspected",
|
|
237
|
+
},
|
|
238
|
+
underWavedCount: 0,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const suppressions = config.suppressions ?? [];
|
|
242
|
+
// F02: a suppression entry is a waiver (expires) or a legacy suppression
|
|
243
|
+
// (permanent). `suppressionCovers` only answers "path+id match" — an
|
|
244
|
+
// EXPIRED waiver still matched, so the metrics wrote the boundary clean
|
|
245
|
+
// while `check`/`health` were re-asserting it. The fate decides: a waiver
|
|
246
|
+
// in force (waive) or a permanent suppression (suppress) covers; an
|
|
247
|
+
// expired one (reassert) covers nothing and the violation is live — the
|
|
248
|
+
// same verdict the rule engine's table walk (`../rules/index.mjs`) renders
|
|
249
|
+
// for the gate. `config.now` is the same reference the rule engine honours.
|
|
250
|
+
const now = config.now ?? referenceTime();
|
|
251
|
+
const waived = raws.filter((v) =>
|
|
252
|
+
suppressions.some((entry) => {
|
|
253
|
+
if (!suppressionCovers(entry, v)) return false;
|
|
254
|
+
return suppressionFate(entry, now) !== "reassert";
|
|
255
|
+
}),
|
|
256
|
+
);
|
|
257
|
+
const violations = metric(raws.length - waived.length);
|
|
258
|
+
// The waiver surface is a METRIC, not a verdict: a waiver is a decision the
|
|
259
|
+
// workspace made about its own law, so a non-zero surface is a fact on the
|
|
260
|
+
// books, never a finding. It carries `ok` + the count (informational, like
|
|
261
|
+
// edge density) — the violations verdict beside it already reports what that
|
|
262
|
+
// surface is waiving. `waived` holds everything currently suppressing (a
|
|
263
|
+
// legacy suppression and an active waiver alike); an EXPIRED waiver (fate
|
|
264
|
+
// `reassert`) is in neither count, so the metric and the gate can never
|
|
265
|
+
// disagree about the same tree (F02).
|
|
266
|
+
/** @type {Metric} */
|
|
267
|
+
const waiverSurface = { verdict: "ok", value: waived.length };
|
|
268
|
+
return { violations, waiverSurface, underWavedCount: waived.length };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Derives the intent (fitness) metric from the drift verdict.
|
|
273
|
+
*
|
|
274
|
+
* The verdict here is the canonical `judgeIntent` verdict: `ok` when every row
|
|
275
|
+
* and every boundary was judged and holds; `findings` when a forbidden path
|
|
276
|
+
* exists or an allowed one is missing; `no-verdict` when a boundary matched no
|
|
277
|
+
* observed project (the tool cannot check it, so it must not read as clean).
|
|
278
|
+
* A workspace with no intent file — a workspace that chose not to declare an
|
|
279
|
+
* intended architecture — is `not_applicable`: there is nothing to keep fit
|
|
280
|
+
* against. Absence of intent is a workspace decision, and a `not_applicable`
|
|
281
|
+
* verdict says exactly that (it is not an `unknown`, which would claim the
|
|
282
|
+
* tool tried and could not look).
|
|
283
|
+
*
|
|
284
|
+
* @param {{ok: boolean, findings: object[], unresolved: object[]}|null} drift
|
|
285
|
+
* The intent verdict: `null` when the workspace has no intent file.
|
|
286
|
+
* @returns {Metric}
|
|
287
|
+
*/
|
|
288
|
+
export function intentMetric(drift) {
|
|
289
|
+
if (drift === null) {
|
|
290
|
+
return {
|
|
291
|
+
verdict: "not_applicable",
|
|
292
|
+
note: "no architecture-intent.json; the workspace declared no intended architecture",
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
if (drift.unresolved.length > 0) {
|
|
296
|
+
return { verdict: "unknown", note: "some intent rows matched no observed project" };
|
|
297
|
+
}
|
|
298
|
+
return metric(drift.ok ? 0 : drift.findings.length);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Derives the coupling metric: the project-level edge density and the cycle
|
|
303
|
+
* count.
|
|
304
|
+
*
|
|
305
|
+
* `edgeDensity` is `edges / projects`, the average fan-out per project — a
|
|
306
|
+
* structural pressure gauge with no correct value, only a trend. So it is
|
|
307
|
+
* reported with a `not_applicable` verdict when there are no projects (nothing
|
|
308
|
+
* to couple), and otherwise carries the number with an informational verdict
|
|
309
|
+
* (`ok` — a ratio is a description, not a pass/fail). A maintainer acts on how
|
|
310
|
+
* it moves between snapshots, never on a single reading.
|
|
311
|
+
*
|
|
312
|
+
* `cycles` uses the rule engine's own reachability (`buildReachability`), the
|
|
313
|
+
* same model `noCircularDependencies` judges against, so health and check
|
|
314
|
+
* cannot disagree about what a cycle is. A cycle is a finding (broken
|
|
315
|
+
* structure), and is `unknown` when the graph could not be fully inspected.
|
|
316
|
+
*
|
|
317
|
+
* @param {object[]} projects
|
|
318
|
+
* @param {object[]} edges
|
|
319
|
+
* @param {{complete: boolean}} coverage
|
|
320
|
+
* @returns {{edgeDensity: Metric, cycles: Metric}}
|
|
321
|
+
*/
|
|
322
|
+
export function couplingMetrics(projects, edges, coverage) {
|
|
323
|
+
const complete = coverage.complete === true;
|
|
324
|
+
/** @type {Metric} */
|
|
325
|
+
const edgeDensity =
|
|
326
|
+
projects.length === 0
|
|
327
|
+
? { verdict: "not_applicable", note: "no projects; there is nothing to couple" }
|
|
328
|
+
: complete
|
|
329
|
+
? { verdict: "ok", value: edges.length / projects.length }
|
|
330
|
+
: { verdict: "unknown", note: "the graph may under-count edges" };
|
|
331
|
+
|
|
332
|
+
/** @type {Metric} */
|
|
333
|
+
let cycles = { verdict: "not_applicable", note: "no edges; nothing can cycle" };
|
|
334
|
+
if (edges.length > 0) {
|
|
335
|
+
if (!complete) {
|
|
336
|
+
cycles = { verdict: "unknown", note: "the graph may under-count edges" };
|
|
337
|
+
} else {
|
|
338
|
+
// A cycle is a pair of projects that reach each other — a 2-cycle in the
|
|
339
|
+
// reachability matrix, counted from the same `buildReachability` the
|
|
340
|
+
// `noCircularDependencies` rule judges against, so health and check
|
|
341
|
+
// cannot disagree about what a cycle is. The matrix diagonal is excluded
|
|
342
|
+
// (`matrix[p][p]` is true by definition); each true off-diagonal pair is
|
|
343
|
+
// counted once.
|
|
344
|
+
const nodes = new Set(edges.flatMap((e) => [e.source, e.target]));
|
|
345
|
+
const graph = {
|
|
346
|
+
nodes: Object.fromEntries([...nodes].map((n) => [n, { name: n }])),
|
|
347
|
+
dependencies: {},
|
|
348
|
+
};
|
|
349
|
+
for (const edge of edges) {
|
|
350
|
+
(graph.dependencies[edge.source] ??= []).push(edge);
|
|
351
|
+
}
|
|
352
|
+
const { matrix } = buildReachability(graph);
|
|
353
|
+
let cycleCount = 0;
|
|
354
|
+
for (const node of nodes) {
|
|
355
|
+
for (const other of nodes) {
|
|
356
|
+
if (node !== other && matrix[node]?.[other] && matrix[other]?.[node]) {
|
|
357
|
+
cycleCount += 1;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
cycleCount = Math.round(cycleCount / 2);
|
|
362
|
+
cycles = metric(cycleCount, {
|
|
363
|
+
note: cycleCount === 0 ? undefined : "a cycle is broken structure — see check",
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return { edgeDensity, cycles };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Derives the debt metric from the boundary config's own `notes`.
|
|
372
|
+
*
|
|
373
|
+
* Archkeep's boundary config dialect lets a workspace declare rows that are
|
|
374
|
+
* temporary with the reason written down (an `"optional": true` allowed row,
|
|
375
|
+
* a suppression entry that predates a fix, a policy note). Those rows are the
|
|
376
|
+
* architecture's debt ledger — each one is a decision to defer a constraint,
|
|
377
|
+
* and the metric counts the deferred rows. A config with no notes has no debt:
|
|
378
|
+
* `ok`, value 0. A config with notes reports each one; the debt is a finding
|
|
379
|
+
* the maintainer acts on.
|
|
380
|
+
*
|
|
381
|
+
* @param {{notes?: string[]}|null} config
|
|
382
|
+
* @returns {Metric}
|
|
383
|
+
*/
|
|
384
|
+
export function debtMetric(config) {
|
|
385
|
+
if (config === null) {
|
|
386
|
+
return { verdict: "not_applicable", note: "no boundary config; no debt ledger" };
|
|
387
|
+
}
|
|
388
|
+
const notes = config.notes ?? [];
|
|
389
|
+
return metric(notes.length === 0 ? 0 : notes.length, {
|
|
390
|
+
note: notes.length === 0 ? undefined : "deferred rows are recorded in the config's own notes",
|
|
391
|
+
});
|
|
392
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"clean-architecture#clean-architecture": "28e4dc572cf278e69f05c01bed020cda425ebd7b11bfe04804f21d2f1bbdcb69",
|
|
3
|
+
"clean-architecture#clean-architecture-pure-core": "85e7d5a36759169190dab1d871acc1d2c733b145306ef33f7b161e641c9f78e4",
|
|
4
|
+
"ddd-bounded-contexts#ddd-bounded-contexts": "8ec7feb0e6dbf64373e1aaf5bfc9d53d6299de3c11fb38955a5a3857fac04f09",
|
|
5
|
+
"ddd-bounded-contexts#ddd-bounded-contexts-isolated": "8ac4183559010abf01e20ae50bffd7611b78417b4af531af6c7a15fbcdc443da",
|
|
6
|
+
"ddd-bounded-contexts#ddd-bounded-contexts-partitioned": "982f90d0718c2bdd6874411964ef9baa379ee5f9c849404cb9cde2ab7aaccc19",
|
|
7
|
+
"hexagonal#hexagonal": "08e57312c449a79ce32c4248931e7d9488441fa47f8638fb780fd02602442003",
|
|
8
|
+
"hexagonal#hexagonal-pure-domain": "3a43406883c6e182592ff1f47347c6f9a04f04690300ea5b9260877dea2f134f",
|
|
9
|
+
"layered#layered-relaxed": "ad2912594d26a270b6139c6561454ef01e2bf6e2214d2d8ec57afcc8fee11467",
|
|
10
|
+
"layered#layered-strict": "26af569cb6c352a223e09e18e3e7c9817d5c1cb53575be46ffad950bd2f84be2",
|
|
11
|
+
"modular-monolith#modular-monolith": "4b3517e4d4ae1357e675b947d7e4845260fa6d75122f40d8e75ad202d910606a",
|
|
12
|
+
"modular-monolith#modular-monolith-sealed-kernel": "124a7bafd43abbd87e5218146219d57af6010e9374d80f0c3c1d2ac13a8e2677",
|
|
13
|
+
"modular-monolith#modular-monolith-sealed-modules": "772d74fd8bbdb85ea39c5e38bdb8193a4f069ace8b458bff96943d5a243f5d25",
|
|
14
|
+
"vertical-slice#vertical-slice": "5a4a17b041bb57046ba0f42a28032afb933c1f28ee3ecaa1262c706786c14dc7",
|
|
15
|
+
"vertical-slice#vertical-slice-sealed-kernel": "efbcd5617f3c96f43b02f622cd79c4b27e49af907329ec9dcd9ee77549eb49fa"
|
|
16
|
+
}
|