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