@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,274 @@
1
+ /**
2
+ * The architecture-debt ledger: the exemptions, gaps and violations a workspace
3
+ * is carrying, each with how long it has been carried and how much of the tree
4
+ * it touches. Pure and deterministic: given the current run's candid facts
5
+ * (suppressions, intent notes, drift findings, unresolved intent) and the
6
+ * history directory's snapshots, it returns one ledger. No I/O here — the
7
+ * caller reads the snapshot directory (`../commands/history.mjs`'s
8
+ * `readSnapshots`) and passes the files through.
9
+ *
10
+ * ## What is tracked, and what "age" means
11
+ *
12
+ * Architecture debt is an aging record, not a finance metaphor: no interest,
13
+ * no compounding — age, count and severity only. A ledger is rebuildable at any
14
+ * moment: it derives from the same files `check`/`drift`/`graph` already read
15
+ * (the boundary config's `boundarySuppressions`, `judgeIntent`'s findings and
16
+ * notes, and the history directory), so there is no private store to go stale.
17
+ *
18
+ * Four entry kinds cover the candidate facts:
19
+ *
20
+ * - **waiver** — a `boundarySuppressions` row: a violation the workspace
21
+ * decided to accept, with the mandatory reason it accepted it
22
+ * (`../config.mjs`). The debt is the accepted violation itself.
23
+ * - **aspirational-gap** — an `"optional": true` `allowed` intent row whose
24
+ * statement is not yet observed: a stated dependency that is not being built.
25
+ * It is not drift (it changes no verdict) but it IS debt.
26
+ * - **drift** — a drift finding: the observed architecture contradicts the
27
+ * declared intent (`../architecture-intent/judge.mjs`).
28
+ * - **unresolved** — an intent boundary or row we could not verify (matched no
29
+ * observed project). Its severity is unknowable, so it reads `unknown` —
30
+ * never a clean ledger.
31
+ *
32
+ * ## Why age is per-project
33
+ *
34
+ * Snapshots carry the project graph and the policy fingerprint — not the
35
+ * ledger facts themselves (there is no constraint table or suppression set in
36
+ * a `graph` envelope, exactly the disclosure `../commands/history.mjs` makes
37
+ * about rule-impact). So an entry is aged by the *owning project*: how many
38
+ * consecutive snapshots the project the debt lives in has been part of the
39
+ * architecture. Age is measured in snapshots, not days. A debt living in a
40
+ * project first observed in the B-th snapshot (0-based) and head=the last of
41
+ * n carries `age = n - B` — a project present in every snapshot has age n, one
42
+ * we only see now has age 1. When the directory holds fewer than two
43
+ * snapshots, `agings: false` is set and every age is 0 — a ledger built from
44
+ * one observation says "observed, not yet aged", exactly like a history with a
45
+ * single snapshot (`../commands/history.mjs`).
46
+ *
47
+ * The owning project of a waiver is the head-snapshot project whose root the
48
+ * suppression path falls under (taking the longest matching root for
49
+ * determinism when several nest); of a drift finding it is the finding's
50
+ * `source` project. An aspirational gap and an unresolved intent name no
51
+ * project, so they carry age 0.
52
+ *
53
+ * `referenceTime` is the ledger's clock; the shared governance clock's current
54
+ * instant when the caller is the CLI (see `computeDebtLedger`).
55
+ *
56
+ * ## Severity
57
+ *
58
+ * One axis, three values, decidable from the facts an entry carries without a
59
+ * second opinion:
60
+ *
61
+ * - **high** — a drift finding in a project that also carries an accepted
62
+ * waiver (the accepted violation is failing today — the ledger must never
63
+ * hide that), and any unresolved intent (a boundary that matched nothing
64
+ * means the whole comparison cannot be trusted).
65
+ * - **medium** — any other drift finding.
66
+ * - **low** — a waiver or an aspirational gap: both are accepted, living
67
+ * states, not contradictions — debt to retire, not findings to fix.
68
+ *
69
+ * ## The empty-result invariant
70
+ *
71
+ * A ledger entry MUST be readable: an unresolved intent reads `unknown`
72
+ * (never a shrug), and when the directory cannot establish age the ledger says
73
+ * `agings: false` rather than guessing ages. An empty entry list must mean
74
+ * exactly "no exemptions, gaps or findings" — `computeDebtLedger` returns the
75
+ * aggregate beside every list. A malformed snapshot directory throws in
76
+ * `readSnapshots` (the caller's job) and surfaces as exit 3 from the command,
77
+ * never as an empty ledger.
78
+ */
79
+
80
+ import { referenceTime as clockReferenceTime } from "./clock.mjs";
81
+ import { EXPIRED_WAIVER_EVIDENCE, suppressionFate } from "./waiver.mjs";
82
+
83
+ /**
84
+ * The head snapshot's projects as name → root, used to place a suppression path
85
+ * under its owning project. Empty when there are no snapshots.
86
+ *
87
+ * @param {object[]} files From `readSnapshots(dir)`.
88
+ * @returns {Map<string, string>}
89
+ */
90
+ function headProjects(files) {
91
+ const head = files[files.length - 1];
92
+ const byName = new Map();
93
+ for (const project of head?.envelope?.result?.projects ?? []) {
94
+ if (typeof project?.name === "string" && typeof project?.root === "string") {
95
+ byName.set(project.name, project.root);
96
+ }
97
+ }
98
+ return byName;
99
+ }
100
+
101
+ /**
102
+ * The owning project of a suppression path: the head-snapshot project whose
103
+ * root is a path prefix of the (glob) path, choosing the longest root when
104
+ * several nest. A glob like `packages/**` is placed by the literal prefix
105
+ * before the wildcard; when no project's root is a prefix, the waiver maps to
106
+ * no project and ages 0 honestly rather than guessing one.
107
+ *
108
+ * @param {string} path The suppression's `path`.
109
+ * @param {Map<string, string>} byName Head project name → root.
110
+ * @returns {string|null} The owning project name, or `null`.
111
+ */
112
+ function owningProjectForPath(path, byName) {
113
+ let best = null;
114
+ let bestRoot = "";
115
+ for (const [name, root] of byName) {
116
+ if (root.length > bestRoot.length && (path === root || path.startsWith(root + "/"))) {
117
+ best = name;
118
+ bestRoot = root;
119
+ }
120
+ }
121
+ return best;
122
+ }
123
+
124
+ /**
125
+ * The complete ledger over one ordered snapshot set. Deterministic: the same
126
+ * files, the same current facts and the same `referenceTime` produce the same
127
+ * ledger. All lists sort by plain `<` comparison, never `localeCompare`.
128
+ *
129
+ * The caller is the CLI; tests may leave `referenceTime` out and receive one
130
+ * of their own, from the shared clock (`./clock.mjs`) — not a promise two
131
+ * test runs could share. (The ledger's own determinism is about a fixed
132
+ * clock.)
133
+ *
134
+ * @param {{suppressions?: object[], intentNotes?: string[], findings?: object[],
135
+ * unresolved?: object[]}} current The current run's candid facts: the loaded
136
+ * boundary config's `suppressions`, `judgeIntent`'s `notes` (aspirational
137
+ * gaps), `findings` (drift), and `unresolved`.
138
+ * @param {{files: {name: string, envelope: object, id: string}[]}} snapshots
139
+ * From `readSnapshots(dir)`, in history order.
140
+ * @param {{referenceTime?: number|string}} [opts]
141
+ * @returns {{entries: {source: string, kind: string, severity: string,
142
+ * age: number, count: number, remediationHint: string}[],
143
+ * total: number, byKind: object, bySeverity: object, agings: boolean,
144
+ * sampleTime: string}}
145
+ */
146
+ export function computeDebtLedger(current, snapshots, opts = {}) {
147
+ const referenceTime = opts.referenceTime ?? clockReferenceTime();
148
+ const sampleTime = new Date(referenceTime).toISOString();
149
+
150
+ const files = snapshots.files ?? [];
151
+ const n = files.length;
152
+ const agings = n >= 2;
153
+
154
+ // Per-project first-seen index, in history order. A project observed for the
155
+ // whole history has firstSeen 0 and age n; one only in the head has age 1.
156
+ const firstSeen = new Map();
157
+ for (let i = 0; i < n; i++) {
158
+ for (const project of files[i].envelope?.result?.projects ?? []) {
159
+ if (project?.name && !firstSeen.has(project.name)) firstSeen.set(project.name, i);
160
+ }
161
+ }
162
+ const ageOf = (name) => {
163
+ if (!agings) return 0;
164
+ const first = firstSeen.get(name);
165
+ return first === undefined ? 0 : n - first;
166
+ };
167
+
168
+ const byName = headProjects(files);
169
+
170
+ /** @type {{source: string, kind: string, severity: string, age: number, count: number, remediationHint: string}[]} */
171
+ const entries = [];
172
+
173
+ for (const suppression of current.suppressions ?? []) {
174
+ const project = owningProjectForPath(suppression.path, byName);
175
+ // F06: a waiver that lapsed re-asserts — the ledger must name it, never
176
+ // book it as a low "still suppressed" row while the gate re-asserts the
177
+ // same row. The shared fate function (`./waiver.mjs`) is the ONE
178
+ // authority, so `debt` and `check` cannot disagree about expiry. A legacy
179
+ // suppression (no `expiresAt`) is `suppress`: still low and permanent.
180
+ const fate = suppressionFate(suppression, sampleTime);
181
+ const expired = fate === "reassert";
182
+ entries.push({
183
+ source: suppression.path,
184
+ kind: expired ? "expired-waiver" : "waiver",
185
+ severity: expired ? "medium" : "low",
186
+ age: project ? ageOf(project) : 0,
187
+ count: 1,
188
+ remediationHint: expired
189
+ ? `the waiver at '${suppression.path}' expired — the boundary it accepted is live again (${EXPIRED_WAIVER_EVIDENCE}); renew it or retire it`
190
+ : `the accepted violation at '${suppression.path}' is still suppressed — ` +
191
+ (project ? `owning project '${project}'` : "retire it or confirm the reason"),
192
+ });
193
+ }
194
+ for (const note of current.intentNotes ?? []) {
195
+ entries.push({
196
+ source: note,
197
+ kind: "aspirational-gap",
198
+ severity: "low",
199
+ age: 0,
200
+ count: 1,
201
+ remediationHint:
202
+ "an optional allowed row is not yet built — either build it or remove the row",
203
+ });
204
+ }
205
+
206
+ // Which projects hold an accepted waiver — so a drift finding in the same
207
+ // project is a waiver-return-to-FAIL, and it is never hidden behind the
208
+ // suppression (`../../../../AGENTS.md`). The waiver stays listed; the drift
209
+ // finding is the current fact and ranks high, loudly.
210
+ /** @type {Set<string>} */
211
+ const waiverProjects = new Set();
212
+ for (const suppression of current.suppressions ?? []) {
213
+ const project = owningProjectForPath(suppression.path, byName);
214
+ if (project) waiverProjects.add(project);
215
+ }
216
+
217
+ for (const finding of current.findings ?? []) {
218
+ const project = typeof finding.source === "string" ? finding.source : null;
219
+ const waiverFailed = project !== null && waiverProjects.has(project);
220
+ entries.push({
221
+ source: finding.source ?? finding.message,
222
+ kind: "drift",
223
+ severity: waiverFailed ? "high" : "medium",
224
+ age: project ? ageOf(project) : 0,
225
+ count: 1,
226
+ remediationHint: waiverFailed
227
+ ? `this drift finding is in a project with an accepted waiver — the accepted violation is failing again, resolve it or remove the waiver`
228
+ : "a dependency the intent forbids (or allows but is not built) — resolve the contradiction",
229
+ });
230
+ }
231
+ for (const unresolved of current.unresolved ?? []) {
232
+ entries.push({
233
+ source: unresolved.boundary,
234
+ kind: "unresolved",
235
+ severity: "unknown",
236
+ age: 0,
237
+ count: 1,
238
+ remediationHint:
239
+ "an intent boundary matched no observed project — the intent cannot be verified",
240
+ });
241
+ }
242
+
243
+ entries.sort((a, b) =>
244
+ a.kind !== b.kind
245
+ ? a.kind < b.kind
246
+ ? -1
247
+ : 1
248
+ : a.source < b.source
249
+ ? -1
250
+ : a.source > b.source
251
+ ? 1
252
+ : 0,
253
+ );
254
+
255
+ // `expired-waiver` is a real `kind` an entry above can carry (a lapsed
256
+ // waiver, F06) — seeding every kind an entry can hold is what keeps
257
+ // `byKind[entry.kind] += 1` from landing on `undefined + 1` (`NaN`, which
258
+ // serializes as JSON `null`) for that one kind, and what keeps
259
+ // `sum(Object.values(byKind)) === total` true for every entry set.
260
+ const byKind = {
261
+ waiver: 0,
262
+ "expired-waiver": 0,
263
+ "aspirational-gap": 0,
264
+ drift: 0,
265
+ unresolved: 0,
266
+ };
267
+ const bySeverity = { high: 0, medium: 0, low: 0 };
268
+ for (const entry of entries) {
269
+ byKind[entry.kind] += 1;
270
+ if (entry.severity !== "unknown") bySeverity[entry.severity] += 1;
271
+ }
272
+
273
+ return { entries, total: entries.length, byKind, bySeverity, agings, sampleTime };
274
+ }
@@ -0,0 +1,423 @@
1
+ /**
2
+ * Candidate-architecture proposal — concrete facts, concrete candidates, no
3
+ * LLM anywhere in the core path.
4
+ *
5
+ * This module derives candidate components, candidate boundary assertions,
6
+ * candidate tag vocabularies and candidate rules from the same observed graph
7
+ * `graph`/`drift`/`check` read — `src/commands/graph.mjs`'s `buildProjects` and
8
+ * `buildDependencies` supply the `projects`/`edges` inputs. Every candidate
9
+ * carries the evidence that produced it and an uncertainty marker, so a reader
10
+ * can tell a strongly-implied structure from a weakly-suggested one.
11
+ *
12
+ * ## Proposal-only, and how that stays true
13
+ *
14
+ * The evaluator is pure: it takes an observed model and returns a proposal
15
+ * object. It never reads a file, never writes a file, and never imports
16
+ * `src/architecture-intent/model.mjs` — the module that LOADS a declaration.
17
+ * Whether a proposal later becomes intent is a governance decision owned
18
+ * elsewhere (`discover --propose` marks every candidate `proposed: true` and
19
+ * `notAuthoritative: true`, and never writes `architecture-intent.json`);
20
+ * this module cannot even express the write.
21
+ *
22
+ * ## The component model
23
+ *
24
+ * A component is a top-level directory grouping — the `projects[].root`'s
25
+ * first path segment, or `""` at the tree root. One component model, shared
26
+ * by every candidate kind, so the components a proposal names are the same
27
+ * components its assertions and rules reason about.
28
+ * ponytail: deeper directory prefixes (`libs/domain` vs `libs/adapters`) are
29
+ * not split out; when a workspace's nested structure diverges from its
30
+ * top-level one, the top-level grouping still surfaces the divergence as
31
+ * cross-component edges, which the assertion and rule candidates carry.
32
+ *
33
+ * ## The uncertainty marker
34
+ *
35
+ * Every candidate carries `confidence: "high"|"medium"|"low"`:
36
+ * - **high** — a direct observation of a structure the intent grammar can
37
+ * state (a tag the projects themselves carry, in the majority);
38
+ * - **medium** — a claim derived from observations (a directory grouping is a
39
+ * "component", an edge crosses a "component" boundary);
40
+ * - **low** — the evaluator's own vocabulary suggestion, which nothing
41
+ * observed states (the `scope:` axis implied by `scope:foo` tag shapes).
42
+ *
43
+ * The marker is bounded by construction — three values, assigned
44
+ * deterministically from what was measured, never from the tree's own text.
45
+ *
46
+ * ## Determinism
47
+ *
48
+ * All leaves sort by plain string comparison, never `localeCompare`, so two
49
+ * runs over an unchanged tree produce byte-identical proposals. The evaluator
50
+ * re-sorts everything it builds; `projects`/`edges` order is irrelevant.
51
+ *
52
+ * ## The empty-result invariant
53
+ *
54
+ * A workspace with zero projects yields the empty proposal with `unknown:
55
+ * true`, never fabricated candidates: nothing observed means nothing to
56
+ * propose. Each candidate list carries `total` alongside `items`, so an
57
+ * absent list is never ambiguous with a list the evaluator failed to build.
58
+ */
59
+
60
+ /**
61
+ * The uncertainty marker vocabulary — three values, the bound the "bounded
62
+ * uncertainty markers" test asserts.
63
+ */
64
+ export const CONFIDENCE = Object.freeze(["high", "medium", "low"]);
65
+
66
+ /**
67
+ * The component model: every project's root's first path segment (`""` at the
68
+ * tree root), keyed deterministically. Members sorted by project name, keys
69
+ * sorted by string comparison.
70
+ *
71
+ * @param {{name: string, root: string, type?: string, tags: string[]}[]} projects
72
+ * From `src/commands/graph.mjs`'s `buildProjects`.
73
+ * @returns {Map<string, {name: string, root: string, tags: string[]}[]>}
74
+ */
75
+ export function componentsByDirectory(projects) {
76
+ const buckets = new Map();
77
+ for (const project of projects) {
78
+ const component = project.root === "" ? "" : project.root.split("/")[0];
79
+ let members = buckets.get(component);
80
+ if (!members) {
81
+ members = [];
82
+ buckets.set(component, members);
83
+ }
84
+ members.push({ name: project.name, root: project.root, tags: project.tags });
85
+ }
86
+ for (const members of buckets.values()) {
87
+ members.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
88
+ }
89
+ return new Map([...buckets.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
90
+ }
91
+
92
+ /**
93
+ * The observed tags a strictly majority of a component's projects share — the
94
+ * strongest evidence a tag axis exists: the projects themselves agree on it at
95
+ * discovery time, so proposing it is a concrete fact, not a guess. Majority
96
+ * (strictly more than half) rather than a threshold, so any pair of projects
97
+ * that shares a tag is still proposed (2/3 shares, 1/2 does not) — the least
98
+ * judgment the evidence supports.
99
+ *
100
+ * @param {Map<string, {name: string, tags: string[]}[]>} components From `componentsByDirectory`.
101
+ * @returns {{tag: string, component: string, members: string[]}[]} Sorted.
102
+ */
103
+ export function dominantTags(components) {
104
+ const tags = [];
105
+ for (const [component, members] of components) {
106
+ if (members.length < 2) continue;
107
+ const votes = new Map();
108
+ for (const member of members) {
109
+ for (const tag of member.tags) {
110
+ votes.set(tag, (votes.get(tag) ?? 0) + 1);
111
+ }
112
+ }
113
+ for (const [tag, count] of votes) {
114
+ if (count > members.length / 2) {
115
+ // `members` is the tag's BEARERS, not the whole component — the evidence
116
+ // is that exactly these projects carry it, in a strict majority.
117
+ const bearers = members.filter((member) => member.tags.includes(tag)).map((m) => m.name);
118
+ tags.push({ tag, component, members: bearers });
119
+ }
120
+ }
121
+ }
122
+ return tags.sort((a, b) => {
123
+ if (a.component !== b.component) return a.component < b.component ? -1 : 1;
124
+ if (a.tag !== b.tag) return a.tag < b.tag ? -1 : 1;
125
+ return 0;
126
+ });
127
+ }
128
+
129
+ /**
130
+ * The candidate vocabulary from a tag's own shape — `scope:foo` implies a
131
+ * `scope:` axis, `layer:bar` a `layer:` one. The axis a tag already carries is
132
+ * the least-assuming vocabulary candidate there is: it proposes no new word,
133
+ * only names the axis the tags themselves spell.
134
+ *
135
+ * @param {{name: string, root: string, type?: string, tags: string[]}[]} projects
136
+ * @returns {{axis: string, values: string[]}[]} Sorted by axis.
137
+ */
138
+ export function tagAxes(projects) {
139
+ const byAxis = new Map();
140
+ for (const project of projects) {
141
+ for (const tag of project.tags) {
142
+ const colon = tag.indexOf(":");
143
+ if (colon <= 0 || colon === tag.length - 1) continue;
144
+ const axis = tag.slice(0, colon);
145
+ const value = tag.slice(colon + 1);
146
+ let values = byAxis.get(axis);
147
+ if (!values) {
148
+ values = [];
149
+ byAxis.set(axis, values);
150
+ }
151
+ if (!values.includes(value)) values.push(value);
152
+ }
153
+ }
154
+ return Array.from(byAxis.entries())
155
+ .map(([axis, values]) => ({
156
+ axis,
157
+ values: values.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
158
+ }))
159
+ .sort((a, b) => (a.axis < b.axis ? -1 : a.axis > b.axis ? 1 : 0));
160
+ }
161
+
162
+ /**
163
+ * Whether two projects belong to the same component — the relationship a
164
+ * boundary assertion proposes ("these projects share a role") reads as "this
165
+ * pair of projects are both members of this candidate component".
166
+ *
167
+ * @param {Map<string, {name: string}[]>} components From `componentsByDirectory`.
168
+ * @param {string} source
169
+ * @param {string} target
170
+ * @returns {boolean}
171
+ */
172
+ export function sameComponent(components, source, target) {
173
+ for (const members of components.values()) {
174
+ if (members.some((m) => m.name === source) && members.some((m) => m.name === target)) {
175
+ return true;
176
+ }
177
+ }
178
+ return false;
179
+ }
180
+
181
+ /**
182
+ * The candidate boundary assertions: one per shared-directory component and
183
+ * one per observed cross-component edge, each carrying the exact evidence
184
+ * (component membership, or edge pair) that produced it.
185
+ *
186
+ * - **component** — "these projects share a role", proposed because they share
187
+ * a directory. The directory is observed; the shared role is the derived
188
+ * claim, hence medium confidence.
189
+ * - **edge** — "source and target belong to different components", proposed
190
+ * because an observed edge crosses the component boundary. An
191
+ * intra-component edge emits no assertion on purpose: it observes no
192
+ * boundary crossing, so proposing a relationship over it would be a
193
+ * fabrication.
194
+ *
195
+ * @param {{projects: {name: string, root: string, type?: string, tags: string[]}[],
196
+ * edges: {source: string, target: string, type: string}[]}} observed
197
+ * @returns {{kind: "edge"|"component", source?: string, target?: string,
198
+ * component?: string, evidence: object[], confidence: string}[]} Sorted.
199
+ */
200
+ export function boundaryAssertions({ projects, edges }) {
201
+ const components = componentsByDirectory(projects);
202
+ const projectNames = new Set(projects.map((p) => p.name));
203
+ /** @type {{kind: "edge"|"component", source: string|undefined, target: string|undefined,
204
+ * component: string|undefined, evidence: object[], confidence: "medium"}[]} */
205
+ const assertions = [];
206
+
207
+ for (const [component, members] of components) {
208
+ if (members.length < 2) continue;
209
+ assertions.push({
210
+ kind: "component",
211
+ source: members[0].name,
212
+ target: undefined,
213
+ component,
214
+ evidence: members.map((member) => ({
215
+ kind: "shared-directory",
216
+ project: member.name,
217
+ directory: component,
218
+ })),
219
+ confidence: "medium",
220
+ });
221
+ }
222
+
223
+ for (const edge of edges) {
224
+ if (!projectNames.has(edge.source) || !projectNames.has(edge.target)) continue;
225
+ if (sameComponent(components, edge.source, edge.target)) continue;
226
+ assertions.push({
227
+ kind: "edge",
228
+ source: edge.source,
229
+ target: edge.target,
230
+ component: undefined,
231
+ evidence: [
232
+ { kind: "observed-edge", source: edge.source, target: edge.target, type: edge.type },
233
+ ],
234
+ confidence: "medium",
235
+ });
236
+ }
237
+
238
+ return assertions.sort((a, b) => {
239
+ if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
240
+ if (a.source !== b.source) return a.source < b.source ? -1 : 1;
241
+ if (a.target !== b.target) return (a.target ?? "") < (b.target ?? "") ? -1 : 1;
242
+ return 0;
243
+ });
244
+ }
245
+
246
+ /**
247
+ * The candidate tag vocabulary: the majority-shared tags observed (the
248
+ * strongest evidence — the projects themselves already agree, hence high
249
+ * confidence), then the axes the tag strings spell (nothing observed states
250
+ * the axis is real governance, hence low). The evaluator never proposes a tag
251
+ * no project carries.
252
+ *
253
+ * @param {{name: string, root: string, type?: string, tags: string[]}[]} projects
254
+ * @returns {{kind: "observed"|"suggested", tag: string|undefined, axis: string|undefined,
255
+ * component: string|undefined, members: string[]|undefined, values: string[]|undefined,
256
+ * evidence: object[], confidence: string}[]} Sorted.
257
+ */
258
+ export function tagVocabulary(projects) {
259
+ const components = componentsByDirectory(projects);
260
+ /** @type {{kind: "observed"|"suggested", tag: string|undefined, axis: string|undefined,
261
+ * component: string|undefined, members: string[]|undefined, values: string[]|undefined,
262
+ * evidence: object[], confidence: "high"|"low"}[]} */
263
+ const candidates = [];
264
+
265
+ for (const { tag, component, members } of dominantTags(components)) {
266
+ candidates.push({
267
+ kind: "observed",
268
+ tag,
269
+ axis: undefined,
270
+ component,
271
+ members,
272
+ values: undefined,
273
+ evidence: [{ kind: "majority-shared-tag", tag, component, members }],
274
+ confidence: "high",
275
+ });
276
+ }
277
+
278
+ for (const { axis, values } of tagAxes(projects)) {
279
+ candidates.push({
280
+ kind: "suggested",
281
+ tag: undefined,
282
+ axis,
283
+ component: undefined,
284
+ members: undefined,
285
+ values,
286
+ evidence: [{ kind: "tag-shape", axis, values }],
287
+ confidence: "low",
288
+ });
289
+ }
290
+
291
+ return candidates.sort((a, b) => {
292
+ if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
293
+ const aKey = a.tag ?? a.axis;
294
+ const bKey = b.tag ?? b.axis;
295
+ if (aKey !== bKey) return aKey < bKey ? -1 : 1;
296
+ return 0;
297
+ });
298
+ }
299
+
300
+ /**
301
+ * The candidate rules: one `noDependency` candidate per observed
302
+ * cross-component edge — "source must not depend on target" is the rule that
303
+ * would make the observed separation real — and one `boundary` candidate per
304
+ * component assertion. Each carries the assertion's evidence and confidence.
305
+ *
306
+ * @param {ReturnType<typeof boundaryAssertions>} assertions
307
+ * @returns {{kind: "noDependency"|"boundary", source?: string, target?: string,
308
+ * component?: string, evidence: object[], confidence: string}[]} Sorted.
309
+ */
310
+ export function candidateRules(assertions) {
311
+ /** @type {{kind: "noDependency"|"boundary", source: string|undefined, target: string|undefined,
312
+ * component: string|undefined, evidence: object[], confidence: "medium"}[]} */
313
+ const rules = [];
314
+ for (const assertion of assertions) {
315
+ if (assertion.kind === "edge") {
316
+ rules.push({
317
+ kind: "noDependency",
318
+ source: assertion.source,
319
+ target: assertion.target,
320
+ component: undefined,
321
+ evidence: assertion.evidence,
322
+ confidence: "medium",
323
+ });
324
+ } else {
325
+ rules.push({
326
+ kind: "boundary",
327
+ source: undefined,
328
+ target: undefined,
329
+ component: assertion.component,
330
+ evidence: assertion.evidence,
331
+ confidence: "medium",
332
+ });
333
+ }
334
+ }
335
+ return rules.sort((a, b) => {
336
+ if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
337
+ if (a.source !== b.source) return (a.source ?? "") < (b.source ?? "") ? -1 : 1;
338
+ if (a.target !== b.target) return (a.target ?? "") < (b.target ?? "") ? -1 : 1;
339
+ return 0;
340
+ });
341
+ }
342
+
343
+ /**
344
+ * The full proposal over one observed model. Every candidate carries
345
+ * `proposed: true` and `notAuthoritative: true` — the two markers that this is
346
+ * a suggestion, never a decision.
347
+ *
348
+ * A zero-project model yields the empty proposal with `unknown: true`. Each
349
+ * list carries `total` so an absent candidate class is never ambiguous with a
350
+ * failed build.
351
+ *
352
+ * @param {{projects: {name: string, root: string, type?: string, tags: string[]}[],
353
+ * edges: {source: string, target: string, type: string}[]}} observed
354
+ * @returns {{proposed: boolean, notAuthoritative: boolean, unknown: boolean,
355
+ * observed: {projects: number, edges: number},
356
+ * components: {items: object[], total: number},
357
+ * boundaryAssertions: {items: object[], total: number},
358
+ * tagVocabulary: {items: object[], total: number},
359
+ * rules: {items: object[], total: number},
360
+ * uncertainty: {high: number, medium: number, low: number}}}
361
+ */
362
+ export function evaluateDiscovery(observed) {
363
+ const projects = observed.projects ?? [];
364
+ const edges = observed.edges ?? [];
365
+
366
+ const withMarker = (item) => ({ ...item, proposed: true, notAuthoritative: true });
367
+
368
+ if (projects.length === 0) {
369
+ return {
370
+ proposed: true,
371
+ notAuthoritative: true,
372
+ unknown: true,
373
+ observed: { projects: 0, edges: edges.length },
374
+ components: { items: [], total: 0 },
375
+ boundaryAssertions: { items: [], total: 0 },
376
+ tagVocabulary: { items: [], total: 0 },
377
+ rules: { items: [], total: 0 },
378
+ uncertainty: { high: 0, medium: 0, low: 0 },
379
+ };
380
+ }
381
+
382
+ const components = componentsByDirectory(projects);
383
+ // A candidate component is a directory grouping of two or more projects — a
384
+ // single-project group is "a project", not "a component", so it is not worth
385
+ // proposing. The grouping itself is derived, hence medium confidence.
386
+ const componentItems = [...components.entries()]
387
+ .filter(([, members]) => members.length >= 2)
388
+ .map(([component, members]) =>
389
+ withMarker({
390
+ name: component === "" ? "(root)" : component,
391
+ commonDirectory: component,
392
+ projects: members.map((m) => m.name),
393
+ evidence: members.map((member) => ({
394
+ kind: "shared-directory",
395
+ project: member.name,
396
+ directory: component,
397
+ })),
398
+ confidence: "medium",
399
+ }),
400
+ );
401
+
402
+ const assertions = boundaryAssertions({ projects, edges }).map(withMarker);
403
+ const tags = tagVocabulary(projects).map(withMarker);
404
+ const rules = candidateRules(assertions).map(withMarker);
405
+
406
+ const all = [...componentItems, ...assertions, ...tags, ...rules];
407
+
408
+ return {
409
+ proposed: true,
410
+ notAuthoritative: true,
411
+ unknown: false,
412
+ observed: { projects: projects.length, edges: edges.length },
413
+ components: { items: componentItems, total: componentItems.length },
414
+ boundaryAssertions: { items: assertions, total: assertions.length },
415
+ tagVocabulary: { items: tags, total: tags.length },
416
+ rules: { items: rules, total: rules.length },
417
+ uncertainty: {
418
+ high: all.filter((c) => c.confidence === "high").length,
419
+ medium: all.filter((c) => c.confidence === "medium").length,
420
+ low: all.filter((c) => c.confidence === "low").length,
421
+ },
422
+ };
423
+ }