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