@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,503 @@
1
+ /**
2
+ * Model ↔ Reality reconciliation — the pure, deterministic scoring half.
3
+ *
4
+ * `drift` (../commands/drift.mjs) reports the intended rows the observed
5
+ * architecture violates. Reconciliation is the two-sided mirror: it scores
6
+ * every intent row and every observed element against the canonical intent
7
+ * the workspace declared, so a reader sees not only what drifts but where the
8
+ * model itself is silent, stricter, or stale. It is READ-ONLY — proposing
9
+ * repair paths is the `--propose` face's job (../commands/reconcile.mjs), and
10
+ * neither ever writes back into architecture-intent.json. Authority stays
11
+ * with the intentional human or agent; this module reports divergence, never
12
+ * edits.
13
+ *
14
+ * This module is pure: it takes the normalized intent model
15
+ * (../architecture-intent/model.mjs), the observed project-model facts that
16
+ * `drift`'s `buildObserved` already computed, and the verdict the canonical
17
+ * judge (../architecture-intent/judge.mjs) emitted — it never re-scans the
18
+ * graph, never re-derives the observed side, and never re-derives a verdict
19
+ * (../commands/drift.mjs owns both derivations, and a second one would be a
20
+ * second truth). No I/O, no provider import.
21
+ *
22
+ * ## The state vocabulary
23
+ *
24
+ * Every scored element carries one of four states, decided by what the intent
25
+ * actually states — never by what it might mean:
26
+ *
27
+ * - `match` — the element agrees with the intent, or the intent does not
28
+ * govern that plane (silence is not a violation).
29
+ * - `absent` — the intent explicitly requires the element and it is not
30
+ * observed.
31
+ * - `unexpected` — the intent explicitly forbids the element and it is
32
+ * observed, or the intent governs a plane (an exhaustive allowlist, a
33
+ * declared existence model) and the element stands outside it.
34
+ * - `unknown` — the observed side could not be established (a whole file the
35
+ * analysis could not read), so no verdict is possible. The one state that
36
+ * must never read as a claim (`../../../../AGENTS.md`).
37
+ *
38
+ * `severity` ranks the divergence a proposal would repair: the higher the
39
+ * value, the earlier it sorts in a ranked candidate list (all `unexpected`
40
+ * at 4 outrank all `absent` at 3; `match` is 0). `unknown` carries
41
+ * `Infinity` — a verdict over an incomplete set outranks every number — and
42
+ * `confidence` then says `"unverifiable"` so a reader can tell "cannot
43
+ * establish" from "stated".
44
+ *
45
+ * Every output here is keyed deterministically: plain-string comparison
46
+ * everywhere (never `localeCompare`), the same total-order rule every other
47
+ * deterministic output in this package holds.
48
+ */
49
+ import { isWholeFileFailure } from "../analysis/source-util.mjs";
50
+ import { resolveMembers } from "../architecture-intent/selectors.mjs";
51
+
52
+ /**
53
+ * A collision-free key for one (boundaryFrom, boundaryTo) pair — matching
54
+ * `edgeKey` in `../architecture-intent/judge.mjs`. A plain `${a}${b}` join
55
+ * (as this used to be) lets two distinct pairs collide: `{from:"web",
56
+ * to:"app-core"}` and `{from:"web-app", to:"core"}` both join to
57
+ * `"webapp-core"`, so one divergent row's finding would be read back for the
58
+ * other and silently score a `match` where a real `intentForbiddenEdge` or
59
+ * `intentAllowedMissing` sits. `JSON.stringify` of each side is unambiguous
60
+ * for any strings a graph can name, unlike a delimiter join, which any
61
+ * delimiter could appear inside.
62
+ *
63
+ * @param {string} from
64
+ * @param {string} to
65
+ * @returns {string}
66
+ */
67
+ function boundaryKey(from, to) {
68
+ return `${JSON.stringify(from)}>${JSON.stringify(to)}`;
69
+ }
70
+
71
+ /** The severity a state earns — the sort key a ranked proposal list uses. */
72
+ export const SEVERITY_ORDER = Object.freeze({
73
+ unexpected: 4,
74
+ absent: 3,
75
+ match: 0,
76
+ unknown: Infinity,
77
+ });
78
+
79
+ /** A scored element's state, in the vocabulary the text and JSON reports share. */
80
+ export const ELEMENT_STATES = Object.freeze(["match", "absent", "unexpected", "unknown"]);
81
+
82
+ /**
83
+ * A scored element.
84
+ *
85
+ * @typedef {object} ScoredElement
86
+ * @property {string} plane One of `"project"`, `"edge"`, `"tag"`, `"boundary"`,
87
+ * or `"intent-row"`.
88
+ * @property {string} name The project name, `source → target`, tag value, boundary
89
+ * name, or intent row identity (`from → to`).
90
+ * @property {"match"|"absent"|"unexpected"|"unknown"} state
91
+ * @property {number} severity
92
+ * @property {string} classification A canonical judge message id, or `"match"`,
93
+ * or `"unanalyzed"`.
94
+ * @property {object|null} intentRow `{plane, index, kind, key}` — the intent row
95
+ * this element states, `null` when no row states it.
96
+ * @property {"stated"|"not governed"|"unverifiable"} confidence
97
+ */
98
+
99
+ /**
100
+ * The declared state of the intent's rows, reduced to the key sets the scoring
101
+ * functions read — the same rows `model.mjs` validated and `judge.mjs` judged,
102
+ * read the same way the file wrote them.
103
+ */
104
+ function intentKeys(intent) {
105
+ const projects = intent.projects ?? {};
106
+ const dependencies = intent.dependencies ?? {};
107
+ return {
108
+ requiredNames: new Set((projects.required ?? []).map((row) => row.name)),
109
+ forbiddenNames: new Set((projects.forbidden ?? []).map((row) => row.name)),
110
+ allowedEdges: new Set(
111
+ (dependencies.allowed ?? []).map((row) => `${row.source} → ${row.target}`),
112
+ ),
113
+ forbiddenEdges: new Set(
114
+ (dependencies.forbidden ?? []).map((row) => `${row.source} → ${row.target}`),
115
+ ),
116
+ tagRules: intent.forbiddenTags ?? [],
117
+ projectSectionDeclared: intent.projects !== undefined,
118
+ edgeAllowlistDeclared: (dependencies.allowed?.length ?? 0) > 0,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Scores one observed project and its required tags.
124
+ *
125
+ * A project is `match` when the intent requires it (it exists — the intent's
126
+ * existence statement is satisfied) or when the intent does not govern
127
+ * existence at all. When the intent's `projects` section IS declared, a
128
+ * project named in `projects.forbidden` is `unexpected`, and a project outside
129
+ * the declared model is `unexpected` too — a declared existence model is
130
+ * exhaustive, and a project it does not know is the model silently
131
+ * under-selecting reality.
132
+ *
133
+ * @param {object} project `{name, data: {tags}}` from `buildObserved`.
134
+ * @param {object} keys From `intentKeys`.
135
+ * @param {Map<string, string[]>} requiredTagsByProject
136
+ * @returns {{project: ScoredElement, tags: ScoredElement[]}}
137
+ */
138
+ export function scoreProject(project, keys, requiredTagsByProject) {
139
+ const tags = project.data?.tags ?? project.tags ?? [];
140
+ const requiredTags = requiredTagsByProject.get(project.name) ?? [];
141
+ const element = { plane: "project", name: project.name };
142
+
143
+ /** @type {ScoredElement["state"]} */
144
+ let state;
145
+ let classification;
146
+ /** @type {ScoredElement["confidence"]} */
147
+ let confidence;
148
+ if (keys.requiredNames.has(project.name)) {
149
+ state = "match";
150
+ classification = "match";
151
+ confidence = "stated";
152
+ } else if (keys.forbiddenNames.has(project.name)) {
153
+ state = "unexpected";
154
+ classification = "projectPresent";
155
+ confidence = "stated";
156
+ } else if (keys.projectSectionDeclared) {
157
+ state = "unexpected";
158
+ classification = "intentUnknownProject";
159
+ confidence = "stated";
160
+ } else {
161
+ state = "match";
162
+ classification = "match";
163
+ confidence = "not governed";
164
+ }
165
+
166
+ /** @type {ScoredElement[]} */
167
+ const tagScores = [];
168
+ for (const tag of requiredTags) {
169
+ if (!tags.includes(tag)) {
170
+ tagScores.push({
171
+ plane: "tag",
172
+ name: `${project.name} ${tag}`,
173
+ state: "absent",
174
+ severity: SEVERITY_ORDER.absent,
175
+ classification: "projectTagMissing",
176
+ intentRow: { plane: "project", index: 0, kind: "required", key: project.name },
177
+ confidence: "stated",
178
+ });
179
+ }
180
+ }
181
+
182
+ /** @type {ScoredElement} */
183
+ const projectScore = {
184
+ ...element,
185
+ state,
186
+ severity: SEVERITY_ORDER[state],
187
+ classification,
188
+ intentRow: null,
189
+ confidence,
190
+ };
191
+
192
+ return { project: projectScore, tags: tagScores };
193
+ }
194
+
195
+ /**
196
+ * Scores one observed edge.
197
+ *
198
+ * The edge plane has two governance modes. A `dependencies.forbidden` row bans
199
+ * a specific pair; a `dependencies.allowed` list, when present, is an
200
+ * exhaustive allowlist — every observed pair outside it is `unexpected`. The
201
+ * canonical judge's forbidden boundary rows and forbiddenTags rows report
202
+ * their witness edges as findings (`intentForbiddenEdge`,
203
+ * `tagDependencyForbidden`); those witnesses are passed in and scored as
204
+ * `unexpected` so a boundary violation is never scored "match" merely because
205
+ * the pair is not in a forbidden row. Every other pair is a match: silence in
206
+ * the edge plane is not a violation.
207
+ *
208
+ * @param {{source: string, target: string}} edge
209
+ * @param {object} keys From `intentKeys`.
210
+ * @param {Set<string>} intentForbiddenPairs Witness edges of forbidden boundary rows.
211
+ * @param {Set<string>} tagForbiddenPairs Witness edges of forbiddenTags rows.
212
+ * @returns {ScoredElement}
213
+ */
214
+ export function scoreEdge(edge, keys, intentForbiddenPairs, tagForbiddenPairs) {
215
+ const key = `${edge.source} → ${edge.target}`;
216
+ const element = { plane: "edge", name: key, intentRow: null };
217
+
218
+ if (keys.forbiddenEdges.has(key)) {
219
+ return {
220
+ ...element,
221
+ state: "unexpected",
222
+ severity: SEVERITY_ORDER.unexpected,
223
+ classification: "dependencyForbidden",
224
+ confidence: "stated",
225
+ };
226
+ }
227
+ if (keys.edgeAllowlistDeclared && !keys.allowedEdges.has(key)) {
228
+ return {
229
+ ...element,
230
+ state: "unexpected",
231
+ severity: SEVERITY_ORDER.unexpected,
232
+ classification: "dependencyNotAllowed",
233
+ confidence: "stated",
234
+ };
235
+ }
236
+ if (tagForbiddenPairs.has(key)) {
237
+ return {
238
+ ...element,
239
+ state: "unexpected",
240
+ severity: SEVERITY_ORDER.unexpected,
241
+ classification: "tagDependencyForbidden",
242
+ confidence: "stated",
243
+ };
244
+ }
245
+ if (intentForbiddenPairs.has(key)) {
246
+ return {
247
+ ...element,
248
+ state: "unexpected",
249
+ severity: SEVERITY_ORDER.unexpected,
250
+ classification: "intentForbiddenEdge",
251
+ confidence: "stated",
252
+ };
253
+ }
254
+ return {
255
+ ...element,
256
+ state: "match",
257
+ severity: SEVERITY_ORDER.match,
258
+ classification: "match",
259
+ confidence: "not governed",
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Scores the intent's own rows — the per-intent-row divergence metric. One
265
+ * score per row, in file order, so a ranked candidate list can name the row
266
+ * it would edit by `intentRow.index`.
267
+ *
268
+ * Boundary `allowed`/`forbidden` rows are scored from the canonical judge's
269
+ * findings (matched exactly by `from`/`to`): a `forbidden` row with an
270
+ * `intentForbiddenEdge` finding is `unexpected`, an `allowed` row with an
271
+ * `intentAllowedMissing` finding is `absent`. Project and dependency rows are
272
+ * scored directly against the observed names and edges. A
273
+ * `dependencies.allowed` row is an allowlist entry, not an existence claim —
274
+ * its absence in the graph is not divergence, so it scores `match` either
275
+ * way (the divergent direction is the observed edge outside the list, scored
276
+ * per-observed-element as `dependencyNotAllowed`).
277
+ *
278
+ * @param {object} intent The normalized intent model.
279
+ * @param {object} judgeVerdict `{findings}` from `judgeIntent`.
280
+ * @param {object} observed `{projects, edges}` from `buildObserved`.
281
+ * @param {Map<string, string[]>} tagsByProject The tags each observed project carries.
282
+ * @returns {ScoredElement[]}
283
+ */
284
+ export function scoreIntentRows(intent, judgeVerdict, observed, tagsByProject) {
285
+ const rows = [];
286
+ const observedNames = new Set(observed.projects.map((p) => p.name));
287
+ const observedEdgeKeys = new Set(observed.edges.map((e) => `${e.source} → ${e.target}`));
288
+
289
+ const boundaryFinding = new Map();
290
+ for (const finding of judgeVerdict.findings) {
291
+ if (finding.boundaryFrom === null) continue;
292
+ boundaryFinding.set(boundaryKey(finding.boundaryFrom, finding.boundaryTo), finding.rule);
293
+ }
294
+
295
+ let index = 0;
296
+ const row = (plane, name, state, classification, kind, key) => {
297
+ /** @type {ScoredElement} */
298
+ const entry = {
299
+ plane,
300
+ name,
301
+ state: /** @type {ScoredElement["state"]} */ (state),
302
+ severity: SEVERITY_ORDER[state],
303
+ classification,
304
+ intentRow: { plane, index, kind, key },
305
+ confidence: state === "unknown" ? "unverifiable" : "stated",
306
+ };
307
+ rows.push(entry);
308
+ index += 1;
309
+ return entry;
310
+ };
311
+
312
+ const projects = intent.projects ?? {};
313
+ for (const required of projects.required ?? []) {
314
+ row(
315
+ "project",
316
+ required.name,
317
+ observedNames.has(required.name) ? "match" : "absent",
318
+ observedNames.has(required.name) ? "match" : "projectMissing",
319
+ "required",
320
+ required.name,
321
+ );
322
+ }
323
+ for (const forbidden of projects.forbidden ?? []) {
324
+ row(
325
+ "project",
326
+ forbidden.name,
327
+ observedNames.has(forbidden.name) ? "unexpected" : "match",
328
+ observedNames.has(forbidden.name) ? "projectPresent" : "match",
329
+ "forbidden",
330
+ forbidden.name,
331
+ );
332
+ }
333
+
334
+ const dependencies = intent.dependencies ?? {};
335
+ for (const allowed of dependencies.allowed ?? []) {
336
+ const key = `${allowed.source} → ${allowed.target}`;
337
+ // An allowlist entry makes no existence claim — present or absent, it is
338
+ // the permission itself that is the statement, not the wiring.
339
+ row("edge", key, "match", "match", "allowed", key);
340
+ }
341
+ for (const forbidden of dependencies.forbidden ?? []) {
342
+ const key = `${forbidden.source} → ${forbidden.target}`;
343
+ row(
344
+ "edge",
345
+ key,
346
+ observedEdgeKeys.has(key) ? "unexpected" : "match",
347
+ observedEdgeKeys.has(key) ? "dependencyForbidden" : "match",
348
+ "forbidden",
349
+ key,
350
+ );
351
+ }
352
+
353
+ for (const tagRow of intent.forbiddenTags ?? []) {
354
+ const key = `${tagRow.from} → ${tagRow.to}`;
355
+ const violated = observed.edges.some((e) => {
356
+ if (e.source === e.target) return false;
357
+ const sourceTags = tagsByProject.get(e.source) ?? [];
358
+ const targetTags = tagsByProject.get(e.target) ?? [];
359
+ return sourceTags.includes(tagRow.from) && targetTags.includes(tagRow.to);
360
+ });
361
+ row(
362
+ "tag",
363
+ key,
364
+ violated ? "unexpected" : "match",
365
+ violated ? "tagDependencyForbidden" : "match",
366
+ "tag-forbidden",
367
+ key,
368
+ );
369
+ }
370
+
371
+ for (const allowed of intent.allowed ?? []) {
372
+ const rule = boundaryFinding.get(boundaryKey(allowed.from, allowed.to));
373
+ row(
374
+ "intent-row",
375
+ `${allowed.from} → ${allowed.to}`,
376
+ rule === "intentAllowedMissing" ? "absent" : "match",
377
+ rule === "intentAllowedMissing" ? "intentAllowedMissing" : "match",
378
+ "allowed",
379
+ `${allowed.from} → ${allowed.to}`,
380
+ );
381
+ }
382
+ for (const forbidden of intent.forbidden ?? []) {
383
+ const rule = boundaryFinding.get(boundaryKey(forbidden.from, forbidden.to));
384
+ row(
385
+ "intent-row",
386
+ `${forbidden.from} → ${forbidden.to}`,
387
+ rule === "intentForbiddenEdge" ? "unexpected" : "match",
388
+ rule === "intentForbiddenEdge" ? "intentForbiddenEdge" : "match",
389
+ "forbidden",
390
+ `${forbidden.from} → ${forbidden.to}`,
391
+ );
392
+ }
393
+
394
+ return rows;
395
+ }
396
+
397
+ /**
398
+ * Scores a whole observed set against the intent.
399
+ *
400
+ * `judgeVerdict` is the canonical judge's output and `observed` the fact set
401
+ * `buildObserved` built — both provided, never re-derived. Whole-file analysis
402
+ * failures shadow every project score as `unknown`, so a partial read can
403
+ * never render as a claim (`../../../../AGENTS.md`).
404
+ *
405
+ * @param {object} intent The normalized intent model.
406
+ * @param {object} judgeVerdict `{findings}` from `judgeIntent`.
407
+ * @param {object} observed `{projects, edges}` from `buildObserved` (the
408
+ * project facts carry `name`, `root` (on `data`), and `tags` (on `data`)).
409
+ * @param {object} analysis `{failures}` from the command context.
410
+ * @returns {{projects: ScoredElement[], edges: ScoredElement[], tags: ScoredElement[],
411
+ * boundaries: ScoredElement[], intentRows: ScoredElement[], unknownFiles: object[]}}
412
+ */
413
+ export function reconcileScores(intent, judgeVerdict, observed, analysis) {
414
+ const tagsByProject = new Map();
415
+ for (const project of observed.projects) {
416
+ tagsByProject.set(project.name, project.data?.tags ?? project.tags ?? []);
417
+ }
418
+
419
+ const keys = intentKeys(intent);
420
+ const requiredTagsByProject = new Map(
421
+ (intent.projects?.required ?? []).map((row) => [row.name, row.tags ?? []]),
422
+ );
423
+
424
+ const projects = [];
425
+ const tags = [];
426
+ for (const project of observed.projects) {
427
+ const { project: score, tags: tagScores } = scoreProject(project, keys, requiredTagsByProject);
428
+ projects.push(score);
429
+ tags.push(...tagScores);
430
+ }
431
+ projects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
432
+ tags.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
433
+
434
+ // The canonical judge's witness edges — the boundary and tag rules it
435
+ // reported as violating pairs.
436
+ const intentForbiddenPairs = new Set();
437
+ const tagForbiddenPairs = new Set();
438
+ for (const finding of judgeVerdict.findings) {
439
+ if (finding.source === null || finding.target === null) continue;
440
+ const pair = `${finding.source} → ${finding.target}`;
441
+ if (finding.rule === "tagDependencyForbidden") tagForbiddenPairs.add(pair);
442
+ else if (finding.rule === "intentForbiddenEdge") intentForbiddenPairs.add(pair);
443
+ }
444
+
445
+ const edges = observed.edges
446
+ .map((edge) => scoreEdge(edge, keys, intentForbiddenPairs, tagForbiddenPairs))
447
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
448
+
449
+ const intentRows = scoreIntentRows(intent, judgeVerdict, observed, tagsByProject);
450
+
451
+ const boundaries = (intent.boundaries ?? [])
452
+ .map((boundary) => {
453
+ const members = resolveMembers(boundary.match, observedNodes(observed));
454
+ return {
455
+ plane: "boundary",
456
+ name: boundary.name,
457
+ state: members.length > 0 ? "match" : "unknown",
458
+ severity: members.length > 0 ? SEVERITY_ORDER.match : SEVERITY_ORDER.unknown,
459
+ classification: members.length > 0 ? "match" : "intentUnknownProject",
460
+ intentRow: null,
461
+ confidence: members.length > 0 ? "stated" : "unverifiable",
462
+ };
463
+ })
464
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
465
+
466
+ // Whole-file failures shadow the whole observed side: a verdict over a tree
467
+ // it could not fully read must never read as a claim.
468
+ const unknownFiles = analysis.failures
469
+ .filter(isWholeFileFailure)
470
+ .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
471
+ if (unknownFiles.length > 0) {
472
+ for (const score of projects) {
473
+ score.state = "unknown";
474
+ score.severity = SEVERITY_ORDER.unknown;
475
+ score.classification = "unanalyzed";
476
+ score.confidence = "unverifiable";
477
+ }
478
+ }
479
+
480
+ return { projects, edges, tags, boundaries, intentRows, unknownFiles };
481
+ }
482
+
483
+ /**
484
+ * The `{name: {data: {root, tags}}}` node shape `resolveMembers` reads, from
485
+ * the observed project facts — the one place this module reconstructs a graph
486
+ * shape, and it reconstructs only the fields the selector engine reads.
487
+ *
488
+ * @param {object} observed From `buildObserved`.
489
+ * @returns {Record<string, {data: {root?: string, tags: string[]}}>}
490
+ */
491
+ function observedNodes(observed) {
492
+ /** @type {Record<string, {data: {root?: string, tags: string[]}}>} */
493
+ const nodes = {};
494
+ for (const project of observed.projects) {
495
+ nodes[project.name] = {
496
+ data: {
497
+ root: project.data?.root ?? "",
498
+ tags: project.data?.tags ?? project.tags ?? [],
499
+ },
500
+ };
501
+ }
502
+ return nodes;
503
+ }