@ecoma-io/archkeep 0.24.0 → 0.25.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 (79) hide show
  1. package/package.json +1 -1
  2. package/src/analysis/csharp.mjs +3 -1
  3. package/src/analysis/dotnet/csproj.mjs +5 -1
  4. package/src/analysis/dotnet/namespaces.mjs +1 -0
  5. package/src/analysis/go.mjs +6 -0
  6. package/src/analysis/java.mjs +2 -0
  7. package/src/analysis/jvm/gradle.mjs +3 -1
  8. package/src/analysis/jvm/maven.mjs +6 -1
  9. package/src/analysis/jvm/packages.mjs +1 -0
  10. package/src/analysis/jvm/resolve.mjs +4 -2
  11. package/src/analysis/kotlin.mjs +2 -0
  12. package/src/analysis/markdown.mjs +1 -0
  13. package/src/analysis/python.mjs +8 -0
  14. package/src/analysis/rust.mjs +5 -1
  15. package/src/analysis/source-util.mjs +1 -1
  16. package/src/analysis/typescript.mjs +2 -0
  17. package/src/architecture-intent/model.mjs +11 -7
  18. package/src/architecture-intent/selectors.mjs +2 -1
  19. package/src/commands/change-intent.mjs +10 -9
  20. package/src/commands/change.mjs +86 -23
  21. package/src/commands/check.mjs +11 -9
  22. package/src/commands/completeness.mjs +7 -6
  23. package/src/commands/coverage-acceptance.mjs +46 -0
  24. package/src/commands/custom-rules.mjs +1 -0
  25. package/src/commands/delta-classify.mjs +3 -0
  26. package/src/commands/delta-snapshot.mjs +27 -5
  27. package/src/commands/delta.mjs +73 -39
  28. package/src/commands/diff.mjs +3 -9
  29. package/src/commands/drift.mjs +1 -1
  30. package/src/commands/evaluation-primitives.mjs +4 -4
  31. package/src/commands/evolution.mjs +2 -0
  32. package/src/commands/explain.mjs +2 -0
  33. package/src/commands/graph.mjs +39 -17
  34. package/src/commands/history.mjs +36 -0
  35. package/src/commands/plan-context-command.mjs +4 -1
  36. package/src/commands/policy.mjs +5 -2
  37. package/src/commands/scenario-evaluation.mjs +1 -1
  38. package/src/commands/snapshot-meta.mjs +33 -0
  39. package/src/commands/trajectory.mjs +2 -1
  40. package/src/config.mjs +1 -1
  41. package/src/custom-rules/host.mjs +3 -3
  42. package/src/custom-rules/values.mjs +1 -4
  43. package/src/eslint-config.mjs +1 -0
  44. package/src/fixtures/evolution-lifecycle/workspace.mjs +15 -4
  45. package/src/go-work.mjs +1 -1
  46. package/src/governance/adr-registry.mjs +4 -1
  47. package/src/governance/debt-ledger.mjs +1 -1
  48. package/src/governance/decision-fitness.mjs +2 -0
  49. package/src/governance/decision-graph.mjs +1 -0
  50. package/src/governance/discovery-proposal.mjs +8 -2
  51. package/src/governance/evolution-event.mjs +42 -0
  52. package/src/governance/fitness-registry.mjs +16 -1
  53. package/src/governance/preset-fingerprints.json +14 -14
  54. package/src/governance/profile-registry.mjs +22 -3
  55. package/src/governance/provenance-record.mjs +4 -1
  56. package/src/governance/reconcile-score.mjs +4 -0
  57. package/src/governance/row-schema.mjs +1 -0
  58. package/src/governance/verdict.mjs +37 -4
  59. package/src/governance/waiver.mjs +1 -0
  60. package/src/intent/intent-manifest.json +18 -12
  61. package/src/intent/mask-non-code.mjs +1 -0
  62. package/src/lsp/diagnostics.mjs +3 -2
  63. package/src/lsp/protocol.mjs +2 -1
  64. package/src/lsp/server.mjs +3 -0
  65. package/src/lsp/workspace-index.mjs +3 -1
  66. package/src/providers/native/differential.fixtures.mjs +29 -11
  67. package/src/providers/native/index.mjs +2 -1
  68. package/src/providers/native/model.mjs +4 -0
  69. package/src/report/envelope-shape.mjs +2 -0
  70. package/src/report/json.mjs +4 -2
  71. package/src/report/sarif.mjs +21 -8
  72. package/src/report/snapshot-text.mjs +3 -3
  73. package/src/report/text.mjs +10 -2
  74. package/src/rules/match.mjs +7 -5
  75. package/src/rules/specifiers.mjs +2 -0
  76. package/src/rules/tags.mjs +3 -2
  77. package/src/rules/topology.mjs +6 -1
  78. package/src/values.mjs +11 -0
  79. package/src/workspace.mjs +1 -0
@@ -103,15 +103,18 @@ import { providerMismatch, readEvidenceSnapshot } from "./delta-snapshot.mjs";
103
103
  import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
104
104
  import { blindSpotRows } from "../analysis/source-util.mjs";
105
105
  import { cyclicProjects } from "../governance/fitness-rules.mjs";
106
- import { fitnessVerdict } from "../governance/verdict.mjs";
106
+ import { eventSnapshotSide } from "./history.mjs";
107
+ import { VERDICTS, fitnessVerdict, isVerdict } from "../governance/verdict.mjs";
108
+ import { describe } from "../values.mjs";
107
109
  import { buildDecision } from "../report/evidence.mjs";
108
110
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
109
111
  import { formatChangeReport } from "../report/change-text.mjs";
110
112
  import { evaluateRun } from "../rules/index.mjs";
111
- import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
113
+ import { compareSnapshotMetadata, dirtyBaselineNote, dirtyHeadNote } from "./snapshot-meta.mjs";
112
114
  import { resolveProvenance } from "./provenance.mjs";
113
115
  import { referenceTime } from "../governance/clock.mjs";
114
116
  import {
117
+ assertReproducibleEventIdentity,
115
118
  classifyEvolution,
116
119
  declarationDigest,
117
120
  escapeIdentityField,
@@ -170,6 +173,7 @@ function cmpFacts(a, b) {
170
173
  * @returns {string}
171
174
  */
172
175
  export function violationFindingId(entry) {
176
+ // used by its own test
173
177
  const sourceProject =
174
178
  entry.sourceProject == null ? "-" : escapeIdentityField(entry.sourceProject);
175
179
  return `${escapeIdentityField(entry.messageId)}:${sourceProject}:${escapeIdentityField(entry.target)}`;
@@ -250,6 +254,7 @@ function observedFrom(structural, meta) {
250
254
  * @returns {{matched: object[], unexpected: object[], missingExpected: object[]}}
251
255
  */
252
256
  export function reconcileMaterialDelta(intent, delta) {
257
+ // used by its own test
253
258
  const addedProjectNames = new Set(intent.projects.add);
254
259
  const removedProjectNames = new Set(intent.projects.remove);
255
260
  // The declared row's identity is `./change-intent.mjs`'s `edgePairKey` — the
@@ -358,13 +363,16 @@ export function reconcileMaterialDelta(intent, delta) {
358
363
  * @param {string[]} unprovenReasons Why the base identity could not be proven.
359
364
  * @returns {"matched"|"undeclared"|"unfulfilled"|"unproven"}
360
365
  */
361
- export function reconciliationVerdict(lists, unprovenReasons) {
366
+ function reconciliationVerdict(lists, unprovenReasons) {
362
367
  if (unprovenReasons.length > 0) return "unproven";
363
368
  if (lists.unexpected.length > 0) return "undeclared";
364
369
  if (lists.missingExpected.length > 0) return "unfulfilled";
365
370
  return "matched";
366
371
  }
367
372
 
373
+ /** The verdict axis `reconcileDisposition` maps — the four `reconciliationVerdict` answers. */
374
+ const RECONCILE_VERDICTS = Object.freeze(["matched", "undeclared", "unfulfilled", "unproven"]);
375
+
368
376
  /**
369
377
  * The reconcile DISPOSITION mapping (wave 3, design §5) — the verdict axis
370
378
  * first, then the declared constraints' verdicts. Pure and exported so the
@@ -387,8 +395,34 @@ export function reconciliationVerdict(lists, unprovenReasons) {
387
395
  * this run — empty when none were declared or when the base identity was
388
396
  * unproven (constraints are left unevaluated then).
389
397
  * @returns {"accepted"|"rejected"|"no-verdict"}
398
+ * @throws {Error} on an axis value outside the four, or a constraint row
399
+ * whose verdict is outside the canonical vocabulary — a stranger would fold
400
+ * to `accepted`, and a fabricated acceptance is the one answer this mapping
401
+ * never hands out.
390
402
  */
391
403
  export function reconcileDisposition(verdict, constraints = []) {
404
+ // The mapping owns its input latch — it is exported and pure, the one place
405
+ // the precedence is stated, and a stranger axis value would match no named
406
+ // arm and land on `accepted`: a fabricated acceptance, the one answer this
407
+ // function must never hand out over input it did not understand.
408
+ if (!RECONCILE_VERDICTS.includes(verdict)) {
409
+ throw new Error(
410
+ `archkeep: refusing to map a reconcile verdict ${describe(verdict)} — ` +
411
+ `expected one of ${RECONCILE_VERDICTS.join(", ")}. This is a bug in the ` +
412
+ `reconciliation that produced it.`,
413
+ );
414
+ }
415
+ // Same latch one axis down: a constraint row verdict outside the four states
416
+ // matches neither `unknown` nor `fail`, and the mapping reads it as consent.
417
+ for (const row of constraints) {
418
+ if (!isVerdict(row.verdict)) {
419
+ throw new Error(
420
+ `archkeep: refusing to map a constraint row whose verdict is ${describe(row.verdict)} — ` +
421
+ `expected one of ${VERDICTS.join(", ")}. This is a bug in the judge that built the row.`,
422
+ );
423
+ }
424
+ }
425
+ // used by its own test
392
426
  if (verdict === "unproven") return "no-verdict";
393
427
  if (verdict === "undeclared" || verdict === "unfulfilled") return "rejected";
394
428
  if (constraints.some((row) => row.verdict === "unknown")) return "no-verdict";
@@ -644,6 +678,28 @@ export async function changeCommand(
644
678
  dependencies: buildDependencies(graph.dependencies),
645
679
  };
646
680
  const structural = computeDiff(baseGraphForDiff, headGraphForDiff);
681
+ // The event's identity sides are frozen HERE — at the same instant the diff
682
+ // above was computed from these same graph objects — so the identity always
683
+ // describes the graph the diff actually compared. Everything between this
684
+ // line and the event assembly (constraint re-judgement, custom rules,
685
+ // classification, debt) reads the graphs too; the engine's purity is what
686
+ // keeps them honest today, but the WS-I adversarial audit named the seam:
687
+ // identity and diff agreeing must not rest on purity conventions alone when
688
+ // one structural line closes it.
689
+ const baseCommit = baseline.provenance?.commit;
690
+ const headCommit = headProvenance?.commit;
691
+ const eventBase = eventSnapshotSide({
692
+ revision: baseCommit,
693
+ projects: baseGraphForDiff.projects,
694
+ dependencies: baseGraphForDiff.dependencies,
695
+ policyFingerprint: baseline.policyFingerprint,
696
+ });
697
+ const eventHead = eventSnapshotSide({
698
+ revision: headCommit,
699
+ projects: headGraphForDiff.projects,
700
+ dependencies: headGraphForDiff.dependencies,
701
+ policyFingerprint: headFingerprint,
702
+ });
647
703
  const reconciliation = reconcileMaterialDelta(intent, structural);
648
704
 
649
705
  // Constraints and the law axis are computed over both sides re-judged under
@@ -732,16 +788,10 @@ export async function changeCommand(
732
788
  );
733
789
  }
734
790
  if (meta.dirtyBaseline) {
735
- notes.push(
736
- "the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
737
- "claim about the commit the contract pins",
738
- );
791
+ notes.push(dirtyBaselineNote(true));
739
792
  }
740
793
  if (meta.dirtyHead) {
741
- notes.push(
742
- "this run's working tree is dirty — the head side describes uncommitted state, not the " +
743
- "commit HEAD names",
744
- );
794
+ notes.push(dirtyHeadNote());
745
795
  }
746
796
  if (meta.provenanceOneSided) {
747
797
  notes.push(
@@ -851,23 +901,24 @@ export async function changeCommand(
851
901
  }
852
902
  }
853
903
 
854
- const baseCommit = baseline.provenance?.commit;
855
- const headCommit = headProvenance?.commit;
856
904
  /** @type {object} */
857
905
  const event = {
858
906
  schemaVersion: EVOLUTION_EVENT_SCHEMA_VERSION,
859
907
  kind: "reconcile",
860
908
  source: "change",
861
- base: {
862
- ...(typeof baseCommit === "string" ? { revision: baseCommit } : {}),
863
- // The caller's own evidence ref the baseline file this run consumed,
864
- // spelled as the run received it (the same convention `declaration.file`
865
- // uses for the intent path).
866
- evidence: baselinePath,
867
- },
868
- head: {
869
- ...(typeof headCommit === "string" ? { revision: headCommit } : {}),
870
- },
909
+ // Both sides were frozen through the ONE identity spelling
910
+ // (`eventSnapshotSide`, ./history.mjs) at the diff site above: a revision
911
+ // when one is known, plus the snapshot identity of the graph the diff
912
+ // compared never the baseline's storage path, which is machine-local
913
+ // and must not make the identity a per-machine property.
914
+ base: eventBase,
915
+ head: eventHead,
916
+ // The caller's own evidence ref — the baseline file this run consumed,
917
+ // spelled as the run received it (the same convention `declaration.file`
918
+ // uses for the intent path). Disclosed OUTSIDE the identity: the tuple
919
+ // above names the state itself, so a relocated baseline is still the
920
+ // same event.
921
+ evidence: baselinePath,
871
922
  declaration: { file: intentPath, digest: declarationDigest(intent) },
872
923
  observed,
873
924
  affected: evolution.affected,
@@ -898,6 +949,18 @@ export async function changeCommand(
898
949
  /** @type {{dir: string, id: string, duplicate: boolean}|null} */
899
950
  let eventWritten = null;
900
951
  if (eventOut !== undefined && eventOut !== null && eventOut !== "") {
952
+ // F-delta-event-id on the reconcile event: the event's identity names
953
+ // base and head revisions, so a commitless head or a dirty tree collapses
954
+ // distinct evidence states onto one event id — a later transition is
955
+ // silently lost or aliased, the silent direction. `delta` holds the same
956
+ // gate; the shared law and its frozen wording live in
957
+ // `../governance/evolution-event.mjs`.
958
+ assertReproducibleEventIdentity({
959
+ label: "change",
960
+ headCommit,
961
+ baseDirty: baseline.provenance?.dirty === true,
962
+ headDirty: headProvenance?.dirty === true,
963
+ });
901
964
  // The store's io.root is the workspace root: the write must be provable
902
965
  // to stay inside the workspace (the same containment `--output` obeys).
903
966
  // A write failure throws — exit 3 upstream, the could-not-look lane —
@@ -29,7 +29,10 @@ import {
29
29
  unownedGapWithoutRunConfiguration,
30
30
  untrackedOwnedFiles,
31
31
  } from "./context.mjs";
32
- import { partitionUnownedCoverage } from "./coverage-acceptance.mjs";
32
+ import {
33
+ partitionUnownedCoverage,
34
+ withdrawAcceptedUnclaimedFailures,
35
+ } from "./coverage-acceptance.mjs";
33
36
  import { readAdrContext } from "./adr.mjs";
34
37
  import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
35
38
  import { declaredEdgeViolationsForCheck, judgeEdge } from "../rules/edge-constraints.mjs";
@@ -223,7 +226,7 @@ export async function check(
223
226
  );
224
227
  const { root, graph, workspace, tracked } = commandContext;
225
228
  const { imports, exemptedFiles, unsupportedLanguageFiles } = commandContext.analysis;
226
- const failures = [...commandContext.analysis.failures];
229
+ let failures = [...commandContext.analysis.failures];
227
230
  const analyzed = commandContext.analysis.analyzed;
228
231
 
229
232
  // The config's location is a separate fact from the workspace root, which is
@@ -308,17 +311,16 @@ export async function check(
308
311
  // acceptance now — stated below as the `"accepted-unowned-files"` coverage
309
312
  // gap, never silently — rather than the exit-3 refusal an unanswered
310
313
  // orphan earns. Uncovered unclaimed files keep their failures, and with
311
- // them the exit code, byte-identical to before the channel existed. An
312
- // unclaimed file carries exactly one failure (it is unowned, so no
313
- // analyzer ever read it), so filtering by file cannot drop an unrelated
314
- // read failure.
314
+ // them the exit code, byte-identical to before the channel existed. The
315
+ // one-failure-per-file assumption the withdrawal rests on is enforced where
316
+ // it is spent (`./coverage-acceptance.mjs`), not stated here: an accepted
317
+ // file carrying a second failure refuses the run instead of losing both
318
+ // rows to a by-file splice.
315
319
  const acceptedUnclaimed = new Set(
316
320
  commandContext.unclaimedGap.files.filter((file) => unownedCoverage.acceptedFiles.has(file)),
317
321
  );
318
322
  if (acceptedUnclaimed.size > 0) {
319
- for (let at = failures.length - 1; at >= 0; at -= 1) {
320
- if (acceptedUnclaimed.has(failures[at].sourceFile)) failures.splice(at, 1);
321
- }
323
+ failures = withdrawAcceptedUnclaimedFailures(failures, acceptedUnclaimed);
322
324
  }
323
325
 
324
326
  // The go.work drift check, keyed off the manifest's presence the way every
@@ -40,10 +40,10 @@ export const EVALUATION_STATUS = Object.freeze({
40
40
  REFUSED: "refused",
41
41
  });
42
42
  export const EVALUATED = EVALUATION_STATUS.EVALUATED;
43
- export const PARTIAL = EVALUATION_STATUS.PARTIAL;
43
+ export const PARTIAL = EVALUATION_STATUS.PARTIAL; // used by its own test
44
44
  export const NOT_EVALUATED = EVALUATION_STATUS.NOT_EVALUATED;
45
- export const UNSUPPORTED = EVALUATION_STATUS.UNSUPPORTED;
46
- export const REFUSED = EVALUATION_STATUS.REFUSED;
45
+ export const UNSUPPORTED = EVALUATION_STATUS.UNSUPPORTED; // used by its own test
46
+ export const REFUSED = EVALUATION_STATUS.REFUSED; // used by its own test
47
47
 
48
48
  // ---------------------------------------------------------------------------
49
49
  // Evaluation contract types — which gates are required per evaluation type
@@ -71,7 +71,7 @@ export const EVALUATION_CONTRACT_TYPES = Object.freeze({
71
71
  *
72
72
  * @type {Readonly<Object<string, ReadonlySet<string>>>}
73
73
  */
74
- export const REQUIRED_GATES_FOR_CONTRACT = Object.freeze({
74
+ const REQUIRED_GATES_FOR_CONTRACT = Object.freeze({
75
75
  [EVALUATION_CONTRACT_TYPES.CANONICAL]: Object.freeze(
76
76
  new Set([
77
77
  "domainCoverage",
@@ -107,7 +107,7 @@ export const REQUIRED_GATES_FOR_CONTRACT = Object.freeze({
107
107
  * Defaults to SCENARIO (most restrictive).
108
108
  * @returns {boolean}
109
109
  */
110
- export function isGateRequired(gateKey, contractType = EVALUATION_CONTRACT_TYPES.SCENARIO) {
110
+ function isGateRequired(gateKey, contractType = EVALUATION_CONTRACT_TYPES.SCENARIO) {
111
111
  const required = REQUIRED_GATES_FOR_CONTRACT[contractType];
112
112
  return required ? required.has(gateKey) : true;
113
113
  }
@@ -121,6 +121,7 @@ export function isGateRequired(gateKey, contractType = EVALUATION_CONTRACT_TYPES
121
121
  * @type {Readonly<{key: string, label: string, type: string}[]>}
122
122
  */
123
123
  export const EVIDENCE_COMPLETE_GATES = Object.freeze([
124
+ // used by its own test
124
125
  { key: "domainCoverage", label: "Domain coverage", type: "ratio" },
125
126
  { key: "claimEvidenceCoverage", label: "Claim evidence coverage", type: "ratio" },
126
127
  { key: "causalCoverage", label: "Causal coverage", type: "ratio" },
@@ -265,7 +266,7 @@ export const REQUIRED_DOMAINS = Object.freeze([
265
266
  * @param {string} status EVALUATION_STATUS value
266
267
  * @returns {boolean}
267
268
  */
268
- export function isDomainEvaluated(status) {
269
+ function isDomainEvaluated(status) {
269
270
  return status === EVALUATION_STATUS.EVALUATED;
270
271
  }
271
272
 
@@ -111,3 +111,49 @@ export function partitionUnownedCoverage({ rows, unownedGap, unclaimedFiles, tra
111
111
  },
112
112
  };
113
113
  }
114
+
115
+ /**
116
+ * Withdraws each accepted unclaimed file's failure from the run's failure
117
+ * list — the acceptance channel's effect on it, kept beside the matching so
118
+ * the channel's contract (accept the file ⇒ withdraw its refusal) is one
119
+ * module's. `./check.mjs` holds the list and narrows `acceptedFiles` to the
120
+ * unclaimed half; `./waivers.mjs` has no failure list and only reports rows.
121
+ *
122
+ * An accepted unclaimed file carries exactly one failure — its unowned-file
123
+ * refusal, the only row any producer appends for a file no analyzer ever
124
+ * read. The withdrawal does not TRUST that assumption, it ENFORCES it: an
125
+ * accepted file carrying more than one failure means some producer appended a
126
+ * second row for it, and a splice by `sourceFile` would take the second one
127
+ * down with the first — the failure count falls, `coverage.notAnalyzed` loses
128
+ * a row, and a could-not-look run tips toward a clean one, the silent
129
+ * direction (`../../../../AGENTS.md`). The run refuses instead, naming the
130
+ * file and the count it found.
131
+ *
132
+ * @param {{sourceFile: string}[]} failures The run's failure list, in order.
133
+ * @param {Set<string>} acceptedFiles The accepted subset of the unclaimed
134
+ * list — `partitionUnownedCoverage`'s `acceptedFiles` narrowed to that list
135
+ * by the caller.
136
+ * @returns {{sourceFile: string}[]} A new array without the accepted files'
137
+ * single failures; the input is not mutated.
138
+ * @throws {Error} when an accepted file carries more than one failure.
139
+ */
140
+ export function withdrawAcceptedUnclaimedFailures(failures, acceptedFiles) {
141
+ const carried = new Map();
142
+ for (const failure of failures) {
143
+ if (!acceptedFiles.has(failure.sourceFile)) continue;
144
+ carried.set(failure.sourceFile, (carried.get(failure.sourceFile) ?? 0) + 1);
145
+ }
146
+ const overloaded = [...carried.entries()].filter(([, count]) => count > 1);
147
+ if (overloaded.length > 0) {
148
+ const named = overloaded
149
+ .map(([file, count]) => `'${file}' (carries ${count} failures)`)
150
+ .join(", ");
151
+ throw new Error(
152
+ `archkeep: the coverage.unowned acceptance channel cannot withdraw ${named} — ` +
153
+ `acceptance withdraws exactly one failure per file (the unowned-file refusal), and ` +
154
+ `withdrawing by file would drop the second one with it, understating what this run ` +
155
+ `could not read`,
156
+ );
157
+ }
158
+ return failures.filter((failure) => !acceptedFiles.has(failure.sourceFile));
159
+ }
@@ -140,6 +140,7 @@ export function declaresCustomRules(config) {
140
140
  * @returns {(artifact: string) => Uint8Array|null}
141
141
  */
142
142
  export function readArtifactBytes(root) {
143
+ // used by its own test
143
144
  return (artifact) => {
144
145
  const abs = join(root, artifact);
145
146
  if (containmentViolation(root, abs) !== null) return null;
@@ -79,6 +79,7 @@ import { namespacedId } from "./custom-rules.mjs";
79
79
  * constraint: object|null}}|{ok: false, reason: string}}
80
80
  */
81
81
  export function violationIdentity(violation) {
82
+ // used by its own test
82
83
  if (violation === null || typeof violation !== "object" || Array.isArray(violation)) {
83
84
  return { ok: false, reason: `violation is ${describe(violation)}, not an object` };
84
85
  }
@@ -148,6 +149,7 @@ export function violationIdentity(violation) {
148
149
  * original violation plus the `reason` its identity could not be stated.
149
150
  */
150
151
  export function classifyViolations({ base, head, suppressions = [], now = referenceTime() }) {
152
+ // used by its own test
151
153
  const baseIdentified = base.map(identityOf);
152
154
  const headIdentified = head.map(identityOf);
153
155
 
@@ -237,6 +239,7 @@ export function classifyViolations({ base, head, suppressions = [], now = refere
237
239
  * unknown: object[]}}
238
240
  */
239
241
  export function classifyUnresolvableRecords({ base, head, sourceProjectOf }) {
242
+ // used by its own test
240
243
  const attribute = sourceProjectOf ?? (() => null);
241
244
  const baseIdentified = base
242
245
  .filter(isUnresolvable)
@@ -52,7 +52,7 @@ import { isPlainObject } from "../values.mjs";
52
52
  import { buildDependencies, buildProjects } from "./graph.mjs";
53
53
 
54
54
  /** The only snapshot schemaVersion this module writes and reads. */
55
- export const EVIDENCE_SNAPSHOT_SCHEMA_VERSION = 1;
55
+ export const EVIDENCE_SNAPSHOT_SCHEMA_VERSION = 1; // used by its own test
56
56
 
57
57
  /**
58
58
  * Builds the snapshot object from already-captured evidence.
@@ -274,7 +274,9 @@ export function buildEvidenceSnapshot({
274
274
  * order is the only order the format keeps, and `buildEvidenceSnapshot` sorts
275
275
  * every array whose source does not guarantee it. Two captures over one
276
276
  * unchanged tree produce byte-identical files, which is what makes a plain
277
- * `diff` of two baselines meaningful.
277
+ * `diff` of two baselines meaningful. That determinism is also why this
278
+ * family carries no identity field, by decision: the bytes are the identity
279
+ * (`../../../../docs/adr/0008-snapshot-identity-per-family.md`).
278
280
  *
279
281
  * @param {object} snapshot From `buildEvidenceSnapshot`.
280
282
  * @returns {string} The JSON text, newline-terminated.
@@ -330,7 +332,11 @@ export function readEvidenceSnapshot(path, io = {}) {
330
332
  * What is deliberately NOT a refusal: dirty base provenance. A baseline from
331
333
  * an uncommitted tree is weaker evidence, not unreadable evidence — the parsed
332
334
  * snapshot exposes `provenance.dirty` so the renderer can say so loudly, and
333
- * classification itself proceeds.
335
+ * classification itself proceeds. A dirty BIT that is not a boolean is a
336
+ * different thing — malformed, not weaker: the gates read `dirty === true`
337
+ * while the renderers interpolate truthiness, so any other type would read
338
+ * clean to some consumers and dirty to others. The provenance block below
339
+ * refuses it by name.
334
340
  *
335
341
  * @param {string} text The file contents.
336
342
  * @param {string} path The path the text came from, for error messages.
@@ -338,6 +344,7 @@ export function readEvidenceSnapshot(path, io = {}) {
338
344
  * @throws {Error} on every condition above.
339
345
  */
340
346
  export function parseEvidenceSnapshot(text, path) {
347
+ // used by its own test
341
348
  let parsed;
342
349
  try {
343
350
  parsed = JSON.parse(text);
@@ -391,8 +398,23 @@ export function parseEvidenceSnapshot(text, path) {
391
398
 
392
399
  if (parsed.provenance !== null && !isPlainObject(parsed.provenance)) {
393
400
  problems.push("provenance: must be an object ({commit, remote, dirty}) or null");
394
- } else if (isPlainObject(parsed.provenance) && typeof parsed.provenance.commit !== "string") {
395
- problems.push("provenance.commit: must be a string when provenance is present");
401
+ } else if (isPlainObject(parsed.provenance)) {
402
+ if (typeof parsed.provenance.commit !== "string") {
403
+ problems.push("provenance.commit: must be a string when provenance is present");
404
+ }
405
+ if (parsed.provenance.remote !== null && typeof parsed.provenance.remote !== "string") {
406
+ problems.push("provenance.remote: must be a string or null when provenance is present");
407
+ }
408
+ if (typeof parsed.provenance.dirty !== "boolean") {
409
+ // Malformed, not weaker: the gates read `dirty === true` while the
410
+ // renderers interpolate truthiness, so a value of any other type reads
411
+ // clean to some consumers and dirty to others — the one disagreement
412
+ // this record must never carry.
413
+ problems.push(
414
+ "provenance.dirty: must be a boolean when provenance is present — the gates decide " +
415
+ "between committed and uncommitted evidence on this bit",
416
+ );
417
+ }
396
418
  }
397
419
 
398
420
  if (typeof parsed.policyFingerprint !== "string" || parsed.policyFingerprint === "") {
@@ -74,6 +74,7 @@ import {
74
74
  import { stripTrailingSlashes } from "../path-util.mjs";
75
75
  import { referenceTime } from "../governance/clock.mjs";
76
76
  import {
77
+ assertReproducibleEventIdentity,
77
78
  eventDedupeKey,
78
79
  eventId,
79
80
  EVOLUTION_EVENT_SCHEMA_VERSION,
@@ -105,9 +106,10 @@ import {
105
106
  } from "./delta-snapshot.mjs";
106
107
  import { computeDiff } from "./diff.mjs";
107
108
  import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
109
+ import { eventSnapshotSide } from "./history.mjs";
108
110
  import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
109
111
  import { resolveProvenance } from "./provenance.mjs";
110
- import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
112
+ import { compareSnapshotMetadata, dirtyBaselineNote, dirtyHeadNote } from "./snapshot-meta.mjs";
111
113
 
112
114
  const require = createRequire(import.meta.url);
113
115
  /** @type {{name: string, version: string}} */
@@ -161,6 +163,7 @@ export function refusePluginGapHead(commandContext, activity) {
161
163
  * @throws {Error} on either condition.
162
164
  */
163
165
  export function refuseUnjudgeableHead(commandContext, activity) {
166
+ // used by its own test
164
167
  refusePluginGapHead(commandContext, activity);
165
168
  const notAnalyzed = commandContext.analysis.failures.filter(isWholeFileFailure);
166
169
 
@@ -271,6 +274,8 @@ export function captureDelta(commandContext, { config }) {
271
274
  * type: string}[], workspaceLayout?: object, exemptedFiles?: string[]}} storedGraph
272
275
  * A validated snapshot's `graph` section (`parseEvidenceSnapshot`).
273
276
  * @returns {object} A graph `evaluate()` consumes.
277
+ * @throws {Error} when an `exemptedFiles` entry is not a string — the engine's
278
+ * own filter would drop it in silence (the refusal beside the copy below).
274
279
  */
275
280
  export function evidenceGraphToProjectGraph(storedGraph) {
276
281
  /** @type {Record<string, object>} */
@@ -297,7 +302,26 @@ export function evidenceGraphToProjectGraph(storedGraph) {
297
302
  const graph = { nodes, dependencies };
298
303
  if (storedGraph.workspaceLayout !== undefined)
299
304
  graph.workspaceLayout = storedGraph.workspaceLayout;
300
- if (Array.isArray(storedGraph.exemptedFiles)) graph.exemptedFiles = storedGraph.exemptedFiles;
305
+ if (Array.isArray(storedGraph.exemptedFiles)) {
306
+ // An entry that is not a string is refused here rather than carried:
307
+ // `../rules/index.mjs`'s `createContext` filters `graph.exemptedFiles`
308
+ // with `typeof file === "string"`, so a corrupted snapshot's entry would
309
+ // ride through this conversion and vanish there — the exemption set the
310
+ // snapshot recorded silently shrinking by one file, the under-count
311
+ // disclosed nowhere.
312
+ const malformed = storedGraph.exemptedFiles
313
+ .map((entry, at) => ({ entry, at }))
314
+ .filter(({ entry }) => typeof entry !== "string");
315
+ if (malformed.length > 0) {
316
+ const { entry, at } = malformed[0];
317
+ throw new Error(
318
+ `archkeep: the snapshot's graph.exemptedFiles[${at}] is ${JSON.stringify(entry)}, ` +
319
+ `not a string — the rule engine drops such entries in silence, which would shrink ` +
320
+ `the exemption set the snapshot recorded; re-capture the baseline or correct the file`,
321
+ );
322
+ }
323
+ graph.exemptedFiles = storedGraph.exemptedFiles;
324
+ }
301
325
  return graph;
302
326
  }
303
327
 
@@ -373,6 +397,7 @@ export function sourceProjectAttributor(headGraph, baselineProjects) {
373
397
  * @returns {"accepted"|"rejected"|"no-verdict"}
374
398
  */
375
399
  export function deltaDisposition({ status }) {
400
+ // used by its own test
376
401
  if (status === "no-verdict") return "no-verdict";
377
402
  if (status === "findings") return "rejected";
378
403
  return "accepted";
@@ -545,16 +570,10 @@ export async function deltaCommand(
545
570
  );
546
571
  }
547
572
  if (meta.dirtyBaseline) {
548
- notes.push(
549
- "the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
550
- "claim about the commit it names",
551
- );
573
+ notes.push(dirtyBaselineNote(false));
552
574
  }
553
575
  if (meta.dirtyHead) {
554
- notes.push(
555
- "this run's working tree is dirty — the head side describes uncommitted state, not the " +
556
- "commit HEAD names",
557
- );
576
+ notes.push(dirtyHeadNote());
558
577
  }
559
578
 
560
579
  // The event's structural signal: what moved between the two graphs the
@@ -564,10 +583,11 @@ export async function deltaCommand(
564
583
  // a second spelling of "changed". The head side is rebuilt by the same
565
584
  // `buildProjects`/`buildDependencies` the snapshot stores
566
585
  // (`./delta-snapshot.mjs`), which is what makes the two sides comparable.
567
- const structuralDiff = computeDiff(baseline.graph, {
586
+ const headGraph = {
568
587
  projects: buildProjects(graph.nodes),
569
588
  dependencies: buildDependencies(graph.dependencies),
570
- });
589
+ };
590
+ const structuralDiff = computeDiff(baseline.graph, headGraph);
571
591
  const structural = {
572
592
  projects: {
573
593
  added: structuralDiff.addedProjects.map((project) => project.name),
@@ -594,6 +614,25 @@ export async function deltaCommand(
594
614
  // same", and a policy change is disclosed, not folded into drift.
595
615
  const baseCommit = baseline.provenance?.commit;
596
616
  const headCommit = headProvenance?.commit;
617
+ // The event's identity sides are frozen HERE — at the same instant the diff
618
+ // above was computed from these same graph objects — so the identity always
619
+ // describes the graph the diff actually compared. Everything below this line
620
+ // (custom rules, classification, intent, debt) reads the graphs too; the
621
+ // engine's purity is what keeps them honest today, but the WS-I adversarial
622
+ // audit named the seam: identity and diff agreeing must not rest on purity
623
+ // conventions alone when one structural line closes it.
624
+ const eventBase = eventSnapshotSide({
625
+ revision: baseCommit,
626
+ projects: baseline.graph.projects,
627
+ dependencies: baseline.graph.dependencies,
628
+ policyFingerprint: baseline.policyFingerprint,
629
+ });
630
+ const eventHead = eventSnapshotSide({
631
+ revision: headCommit,
632
+ projects: headGraph.projects,
633
+ dependencies: headGraph.dependencies,
634
+ policyFingerprint: headFingerprint,
635
+ });
597
636
  const provenanceAdvanced =
598
637
  typeof baseCommit === "string" &&
599
638
  typeof headCommit === "string" &&
@@ -810,26 +849,16 @@ export async function deltaCommand(
810
849
  let eventWrite = null;
811
850
  if (eventOut !== null && eventOut !== undefined) {
812
851
  // F-delta-event-id: an evolution event is only written from a reproducible
813
- // identity — a committed, clean head and a clean base. A commitless head
814
- // has no revision to name, and a dirty tree names a commit its evidence
815
- // does not back; either way TWO distinct evidence states collapse onto ONE
816
- // event id, so a later transition is silently lost or aliased (the silent
817
- // direction). Refuse loudly instead. The same run without `--event-out`
818
- // stays a byte-identical in-memory delta.
819
- if (typeof headCommit !== "string") {
820
- throw new Error(
821
- "archkeep: refusing to write a delta event without a committed head — a commitless " +
822
- "head has no reproducible event identity, and every distinct head state would " +
823
- "collide on one event id. Commit the head, or capture without --event-out.",
824
- );
825
- }
826
- if (baseline.provenance?.dirty === true || headProvenance?.dirty === true) {
827
- throw new Error(
828
- "archkeep: refusing to write a delta event from a dirty working tree — the event " +
829
- "would name a commit whose evidence is uncommitted, and distinct uncommitted " +
830
- "states would collide on one event id. Commit both sides first.",
831
- );
832
- }
852
+ // identity — a committed, clean head and a clean base, by the shared law
853
+ // `assertReproducibleEventIdentity` owns. The wording is frozen there; the
854
+ // messages consumers match on are byte-identical to the inline refusals
855
+ // this call replaces.
856
+ assertReproducibleEventIdentity({
857
+ label: "delta",
858
+ headCommit,
859
+ baseDirty: baseline.provenance?.dirty === true,
860
+ headDirty: headProvenance?.dirty === true,
861
+ });
833
862
  // The architecture-debt sub-ledger (design §8): judged by re-running the
834
863
  // current intent over this run's base and head graphs — a drift finding
835
864
  // present at head but not base is introduced; one gone is resolved. Both
@@ -864,13 +893,18 @@ export async function deltaCommand(
864
893
  schemaVersion: EVOLUTION_EVENT_SCHEMA_VERSION,
865
894
  kind: "transition",
866
895
  source: "delta",
867
- base: {
868
- ...(typeof baseCommit === "string" ? { revision: baseCommit } : {}),
869
- // The evidence ref is the baseline file this run actually compared
870
- // against a pointer into the evidence, never a graph.
871
- evidence: baselinePath,
872
- },
873
- head: typeof headCommit === "string" ? { revision: headCommit } : {},
896
+ // Both sides were frozen through the ONE identity spelling
897
+ // (`eventSnapshotSide`, ./history.mjs) at the diff site above: a
898
+ // revision when one is known, plus the snapshot identity of the graph
899
+ // the diff compared never the baseline's storage path, which is
900
+ // machine-local and must not make the identity a per-machine property.
901
+ base: eventBase,
902
+ head: eventHead,
903
+ // The evidence ref is the baseline file this run actually compared
904
+ // against — a pointer into the evidence, never a graph, and disclosed
905
+ // OUTSIDE the identity: the tuple above names the state itself, so a
906
+ // relocated baseline is still the same event.
907
+ evidence: baselinePath,
874
908
  recordedAt: recordOrigin({
875
909
  by: "cli",
876
910
  tool: `archkeep:v${TOOL_VERSION}`,
@@ -46,7 +46,7 @@ import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
46
46
  import { SCHEMA_VERSION } from "../report/json.mjs";
47
47
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
48
48
  import { formatDiffReport } from "../report/diff-text.mjs";
49
- import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
49
+ import { compareSnapshotMetadata, dirtyBaselineNote, dirtyHeadNote } from "./snapshot-meta.mjs";
50
50
  import { resolveProvenance } from "./provenance.mjs";
51
51
 
52
52
  /**
@@ -475,16 +475,10 @@ export function diffCommand(
475
475
  }
476
476
 
477
477
  if (meta.dirtyBaseline) {
478
- coverage.notes.push(
479
- "the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
480
- "claim about the commit it names",
481
- );
478
+ coverage.notes.push(dirtyBaselineNote(false));
482
479
  }
483
480
  if (meta.dirtyHead) {
484
- coverage.notes.push(
485
- "this run's working tree is dirty — the head side describes uncommitted state, not the " +
486
- "commit HEAD names",
487
- );
481
+ coverage.notes.push(dirtyHeadNote());
488
482
  }
489
483
 
490
484
  if (meta.policyChanged === true) {