@ecoma-io/archkeep 0.21.0 → 0.22.1

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 (53) hide show
  1. package/cli.mjs +156 -66
  2. package/gate-attestation.mjs +23 -0
  3. package/package.json +3 -1
  4. package/src/analysis/analyze.mjs +6 -0
  5. package/src/analysis/contract.md +32 -5
  6. package/src/analysis/csharp.mjs +18 -0
  7. package/src/analysis/go.mjs +18 -0
  8. package/src/analysis/java.mjs +15 -0
  9. package/src/analysis/kotlin.mjs +15 -0
  10. package/src/analysis/python.mjs +25 -3
  11. package/src/analysis/rust.mjs +18 -0
  12. package/src/analysis/source-util.mjs +113 -0
  13. package/src/analysis/typescript.mjs +86 -5
  14. package/src/canonical.mjs +43 -25
  15. package/src/commands/README.md +63 -12
  16. package/src/commands/change-intent.mjs +25 -1
  17. package/src/commands/change.mjs +90 -40
  18. package/src/commands/check.mjs +65 -26
  19. package/src/commands/completeness.mjs +126 -19
  20. package/src/commands/context-command.mjs +13 -5
  21. package/src/commands/context.mjs +31 -4
  22. package/src/commands/coverage-verdict.mjs +191 -0
  23. package/src/commands/debt.mjs +18 -15
  24. package/src/commands/delta-classify.mjs +13 -18
  25. package/src/commands/delta-snapshot.mjs +13 -5
  26. package/src/commands/delta.mjs +95 -33
  27. package/src/commands/diff.mjs +31 -24
  28. package/src/commands/discover.mjs +70 -29
  29. package/src/commands/drift.mjs +21 -21
  30. package/src/commands/edge-constraints.mjs +47 -1
  31. package/src/commands/evaluation-primitives.mjs +194 -2
  32. package/src/commands/evolution.mjs +27 -10
  33. package/src/commands/explain.mjs +14 -13
  34. package/src/commands/fitness.mjs +20 -19
  35. package/src/commands/graph.mjs +29 -11
  36. package/src/commands/health.mjs +12 -5
  37. package/src/commands/history.mjs +41 -26
  38. package/src/commands/impact.mjs +17 -18
  39. package/src/commands/plan-context-command.mjs +10 -5
  40. package/src/commands/reconcile.mjs +14 -17
  41. package/src/commands/scenario-evaluation.mjs +93 -16
  42. package/src/commands/scenario.mjs +28 -18
  43. package/src/commands/waivers.mjs +36 -28
  44. package/src/governance/evolution-event.mjs +96 -9
  45. package/src/intent/intent-manifest.json +83 -39
  46. package/src/lsp/diagnose.mjs +12 -3
  47. package/src/report/discover-text.mjs +31 -9
  48. package/src/report/graph-text.mjs +25 -5
  49. package/src/report/json.mjs +32 -5
  50. package/src/report/text.mjs +82 -12
  51. package/src/verdict.mjs +78 -36
  52. package/src/verify-gate-attestation.mjs +323 -0
  53. package/src/workspace.mjs +126 -2
@@ -32,7 +32,11 @@
32
32
  * than explaining constraints from a graph whose edges silently under-represent
33
33
  * the real architecture.
34
34
  */
35
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
35
+ import {
36
+ blindSpotRows,
37
+ isWholeFileFailure,
38
+ unresolvableLiteralCount,
39
+ } from "../analysis/source-util.mjs";
36
40
  import { UsageError } from "../errors.mjs";
37
41
  import { judgeEdge } from "./edge-constraints.mjs";
38
42
  import { findConstraintsFor } from "../rules/tags.mjs";
@@ -150,7 +154,13 @@ export function contextCommand(projectName, commandContext, config) {
150
154
  .filter(isWholeFileFailure)
151
155
  .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
152
156
 
153
- const complete = notAnalyzed.length === 0;
157
+ // The same completeness `check` claims (#595, #599): unjudged sites and
158
+ // a zero-analyzed run defeat it here exactly as they do there, so a
159
+ // context report cannot look complete over a tree the run could not
160
+ // fully read.
161
+ const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
162
+ const complete =
163
+ notAnalyzed.length === 0 && blindSpotCount === 0 && commandContext.analysis.analyzed > 0;
154
164
  const status = complete ? "ok" : "no-verdict";
155
165
  const exitCode = complete ? 0 : 3;
156
166
 
@@ -160,9 +170,7 @@ export function contextCommand(projectName, commandContext, config) {
160
170
  analyzedFiles: commandContext.analysis.analyzed,
161
171
  imports: commandContext.analysis.imports.length,
162
172
  notAnalyzed,
163
- blindSpots: commandContext.analysis.failures
164
- .filter((f) => !isWholeFileFailure(f))
165
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
173
+ blindSpots: blindSpotRows(commandContext.analysis.failures),
166
174
  notes: [
167
175
  "per-edge violations cover only depConstraints (3 of 15 violation types). " +
168
176
  "A dependency with no violations here may still violate npm-ban, circular-dependency, " +
@@ -214,11 +214,14 @@ export const WORKSPACE_MARKERS = [
214
214
  * `createWorkspace` returns.
215
215
  * @property {string[]} tracked Every tracked file, from `listFiles(root)`.
216
216
  * @property {{imports: object[], failures: object[], analyzed: number,
217
- * analyzedFiles: string[], exemptedFiles: string[]}} analysis The
217
+ * analyzedFiles: string[], exemptedFiles: string[],
218
+ * unsupportedLanguageFiles: string[]}} analysis The
218
219
  * whole-tree-then-scoped (native) or scoped-then-analyzed (nx/moon) result —
219
220
  * see the branches below for why the order is not the same on all three.
220
221
  * `exemptedFiles` is always `[]` on nx/moon: `coverage.exempt` is a
221
- * native-only `archkeep.json` key.
222
+ * native-only `archkeep.json` key. `unsupportedLanguageFiles` is scoped to
223
+ * the selected files on every branch (#601): a gap row names only files the
224
+ * selected scope could have judged.
222
225
  * @property {{boundaryConfig: string|object, tsConfig: object|undefined,
223
226
  * boundaryConfigDeclared: boolean, profiles?: string, inline?: boolean}} options
224
227
  * What this workspace names its boundary law, its shared tsconfig, and —
@@ -549,6 +552,7 @@ export function resolveCommandContext(
549
552
  let failures;
550
553
  let analyzed;
551
554
  let analyzedFiles;
555
+ let unsupportedLanguageFiles;
552
556
  let pluginGap;
553
557
  let unownedGap;
554
558
  let unclaimedGap;
@@ -664,6 +668,12 @@ export function resolveCommandContext(
664
668
  ...jvmIndexFailures(workspace),
665
669
  ]);
666
670
  analyzedFiles = wholeTreeAnalysis.analyzedFiles.filter((file) => selectedFiles.has(file));
671
+ // Scoped exactly like `analyzedFiles` above (#601): a gap row must name
672
+ // only files the selected scope could have judged, and leaving this
673
+ // undefined here would crash `check`'s gap assembly instead of reporting.
674
+ unsupportedLanguageFiles = wholeTreeAnalysis.unsupportedLanguageFiles.filter((file) =>
675
+ selectedFiles.has(file),
676
+ );
667
677
  analyzed = analyzedFiles.length;
668
678
  // Unaffected by `paths`: an exempted file is by definition unowned by any
669
679
  // project, so it was never a candidate for `owned`/`selected` in the
@@ -787,6 +797,13 @@ export function resolveCommandContext(
787
797
  ]);
788
798
  unclaimedGap = { files: unclaimedFiles };
789
799
  analyzedFiles = wholeTreeAnalysis.analyzedFiles.filter((file) => selectedFiles.has(file));
800
+ // Scoped exactly like the native branch's own filter above (#601): a gap
801
+ // row must name only files the selected scope could have judged, and
802
+ // leaving this undefined here would crash `check`'s gap assembly instead
803
+ // of reporting.
804
+ unsupportedLanguageFiles = wholeTreeAnalysis.unsupportedLanguageFiles.filter((file) =>
805
+ selectedFiles.has(file),
806
+ );
790
807
  analyzed = analyzedFiles.length;
791
808
  // `coverage.exempt` is a native-only key (`../providers/native/coverage.mjs`'s
792
809
  // header: "Nx has no equivalent question") — Moon carries no such list.
@@ -835,7 +852,10 @@ export function resolveCommandContext(
835
852
  paths,
836
853
  { root, cwd, tracked },
837
854
  );
838
- ({ imports, failures, analyzed, analyzedFiles } = analyzeWorkspace(workspace, selected));
855
+ ({ imports, failures, analyzed, analyzedFiles, unsupportedLanguageFiles } = analyzeWorkspace(
856
+ workspace,
857
+ selected,
858
+ ));
839
859
  // Unclaimed analyzable files — `unclaimedFileFailures` above — join
840
860
  // unconditionally, the same workspace-wide posture native's own
841
861
  // `discovered.failures` has, so a scoped `check <path>` cannot hide an
@@ -876,7 +896,14 @@ export function resolveCommandContext(
876
896
  graph,
877
897
  workspace,
878
898
  tracked,
879
- analysis: { imports, failures, analyzed, analyzedFiles, exemptedFiles },
899
+ analysis: {
900
+ imports,
901
+ failures,
902
+ analyzed,
903
+ analyzedFiles,
904
+ exemptedFiles,
905
+ unsupportedLanguageFiles,
906
+ },
880
907
  options,
881
908
  pluginGap,
882
909
  unownedGap,
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The ONE incomplete-coverage refusal contract, and the one completeness
3
+ * every command's verdict is a claim about (#608).
4
+ *
5
+ * #602 gave the graph family (`check`, `graph`, `discover`, `explain`,
6
+ * `context`) a structured refusal: `status: "no-verdict"`, exit 3, a
7
+ * `coverage` block naming what the run could not read — in the envelope,
8
+ * where a parser and `--output` can read it. Every command here that refuses
9
+ * the same condition refused it by throwing: a stderr sentence, an empty
10
+ * stdout, no envelope, nothing under `--output`. Same refusal class, two
11
+ * machine contracts — while `docs/reference/exit-codes.md` already documents
12
+ * the structured one for every descriptive command. The functions here are
13
+ * the shared construction the throw family now goes through, so the two
14
+ * families cannot drift again:
15
+ *
16
+ * - `coverageVerdict` computes the one completeness over three axes — no
17
+ * whole-file failure, no unjudged blind spot, at least one file analyzed —
18
+ * and the status/exit pair that completeness implies. The first two axes
19
+ * ride the envelope law (`../report/json.mjs`), and there only in ONE
20
+ * direction: the envelope refuses `complete: true` over a non-empty
21
+ * `notAnalyzed` list or an unjudged blind-spot row, and deliberately
22
+ * allows `complete: false` with both lists empty, so a run that judged
23
+ * nothing is able to say so. The third axis — `analyzed > 0` — is the one
24
+ * this constructor adds on top (#599, #612, #619): the envelope has no
25
+ * zero-analysis refusal by design, which is WHY this constructor exists.
26
+ * The failure classes are read through
27
+ * `../analysis/source-util.mjs`'s classifiers, never an inline filter: the
28
+ * class line is theirs alone to own.
29
+ * - `coverageRefusal` builds the refusal result itself: the envelope through
30
+ * `jsonEnvelope` (so the envelope invariants hold by construction, and a
31
+ * refusal cannot accidentally claim `complete: true`), and a text face
32
+ * whose coverage lines come from `coverageIncompleteReasons` — the same
33
+ * clauses `check`'s text report renders — so a terminal reader is told the
34
+ * same thing the envelope tells a parser.
35
+ *
36
+ * What still throws: the plugin-gap refusal (a graph that under-represents
37
+ * the workspace is a different refusal, kept loud in every command that has
38
+ * one — `drift.mjs`'s `refuseIncompleteGraph` and `delta.mjs`'s
39
+ * `refusePluginGapHead` are the two shared guards), input refusals (a
40
+ * malformed baseline file, a missing intent), and the capture modes
41
+ * (`delta --capture`, `history --capture`) whose product is a snapshot file
42
+ * with no envelope contract to refuse through.
43
+ *
44
+ * @module
45
+ */
46
+
47
+ import {
48
+ blindSpotRows,
49
+ isWholeFileFailure,
50
+ unresolvableLiteralCount,
51
+ } from "../analysis/source-util.mjs";
52
+ import { EXIT, coverageIncompleteReasons } from "../verdict.mjs";
53
+ import { buildDecision } from "../report/evidence.mjs";
54
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
55
+ import { formatCoverageIncomplete } from "../report/text.mjs";
56
+ import { resolveProvenance } from "./provenance.mjs";
57
+
58
+ /**
59
+ * @typedef {object} CoverageVerdict
60
+ * @property {{file: string, reason: string}[]} notAnalyzed One row per
61
+ * whole-file failure — the files the run never read.
62
+ * @property {object[]} blindSpots Every positioned failure, all permanent
63
+ * classes — the run's disclosure of every site it saw and did not judge.
64
+ * @property {number} blindSpotCount The count of sites that WITHHOLD the
65
+ * verdict (unresolvable literals referencing the workspace; dynamic and
66
+ * external sites disclosed but exit-neutral).
67
+ * @property {boolean} complete Whether the run judged everything in scope.
68
+ * @property {"ok"|"no-verdict"} status The verdict the completeness implies.
69
+ * @property {0|3} exitCode The exit code the status implies.
70
+ */
71
+
72
+ /**
73
+ * The one completeness verdict over a command context's analysis.
74
+ *
75
+ * The three completeness axes are exactly the envelope law's: a whole-file
76
+ * failure is a file no verdict was reached on; an unresolvable literal site
77
+ * is a question the resolver was asked and could not answer; a zero-analyzed
78
+ * run judged nothing (#599). Dynamic and external sites are declared limits —
79
+ * disclosed in `blindSpots`, never withholding — because
80
+ * `unresolvableLiteralCount` draws that class line once, and this function
81
+ * reads the count rather than re-deriving it.
82
+ *
83
+ * @param {object} commandContext From `resolveCommandContext`.
84
+ * @param {{acceptedFiles?: Set<string>}} [io] `acceptedFiles` names whole-
85
+ * file failures a `coverage.unowned` row already accepts (`waivers` and
86
+ * `check` withdraw those first): the state is a recorded acceptance the
87
+ * report itself names, not a hole the run failed to look at. Absent ⇒ no
88
+ * withdrawal, which is every command but `waivers`.
89
+ * @returns {CoverageVerdict}
90
+ */
91
+ export function coverageVerdict(commandContext, { acceptedFiles } = {}) {
92
+ const { analysis } = commandContext;
93
+ const notAnalyzed = analysis.failures
94
+ .filter(isWholeFileFailure)
95
+ .filter(({ sourceFile }) => !(acceptedFiles?.has(sourceFile) ?? false))
96
+ .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
97
+ const blindSpots = blindSpotRows(analysis.failures);
98
+ const blindSpotCount = unresolvableLiteralCount(analysis.failures);
99
+ const complete = notAnalyzed.length === 0 && blindSpotCount === 0 && analysis.analyzed > 0;
100
+ return {
101
+ notAnalyzed,
102
+ blindSpots,
103
+ blindSpotCount,
104
+ complete,
105
+ status: complete ? "ok" : "no-verdict",
106
+ exitCode: complete ? EXIT.ok : EXIT.error,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * The structured refusal itself: what a command returns over an analysis it
112
+ * could not fully read, instead of throwing.
113
+ *
114
+ * The envelope carries no `result`: the verdict was withheld, so there is no
115
+ * verdict payload to ride beside the refusal — the `coverage` block IS the
116
+ * payload, and `jsonEnvelope`'s law would refuse any `ok`-shaped claim this
117
+ * object might otherwise smuggle through. `decision` is accepted for the two
118
+ * commands whose envelopes already carry one (`delta`, `change`); an
119
+ * "unknown" decision must name its reason (I3), so callers pass `true` and
120
+ * the reason is joined from the same clause list the text face renders.
121
+ *
122
+ * @param {{command: string, commandContext: object, what: string,
123
+ * acceptedFiles?: Set<string>, decision?: boolean}} input
124
+ * `command` is the envelope's command name, `what` names the question the
125
+ * command was asking (the text face's one free clause), `acceptedFiles`
126
+ * forwards to `coverageVerdict` (see above), and `decision: true` attaches
127
+ * the canonical `unknown` decision.
128
+ * @returns {{status: "no-verdict", coverage: object,
129
+ * report: {text: string, json: string}}}
130
+ */
131
+ export function coverageRefusal({ command, commandContext, what, acceptedFiles, decision }) {
132
+ const verdict = coverageVerdict(commandContext, { acceptedFiles });
133
+ // A refusal over a complete coverage would be a `no-verdict` claim about a
134
+ // run that looked at everything — the silent direction in the other dress.
135
+ // Every caller gates on `coverageVerdict(...).complete` first; this is the
136
+ // one place that refuses to build the lie if a caller stops gating.
137
+ if (verdict.complete) {
138
+ throw new Error(
139
+ `coverageRefusal: ${command}'s coverage is complete — there is nothing to refuse`,
140
+ );
141
+ }
142
+ /** @type {"no-verdict"} The completeness is false, so this is the status. */
143
+ const status = "no-verdict";
144
+ const reasons = coverageIncompleteReasons({
145
+ unchecked: verdict.notAnalyzed.length,
146
+ blindSpots: verdict.blindSpotCount,
147
+ analyzed: commandContext.analysis.analyzed,
148
+ });
149
+ const coverage = {
150
+ complete: false,
151
+ projects: Object.keys(commandContext.graph.nodes).length,
152
+ analyzedFiles: commandContext.analysis.analyzed,
153
+ imports: commandContext.analysis.imports.length,
154
+ notAnalyzed: verdict.notAnalyzed,
155
+ blindSpots: verdict.blindSpots,
156
+ notes: [],
157
+ };
158
+ const envelope = jsonEnvelope({
159
+ command,
160
+ context: {
161
+ root: commandContext.root,
162
+ provider: commandContext.provider,
163
+ marker: commandContext.marker,
164
+ provenance: resolveProvenance(commandContext.root),
165
+ },
166
+ status,
167
+ exitCode: verdict.exitCode,
168
+ coverage,
169
+ result: undefined,
170
+ ...(decision === true
171
+ ? {
172
+ decision: buildDecision({
173
+ status,
174
+ coverageComplete: false,
175
+ findings: 0,
176
+ reason: reasons.join("; "),
177
+ }),
178
+ }
179
+ : {}),
180
+ });
181
+ return {
182
+ status,
183
+ coverage,
184
+ report: {
185
+ text:
186
+ `${command}: no verdict — ${what} needs a workspace this run could fully read\n` +
187
+ formatCoverageIncomplete(reasons),
188
+ json: renderJson(envelope),
189
+ },
190
+ };
191
+ }
@@ -45,12 +45,13 @@
45
45
  * when the ledger was taken; the aging itself is snapshot-relative
46
46
  * (`../governance/debt-ledger.mjs`).
47
47
  */
48
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
48
+ import { blindSpotRows } from "../analysis/source-util.mjs";
49
49
  import { judgeIntent } from "../architecture-intent/judge.mjs";
50
50
  import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
51
51
  import { computeDebtLedger } from "../governance/debt-ledger.mjs";
52
52
  import { readEvents } from "../governance/evolution-store.mjs";
53
53
  import { formatDebtReport } from "../report/debt-text.mjs";
54
+ import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
54
55
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
55
56
  import { resolveProvenance } from "./provenance.mjs";
56
57
  import { buildObserved, refuseIncompleteGraph } from "./drift.mjs";
@@ -75,9 +76,12 @@ import { readSnapshots } from "./history.mjs";
75
76
  * the `history`/`drift` pattern. `events` is the event-store directory the
76
77
  * `--events <dir>` CLI flag resolves; absent ⇒ no lifecycle refs (the
77
78
  * ledger says so in `lifecycle.note`), never guessed.
78
- * @returns {Promise<{status: "ok", ledger: object, coverage: object,
79
+ * @returns {Promise<{status: "ok"|"no-verdict", ledger?: object, coverage: object,
79
80
  * report: {text: string, json: string}}>}
80
- * @throws {Error} on every condition the header lists, all exit-3 class.
81
+ * `status: "no-verdict"` carries no `ledger` payload the verdict was
82
+ * withheld, and the envelope's `coverage` block is the whole answer (#608).
83
+ * @throws {Error} on every condition the header lists except the coverage one,
84
+ * which returns instead of throwing.
81
85
  */
82
86
  export async function debtCommand(dir, commandContext, options = {}) {
83
87
  const { root, provider, marker, analysis } = commandContext;
@@ -86,15 +90,16 @@ export async function debtCommand(dir, commandContext, options = {}) {
86
90
 
87
91
  refuseIncompleteGraph(commandContext);
88
92
 
89
- const notAnalyzed = analysis.failures
90
- .filter(isWholeFileFailure)
91
- .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
92
- if (notAnalyzed.length > 0) {
93
- throw new Error(
94
- `archkeep: debt has incomplete coverage ${notAnalyzed.length} file` +
95
- `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so every "project missing" ` +
96
- `would be ambiguous between "gone" and "never seen". Fix the unanalyzed files and re-run.`,
97
- );
93
+ // The ledger is a claim about the tree the run read, refused through the one
94
+ // structured contract `./coverage-verdict.mjs` builds (#608). This gate used
95
+ // to check whole-file failures only and then claim `coverage.complete: true`
96
+ // beside `blindSpots` that could carry an unjudged site — which the envelope
97
+ // law refuses as a programming error, so a blind-spot-only tree crashed
98
+ // here. The unified completeness refuses it as the verdict-withholding state
99
+ // it is, before the snapshot directory is read.
100
+ const completeness = coverageVerdict(commandContext);
101
+ if (!completeness.complete) {
102
+ return coverageRefusal({ command: "debt", commandContext, what: "aging the debt ledger" });
98
103
  }
99
104
 
100
105
  // The snapshot directory is the sole source of truth for aging; a directory
@@ -173,9 +178,7 @@ export async function debtCommand(dir, commandContext, options = {}) {
173
178
  analyzedFiles: analysis.analyzed,
174
179
  imports: analysis.imports.length,
175
180
  notAnalyzed: [],
176
- blindSpots: analysis.failures
177
- .filter((failure) => !isWholeFileFailure(failure))
178
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
181
+ blindSpots: blindSpotRows(analysis.failures),
179
182
  // The ledger names what it could not age — a reader can tell "aged" from
180
183
  // "observed, not yet aged". The second note discloses `sampleTime`'s own
181
184
  // nature: it reflects the wall clock at the moment of THIS run, not the
@@ -593,20 +593,11 @@ export function deltaFindings(delta) {
593
593
  };
594
594
  }
595
595
 
596
- /**
597
- * The identity string of one graph edge, in the design's canonical spelling
598
- * `source>target:type` (the `(source, target, type)` identity the design
599
- * §1 names, printed the way `docs/concepts/evolution.md`'s example shows).
600
- * The ONE spelling the delta's event `observed.edges`/`affected.boundaries`
601
- * use — a second spelling somewhere would be a second definition of "same
602
- * edge", and two definitions drift.
603
- *
604
- * @param {{source: string, target: string, type: string}} edge
605
- * @returns {string}
606
- */
607
- export function edgeEvolutionIdentity({ source, target, type }) {
608
- return `${source}>${target}:${type}`;
609
- }
596
+ // The identity spelling's ONE home is `../governance/evolution-event.mjs` —
597
+ // `classifyEvolution` owns it and maps every `observed.edges` entry through
598
+ // it. This re-export keeps delta's importers on the same name; it defines
599
+ // nothing.
600
+ export { edgeEvolutionIdentity } from "../governance/evolution-event.mjs";
610
601
 
611
602
  /**
612
603
  * The delta event's per-constraint verdict deltas (design §1 `fitness`),
@@ -675,12 +666,16 @@ export function deltaVerdictDeltas(delta) {
675
666
  *
676
667
  * @param {object} delta The `deltaCommand` result payload.
677
668
  * @param {{projects?: {added: string[], removed: string[], changed: string[]},
678
- * edges?: {added: string[], removed: string[]}, codeDrift?: boolean}} [signals]
669
+ * edges?: {added: {source: string, target: string, type: string}[],
670
+ * removed: {source: string, target: string, type: string}[]},
671
+ * codeDrift?: boolean}} [signals]
679
672
  * The structural-change and drift signals a delta run derives from the two
680
673
  * graphs it holds (the graph diff is `diff`'s vocabulary, shared here, never
681
- * re-derived) — `projects`/`edges` carry identity strings (`edgeEvolutionIdentity`
682
- * for edges), and `codeDrift` is the delta's computed "provenance advanced,
683
- * no arch/policy change" fact. Absent signals are empty, so a delta that
674
+ * re-derived) — `edges` carries the raw `{source, target, type}` triples,
675
+ * which `classifyEvolution` maps through `edgeEvolutionIdentity` itself (the
676
+ * identity string is its output spelling, never an input), and `codeDrift`
677
+ * is the delta's computed "provenance advanced, no arch/policy change" fact.
678
+ * Absent signals are empty, so a delta that
684
679
  * computed none reads as a violation-only mapping.
685
680
  * @returns {{classifications: string[], disposition: "accepted"|"rejected"|"no-verdict",
686
681
  * notes: string[], affected: {projects: string[], boundaries: string[],
@@ -47,6 +47,7 @@
47
47
  */
48
48
  import { readFileSync } from "node:fs";
49
49
 
50
+ import { canonicalJsonReplacer } from "../canonical.mjs";
50
51
  import { buildDependencies, buildProjects } from "./graph.mjs";
51
52
 
52
53
  /** The only snapshot schemaVersion this module writes and reads. */
@@ -262,16 +263,23 @@ export function buildEvidenceSnapshot({
262
263
  /**
263
264
  * Renders the snapshot as deterministic JSON text.
264
265
  *
265
- * Deterministic because `buildEvidenceSnapshot` constructs every key in a
266
- * fixed order and sorts every array whose source does not guarantee order;
267
- * two captures over one unchanged tree produce byte-identical files, which is
268
- * what makes a plain `diff` of two baselines meaningful.
266
+ * Deterministic by mechanism, not by constructor discipline: the text is
267
+ * produced through `../canonical.mjs`'s `canonicalJsonReplacer`, which sorts
268
+ * plain-object keys at every depth the same canonicalizer the graph-snapshot
269
+ * family's `snapshotIdentity` hashes with. That is load-bearing because two of
270
+ * the stored inputs (`records`, `graph.workspaceLayout`) arrive verbatim from
271
+ * upstream code that owns their nested key order; sorting at serialize time is
272
+ * what makes the bytes a function of what the snapshot MEANS. Array element
273
+ * order is the only order the format keeps, and `buildEvidenceSnapshot` sorts
274
+ * every array whose source does not guarantee it. Two captures over one
275
+ * unchanged tree produce byte-identical files, which is what makes a plain
276
+ * `diff` of two baselines meaningful.
269
277
  *
270
278
  * @param {object} snapshot From `buildEvidenceSnapshot`.
271
279
  * @returns {string} The JSON text, newline-terminated.
272
280
  */
273
281
  export function serializeEvidenceSnapshot(snapshot) {
274
- return `${JSON.stringify(snapshot, null, 2)}\n`;
282
+ return `${JSON.stringify(snapshot, canonicalJsonReplacer, 2)}\n`;
275
283
  }
276
284
 
277
285
  /**