@ecoma-io/archkeep 0.13.0 → 0.15.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 (37) hide show
  1. package/README.md +9 -3
  2. package/cli.mjs +599 -55
  3. package/commands.mjs +51 -0
  4. package/package.json +3 -1
  5. package/src/analysis/typescript.mjs +2 -1
  6. package/src/commands/README.md +70 -1
  7. package/src/commands/change-intent.mjs +461 -0
  8. package/src/commands/change.mjs +612 -0
  9. package/src/commands/check.mjs +84 -17
  10. package/src/commands/context.mjs +92 -16
  11. package/src/commands/coverage-acceptance.mjs +113 -0
  12. package/src/commands/custom-rules.mjs +286 -2
  13. package/src/commands/delta-classify.mjs +664 -0
  14. package/src/commands/delta-snapshot.mjs +672 -0
  15. package/src/commands/delta.mjs +606 -0
  16. package/src/commands/diff.mjs +41 -13
  17. package/src/commands/evolution.mjs +473 -0
  18. package/src/commands/explain.mjs +39 -0
  19. package/src/commands/history.mjs +130 -103
  20. package/src/commands/policy.mjs +93 -1
  21. package/src/commands/trajectory.mjs +437 -0
  22. package/src/commands/waivers.mjs +53 -3
  23. package/src/config.mjs +129 -11
  24. package/src/lsp/boundary-config.mjs +9 -4
  25. package/src/path-util.mjs +40 -0
  26. package/src/providers/native/model.mjs +17 -0
  27. package/src/report/change-text.mjs +148 -0
  28. package/src/report/delta-text.mjs +264 -0
  29. package/src/report/evolution-text.mjs +83 -0
  30. package/src/report/explain-text.mjs +27 -0
  31. package/src/report/history-text.mjs +4 -114
  32. package/src/report/sarif.mjs +280 -0
  33. package/src/report/snapshot-text.mjs +123 -0
  34. package/src/report/text.mjs +36 -0
  35. package/src/report/trajectory-text.mjs +143 -0
  36. package/src/report/waivers-text.mjs +35 -2
  37. package/src/tsconfig-paths.mjs +3 -2
@@ -0,0 +1,612 @@
1
+ /**
2
+ * The `change` command: a declared change-intent contract reconciled against
3
+ * the actual architectural delta.
4
+ *
5
+ * Three questions stay separate in this repository, and this module exists to
6
+ * keep them that way. Whether the tree obeys the workspace's long-lived law is
7
+ * `check`'s verdict. Why an architectural state arose is history's question.
8
+ * THIS command answers only: **did the change produce exactly the material
9
+ * architectural consequences its contract declared?** A change can be
10
+ * policy-compliant but undeclared, policy-invalid but intent-matched, both,
11
+ * or neither — every combination is reported on independent axes, and none is
12
+ * collapsed into another.
13
+ *
14
+ * The material delta is not recomputed here. It is `./diff.mjs`'s
15
+ * `computeDiff` over the baseline evidence snapshot's stored graph and the
16
+ * head graph — projects added/removed/changed, edges added/removed — so a
17
+ * "material architectural change" means exactly what `diff` has always meant,
18
+ * never this module's second opinion about which edits count. Constraint
19
+ * evaluation re-judges BOTH sides through the same engine under ONE current
20
+ * law (`../rules/index.mjs` + `./delta-classify.mjs`, the `delta` arrangement)
21
+ * and counts cycles through `../governance/fitness-rules.mjs`'s
22
+ * `cyclicProjects`; no verdict below re-derives what those functions already
23
+ * answer. The workspace-law axis is informational — computed through the same
24
+ * engine, labeled as such, and never folded into this command's exit code:
25
+ * `check` remains the authority on the law.
26
+ *
27
+ * ## Verdicts, and why unfulfilled is its own state
28
+ *
29
+ * - `matched` — every declared fact is observed, nothing else changed.
30
+ * - `undeclared` — at least one OBSERVED material change no declaration
31
+ * covers. A review signal, not a governance failure: the result may still
32
+ * be legal architecture that simply was not promised.
33
+ * - `unfulfilled` — nothing undeclared, but at least one declared change
34
+ * never happened. Proven divergence between plan and outcome — not a
35
+ * failure to look, and never read as matched.
36
+ * - `unproven` — the base identity could not be established (the manifest's
37
+ * `base.commit` against the baseline's provenance), so no comparison this
38
+ * run makes can be attached to the architectures the author saw.
39
+ * Unproven MUST NOT become matched; when it holds, constraint rows are
40
+ * left unevaluated rather than reported over a base this run cannot vouch
41
+ * for.
42
+ *
43
+ * When unexpected and missing expectations coexist, the verdict reads
44
+ * `undeclared` — the surprise is the reviewer's first question — and both
45
+ * lists are always present in full, so precedence hides nothing.
46
+ *
47
+ * ## Exit fold
48
+ *
49
+ * `undeclared` or `unfulfilled`, or a failed declared constraint, is a
50
+ * finding (exit 1) — the fourth verb whose verdict carries it, beside
51
+ * `check`, `fitness` and `delta`. An unproven identity or an undeterminable
52
+ * constraint is a no-verdict (exit 3). Matched with every declared constraint
53
+ * passing is ok (exit 0). The workspace-law axis rides the envelope as
54
+ * evidence only.
55
+ *
56
+ * Refusals (each a throw → exit 3 upstream): a manifest that fails shape or
57
+ * reference validation, an unreadable/malformed/foreign-schema baseline,
58
+ * incomplete baseline coverage, a provider mismatch, incomplete head
59
+ * coverage, an unregistered-plugin graph over polyglot manifests, and a run
60
+ * with no boundary law (constraints and the law fingerprint need one).
61
+ *
62
+ * This module computes and returns; `../../cli.mjs`'s `runChange` owns argv,
63
+ * output destination and the process exit code (`./README.md`).
64
+ */
65
+ import { classifyDelta } from "./delta-classify.mjs";
66
+ import { computeDiff } from "./diff.mjs";
67
+ import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
68
+ import {
69
+ CONSTRAINT_ORDER,
70
+ CONSTRAINT_ROW_NAMES,
71
+ findChangeIntentReferenceViolations,
72
+ readChangeIntent,
73
+ } from "./change-intent.mjs";
74
+ import {
75
+ evidenceGraphToProjectGraph,
76
+ refuseUnjudgeableHead,
77
+ sourceProjectAttributor,
78
+ } from "./delta.mjs";
79
+ import { providerMismatch, readEvidenceSnapshot } from "./delta-snapshot.mjs";
80
+ import { cyclicProjects } from "../governance/fitness-rules.mjs";
81
+ import { fitnessVerdict } from "../governance/verdict.mjs";
82
+ import { buildDecision } from "../report/evidence.mjs";
83
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
84
+ import { formatChangeReport } from "../report/change-text.mjs";
85
+ import { evaluateRun } from "../rules/index.mjs";
86
+ import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
87
+ import { resolveProvenance } from "./provenance.mjs";
88
+
89
+ /**
90
+ * One expected-fact row as the report and JSON carry it. Kept in one builder
91
+ * so every list entry carries the same five-field vocabulary and a consumer
92
+ * branches on `kind` alone.
93
+ *
94
+ * @param {{kind: string, project?: string, from?: string, to?: string,
95
+ * type?: string, changes?: object[]}} fields
96
+ * @returns {object}
97
+ */
98
+ const factRow = ({ kind, ...rest }) => ({ kind, ...rest });
99
+
100
+ /** Plain lexicographic comparison — never localeCompare (byte-determinism). */
101
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
102
+
103
+ /** Sort key for fact rows: kind first, then the identity fields in order. */
104
+ function cmpFacts(a, b) {
105
+ return (
106
+ cmp(a.kind, b.kind) ||
107
+ cmp(a.project ?? "", b.project ?? "") ||
108
+ cmp(a.from ?? "", b.from ?? "") ||
109
+ cmp(a.to ?? "", b.to ?? "") ||
110
+ cmp(a.type ?? "", b.type ?? "")
111
+ );
112
+ }
113
+
114
+ /**
115
+ * Reconciles a normalized change-intent contract against the structural diff
116
+ * of the two graphs. Pure — the heart of the command, and the function a
117
+ * future capability (trend context, predicted risk) composes rather than
118
+ * replaces.
119
+ *
120
+ * Edge expectations match on `(from, to)` alone, deliberately: whether the
121
+ * graph emits the dependency as a `static` or `dynamic` edge is the model's
122
+ * spelling, not the author's promise, and requiring it would force the agent
123
+ * to predict implementation detail. The observed type rides the row either
124
+ * way. A project whose tags/type/root CHANGED between the sides has no
125
+ * declaration surface in this version — any such row lands in
126
+ * `unexpected` (`kind: "project-changed"`), the loud direction, because a
127
+ * tag change alters which rows of the boundary law reach the project.
128
+ *
129
+ * @param {object} intent A `parseChangeIntent` result.
130
+ * @param {{addedProjects: object[], removedProjects: object[],
131
+ * changedProjects: object[], addedEdges: object[], removedEdges: object[]}}
132
+ * delta `computeDiff`'s output for baseline vs head.
133
+ * @returns {{matched: object[], unexpected: object[], missingExpected: object[]}}
134
+ */
135
+ export function reconcileMaterialDelta(intent, delta) {
136
+ const addedProjectNames = new Set(intent.projects.add);
137
+ const removedProjectNames = new Set(intent.projects.remove);
138
+ const edgeKey = ({ from, to }) => `${from}\u0000${to}`;
139
+ const addEdgeKeys = new Set(intent.edges.add.map(edgeKey));
140
+ const removeEdgeKeys = new Set(intent.edges.remove.map(edgeKey));
141
+
142
+ /** @type {object[]} */
143
+ const matched = [];
144
+ /** @type {object[]} */
145
+ const unexpected = [];
146
+
147
+ for (const project of delta.addedProjects) {
148
+ const row = factRow({ kind: "project-added", project: project.name });
149
+ if (addedProjectNames.has(project.name)) matched.push(row);
150
+ else unexpected.push(row);
151
+ }
152
+ for (const project of delta.removedProjects) {
153
+ const row = factRow({ kind: "project-removed", project: project.name });
154
+ if (removedProjectNames.has(project.name)) matched.push(row);
155
+ else unexpected.push(row);
156
+ }
157
+ for (const edge of delta.addedEdges) {
158
+ const row = factRow({
159
+ kind: "edge-added",
160
+ from: edge.source,
161
+ to: edge.target,
162
+ type: edge.type,
163
+ });
164
+ if (addEdgeKeys.has(edgeKey({ from: edge.source, to: edge.target }))) matched.push(row);
165
+ else unexpected.push(row);
166
+ }
167
+ for (const edge of delta.removedEdges) {
168
+ const row = factRow({
169
+ kind: "edge-removed",
170
+ from: edge.source,
171
+ to: edge.target,
172
+ type: edge.type,
173
+ });
174
+ if (removeEdgeKeys.has(edgeKey({ from: edge.source, to: edge.target }))) matched.push(row);
175
+ else unexpected.push(row);
176
+ }
177
+ // No declaration surface in this version: a metadata change to a project
178
+ // both sides have is always a surprise. Loud by construction — the silent
179
+ // alternative would be dropping it, and a tag change moves the law.
180
+ for (const changed of delta.changedProjects) {
181
+ unexpected.push(
182
+ factRow({
183
+ kind: "project-changed",
184
+ project: changed.name,
185
+ changes: changed.changes,
186
+ }),
187
+ );
188
+ }
189
+
190
+ const observedAdded = new Set(delta.addedProjects.map((p) => p.name));
191
+ const observedRemoved = new Set(delta.removedProjects.map((p) => p.name));
192
+ const observedAddEdges = new Set(
193
+ delta.addedEdges.map((edge) => edgeKey({ from: edge.source, to: edge.target })),
194
+ );
195
+ const observedRemoveEdges = new Set(
196
+ delta.removedEdges.map((edge) => edgeKey({ from: edge.source, to: edge.target })),
197
+ );
198
+
199
+ // Declared but never observed — the unfulfilled half, kept apart from
200
+ // `unexpected` because "the change did extra" and "the change skipped its
201
+ // own plan" are different review conversations.
202
+ /** @type {object[]} */
203
+ const missingExpected = [];
204
+ for (const name of intent.projects.add) {
205
+ if (!observedAdded.has(name))
206
+ missingExpected.push(factRow({ kind: "project-added", project: name }));
207
+ }
208
+ for (const name of intent.projects.remove) {
209
+ if (!observedRemoved.has(name)) {
210
+ missingExpected.push(factRow({ kind: "project-removed", project: name }));
211
+ }
212
+ }
213
+ for (const edge of intent.edges.add) {
214
+ if (!observedAddEdges.has(edgeKey(edge))) {
215
+ missingExpected.push(factRow({ kind: "edge-added", from: edge.from, to: edge.to }));
216
+ }
217
+ }
218
+ for (const edge of intent.edges.remove) {
219
+ if (!observedRemoveEdges.has(edgeKey(edge))) {
220
+ missingExpected.push(factRow({ kind: "edge-removed", from: edge.from, to: edge.to }));
221
+ }
222
+ }
223
+
224
+ return {
225
+ matched: matched.sort(cmpFacts),
226
+ unexpected: unexpected.sort(cmpFacts),
227
+ missingExpected: missingExpected.sort(cmpFacts),
228
+ };
229
+ }
230
+
231
+ /**
232
+ * The reconciliation verdict from its three lists — pure, and the one place
233
+ * the precedence is stated. Precedence hides nothing: every list rides the
234
+ * envelope in full whatever the verdict says.
235
+ *
236
+ * @param {{unexpected: object[], missingExpected: object[]}} lists
237
+ * @param {string[]} unprovenReasons Why the base identity could not be proven.
238
+ * @returns {"matched"|"undeclared"|"unfulfilled"|"unproven"}
239
+ */
240
+ export function reconciliationVerdict(lists, unprovenReasons) {
241
+ if (unprovenReasons.length > 0) return "unproven";
242
+ if (lists.unexpected.length > 0) return "undeclared";
243
+ if (lists.missingExpected.length > 0) return "unfulfilled";
244
+ return "matched";
245
+ }
246
+
247
+ /**
248
+ * Judges the constraints the contract declares, through the shared engine —
249
+ * both sides re-judged under the CURRENT law and one shared instant, exactly
250
+ * the `delta` arrangement, so a policy edit between capture and verify cannot
251
+ * fabricate a pass or a fail. Rows appear only for DECLARED constraints: an
252
+ * omitted key was never asserted, and inventing a row for it would report a
253
+ * verdict nobody asked for.
254
+ *
255
+ * @param {object} intent A `parseChangeIntent` result.
256
+ * @param {object} io Everything the judges share: the raw violation arrays
257
+ * and record arrays for both sides, the rebuilt base graph and the head
258
+ * graph, the current config, the shared instant, and the attribution
259
+ * function `classifyDelta` needs for unresolvable records.
260
+ * @returns {object[]} `fitnessVerdict` rows in `CONSTRAINT_ORDER` order.
261
+ */
262
+ function judgeDeclaredConstraints(intent, io) {
263
+ /** @type {object[]} */
264
+ const rows = [];
265
+ const introducedUnknown =
266
+ io.classification.violations.unknown.length + io.classification.unresolvable.unknown.length;
267
+ for (const constraint of CONSTRAINT_ORDER) {
268
+ if (intent.constraints[constraint] !== true) continue;
269
+ const name = CONSTRAINT_ROW_NAMES[constraint];
270
+ if (constraint === "noNewViolations") {
271
+ if (introducedUnknown > 0) {
272
+ rows.push(
273
+ fitnessVerdict({
274
+ verdict: "unknown",
275
+ name,
276
+ evidence: { introduced: io.classification.violations.introduced.length },
277
+ message:
278
+ `${introducedUnknown} delta item${introducedUnknown === 1 ? "" : "s"} could not ` +
279
+ "be classified, so the introduced set may be incomplete — an unknown item is " +
280
+ "never read as a clean introduction",
281
+ }),
282
+ );
283
+ continue;
284
+ }
285
+ const introducedNotWaived = io.classification.violations.introduced.filter(
286
+ (entry) => entry.waived !== true,
287
+ );
288
+ rows.push(
289
+ fitnessVerdict({
290
+ verdict: introducedNotWaived.length === 0 ? "pass" : "fail",
291
+ name,
292
+ evidence: {
293
+ introduced: io.classification.violations.introduced.length,
294
+ introducedWaived:
295
+ io.classification.violations.introduced.length - introducedNotWaived.length,
296
+ },
297
+ message:
298
+ introducedNotWaived.length === 0
299
+ ? "no non-waived violation was introduced between the captured base and this tree"
300
+ : `${introducedNotWaived.length} non-waived violation${introducedNotWaived.length === 1 ? "" : "s"} introduced since the captured base`,
301
+ }),
302
+ );
303
+ continue;
304
+ }
305
+ if (constraint === "noNewCycles") {
306
+ const newlyCyclic = io.cyclesHead.filter((project) => !io.cyclesBase.includes(project));
307
+ rows.push(
308
+ fitnessVerdict({
309
+ verdict: newlyCyclic.length === 0 ? "pass" : "fail",
310
+ name,
311
+ evidence: {
312
+ baseCyclicProjects: io.cyclesBase,
313
+ headCyclicProjects: io.cyclesHead,
314
+ newCyclicProjects: newlyCyclic,
315
+ },
316
+ message:
317
+ newlyCyclic.length === 0
318
+ ? "no project sits on a cycle at head that did not sit on one at the captured base"
319
+ : `${newlyCyclic.length} project${newlyCyclic.length === 1 ? " is" : "s are"} on a cycle at head that ${newlyCyclic.length === 1 ? "was" : "were"} acyclic at the captured base: ${newlyCyclic.join(", ")}`,
320
+ }),
321
+ );
322
+ }
323
+ }
324
+ return rows;
325
+ }
326
+
327
+ /**
328
+ * Runs the `change` command: loads the contract and the baseline, proves the
329
+ * base identity, computes the structural delta through `diff`'s own function,
330
+ * reconciles, judges declared constraints, and folds everything into a
331
+ * verdict the process exit code maps onto.
332
+ *
333
+ * @param {string} baselinePath Absolute path to the evidence snapshot
334
+ * (`archkeep delta --capture` output).
335
+ * @param {string} intentPath Absolute path to the change-intent manifest.
336
+ * @param {object} commandContext From `resolveCommandContext`.
337
+ * @param {{config?: object|null, readBaseline?: (path: string) => object,
338
+ * readIntent?: (path: string) => Promise<object>, now?: string}} [io]
339
+ * The resolved boundary config (required — constraints are judged under it
340
+ * and the fingerprint identifies the law), injectable readers so tests drive
341
+ * the command without disk, and the shared reference instant. Both readers
342
+ * return VALIDATED domain objects — `readEvidenceSnapshot`'s parse and
343
+ * `parseChangeIntent`'s normalized contract respectively — the same seam
344
+ * contract `deltaCommand`'s `readBaseline` states; an injector handing back
345
+ * raw file contents skips the grammar this command trusts.
346
+ * @returns {Promise<{status: "ok"|"findings"|"no-verdict",
347
+ * changeIntent: object, coverage: object, report: {text: string, json: string}}>}
348
+ * @throws {Error} on every refusal the module header lists.
349
+ */
350
+ export async function changeCommand(
351
+ baselinePath,
352
+ intentPath,
353
+ commandContext,
354
+ { config, readBaseline = readEvidenceSnapshot, readIntent, now } = {},
355
+ ) {
356
+ const { root, provider, marker, graph, analysis } = commandContext;
357
+
358
+ // The one required member of `io`: constraints are judged under the current
359
+ // law and the envelope records which law that was, so an undefined config
360
+ // refuses here rather than degrading either.
361
+ if (config === undefined || config === null) {
362
+ throw new Error(
363
+ "archkeep: cannot reconcile a change intent without a boundary config — the declared " +
364
+ "constraints are judged under the current law and the envelope records which law that " +
365
+ "was, and a run that resolves no law has neither.",
366
+ );
367
+ }
368
+
369
+ refuseUnjudgeableHead(commandContext, "reconcile a change intent");
370
+
371
+ const intent = await (readIntent ? readIntent(intentPath) : readChangeIntent(intentPath));
372
+
373
+ const baseline = readBaseline(baselinePath);
374
+
375
+ // Provider mismatch refuses here exactly as `delta` refuses: project
376
+ // identity across two different models is not trustworthy, and matching a
377
+ // declaration against names that may be artefacts would fabricate both
378
+ // directions of the verdict.
379
+ const mismatch = providerMismatch(baseline.provider, provider);
380
+ if (mismatch !== null) {
381
+ throw new Error(
382
+ `archkeep: refusing to reconcile a change intent — ${mismatch}. Re-capture the ` +
383
+ `baseline under this run's provider.`,
384
+ );
385
+ }
386
+
387
+ // Reference validation before anything else consumes the declarations: a
388
+ // fact that cannot exist in ANY descendant of this base is a defect in the
389
+ // input, not a divergence to report.
390
+ const referenceViolations = findChangeIntentReferenceViolations(
391
+ intent,
392
+ new Set(baseline.graph.projects.map((project) => project.name)),
393
+ );
394
+ if (referenceViolations.length > 0) {
395
+ throw new Error(
396
+ `archkeep: the change intent '${intentPath}' references architecture the captured ` +
397
+ `baseline does not contain:\n ${referenceViolations.join("\n ")}`,
398
+ );
399
+ }
400
+
401
+ // Base identity. The commit pin is the proof that the two sides compared
402
+ // are the ones the author saw when writing the contract; without it the
403
+ // run answers unproven with the reason named, never proceeds silently.
404
+ const headProvenance = resolveProvenance(root);
405
+ const headFingerprint = computePolicyFingerprint(config);
406
+ /** @type {string[]} */
407
+ const unprovenReasons = [];
408
+ if (!baseline.provenance || typeof baseline.provenance.commit !== "string") {
409
+ unprovenReasons.push(
410
+ `the baseline '${baselinePath}' carries no provenance, so the contract's base.commit ` +
411
+ `(${intent.base.commit}) cannot be verified against the tree it was captured from`,
412
+ );
413
+ } else if (baseline.provenance.commit !== intent.base.commit) {
414
+ unprovenReasons.push(
415
+ `the contract pins base.commit ${intent.base.commit}, but the baseline was captured at ` +
416
+ `${baseline.provenance.commit} — comparing against a different base than the one the ` +
417
+ `author declared`,
418
+ );
419
+ }
420
+
421
+ const meta = compareSnapshotMetadata({
422
+ baselineProvider: baseline.provider,
423
+ headProvider: provider,
424
+ baselineProvenance: baseline.provenance,
425
+ headProvenance,
426
+ baselineFingerprint: baseline.policyFingerprint,
427
+ headFingerprint,
428
+ });
429
+ if (meta.crossRepo) {
430
+ unprovenReasons.push(
431
+ `the baseline's remote (${baseline.provenance?.remote}) differs from this tree's remote ` +
432
+ `(${headProvenance?.remote}) — the comparison may span unrelated repositories`,
433
+ );
434
+ }
435
+
436
+ const baseGraphForDiff = {
437
+ projects: baseline.graph.projects,
438
+ dependencies: baseline.graph.dependencies,
439
+ };
440
+ const headGraphForDiff = {
441
+ projects: buildProjects(graph.nodes),
442
+ dependencies: buildDependencies(graph.dependencies),
443
+ };
444
+ const structural = computeDiff(baseGraphForDiff, headGraphForDiff);
445
+ const reconciliation = reconcileMaterialDelta(intent, structural);
446
+
447
+ // Constraints and the law axis are computed over both sides re-judged under
448
+ // ONE current law — the `delta` arrangement. Left unevaluated when the base
449
+ // identity is unproven: a constraint pass over evidence this run cannot
450
+ // attach to the declared base would read as a verdict about the wrong
451
+ // trees.
452
+ /** @type {object[]} */
453
+ let constraints = [];
454
+ let liveViolations = null;
455
+ if (unprovenReasons.length === 0) {
456
+ const configWithNow = now === undefined ? config : { ...config, now };
457
+ const baseEngineGraph = evidenceGraphToProjectGraph(baseline.graph);
458
+ const baseRaw = evaluateRun(baseline.records, baseEngineGraph, configWithNow).rawViolations;
459
+ const headEval = evaluateRun(analysis.imports, graph, configWithNow);
460
+ const classification = classifyDelta({
461
+ baseViolations: baseRaw,
462
+ headViolations: headEval.rawViolations,
463
+ baseRecords: baseline.records,
464
+ headRecords: analysis.imports,
465
+ suppressions: config.suppressions ?? [],
466
+ ...(now === undefined ? {} : { now }),
467
+ sourceProjectOf: sourceProjectAttributor(graph, baseline.graph.projects),
468
+ });
469
+ constraints = judgeDeclaredConstraints(intent, {
470
+ classification,
471
+ cyclesBase: cyclicProjects(baseEngineGraph, Object.keys(baseEngineGraph.nodes)),
472
+ cyclesHead: cyclicProjects(graph, Object.keys(graph.nodes)),
473
+ });
474
+ // The informational law axis: what `check` would count right now, after
475
+ // the table — computed here so the envelope can show it beside the
476
+ // intent verdict WITHOUT becoming a second gate over it.
477
+ liveViolations = headEval.violations.length;
478
+ }
479
+
480
+ const verdict = reconciliationVerdict(reconciliation, unprovenReasons);
481
+ const failedConstraints = constraints.filter((row) => row.verdict === "fail").length;
482
+ const unknownConstraints = constraints.filter((row) => row.verdict === "unknown").length;
483
+ const findings =
484
+ reconciliation.unexpected.length + reconciliation.missingExpected.length + failedConstraints;
485
+
486
+ /** @type {"ok"|"findings"|"no-verdict"} */
487
+ let status;
488
+ /** @type {0|1|3} */
489
+ let exitCode;
490
+ let decision;
491
+ if (verdict === "unproven" || unknownConstraints > 0) {
492
+ status = "no-verdict";
493
+ exitCode = 3;
494
+ decision = buildDecision({
495
+ status,
496
+ coverageComplete: true,
497
+ findings: 0,
498
+ reason:
499
+ verdict === "unproven"
500
+ ? `the change intent could not be verified against the declared base: ${unprovenReasons[0]}`
501
+ : `${unknownConstraints} declared constraint${unknownConstraints === 1 ? "" : "s"} could not be determined`,
502
+ });
503
+ } else if (findings > 0) {
504
+ status = "findings";
505
+ exitCode = 1;
506
+ decision = buildDecision({ status, coverageComplete: true, findings });
507
+ } else {
508
+ status = "ok";
509
+ exitCode = 0;
510
+ decision = buildDecision({ status, coverageComplete: true, findings: 0 });
511
+ }
512
+
513
+ /** @type {string[]} */
514
+ const notes = [];
515
+ if (meta.policyChanged === true) {
516
+ notes.push(
517
+ "the boundary law changed since capture — constraints were re-judged under the current " +
518
+ "law applied to both sides, so a finding a policy edit created classifies as unchanged",
519
+ );
520
+ }
521
+ if (meta.dirtyBaseline) {
522
+ notes.push(
523
+ "the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
524
+ "claim about the commit the contract pins",
525
+ );
526
+ }
527
+ if (meta.dirtyHead) {
528
+ notes.push(
529
+ "this run's working tree is dirty — the head side describes uncommitted state, not the " +
530
+ "commit HEAD names",
531
+ );
532
+ }
533
+ if (meta.provenanceOneSided) {
534
+ notes.push(
535
+ "only one side carries repository provenance — the comparison cannot confirm both sides " +
536
+ "come from the same repository",
537
+ );
538
+ }
539
+
540
+ const coverage = {
541
+ complete: true,
542
+ projects: headGraphForDiff.projects.length,
543
+ analyzedFiles: analysis.analyzed,
544
+ imports: analysis.imports.length,
545
+ notAnalyzed: [],
546
+ blindSpots: [],
547
+ notes,
548
+ };
549
+
550
+ const result = {
551
+ intent: {
552
+ file: intentPath,
553
+ version: intent.version,
554
+ base: { ...intent.base },
555
+ ...(intent.summary === undefined ? {} : { summary: intent.summary }),
556
+ declared: {
557
+ projectsAdd: intent.projects.add.length,
558
+ projectsRemove: intent.projects.remove.length,
559
+ edgesAdd: intent.edges.add.length,
560
+ edgesRemove: intent.edges.remove.length,
561
+ constraints: CONSTRAINT_ORDER.filter((key) => intent.constraints[key] === true),
562
+ },
563
+ },
564
+ baseline: {
565
+ path: baselinePath,
566
+ tool: baseline.tool,
567
+ provider: baseline.provider,
568
+ provenance: baseline.provenance,
569
+ policyFingerprint: baseline.policyFingerprint,
570
+ projects: baseline.graph.projects.length,
571
+ records: baseline.records.length,
572
+ },
573
+ head: {
574
+ provenance: headProvenance,
575
+ policyFingerprint: headFingerprint,
576
+ projects: headGraphForDiff.projects.length,
577
+ },
578
+ reconciliation: {
579
+ verdict,
580
+ reasons: unprovenReasons,
581
+ matched: reconciliation.matched,
582
+ unexpected: reconciliation.unexpected,
583
+ missingExpected: reconciliation.missingExpected,
584
+ },
585
+ constraints,
586
+ policy: {
587
+ fingerprint: headFingerprint,
588
+ changedSinceBase: meta.policyChanged === true,
589
+ liveViolations,
590
+ },
591
+ };
592
+
593
+ const envelope = jsonEnvelope({
594
+ command: "change",
595
+ context: { root, provider, marker, provenance: headProvenance },
596
+ status,
597
+ exitCode,
598
+ coverage,
599
+ result,
600
+ decision,
601
+ });
602
+
603
+ return {
604
+ status,
605
+ changeIntent: result,
606
+ coverage,
607
+ report: {
608
+ text: formatChangeReport({ change: result, coverage }),
609
+ json: renderJson(envelope),
610
+ },
611
+ };
612
+ }