@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,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
+ }