@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
@@ -71,30 +71,37 @@
71
71
  * change lives in `./change-intent.mjs`'s validation (`parseChangeIntent`),
72
72
  * the same loud lane as every other malformed declaration.
73
73
  *
74
- * Refusals (each a throw exit 3 upstream): a manifest that fails shape or
75
- * reference validation, an unreadable/malformed/foreign-schema baseline,
76
- * incomplete baseline coverage, a provider mismatch, incomplete head
77
- * coverage, an unregistered-plugin graph over polyglot manifests, and a run
78
- * with no boundary law (constraints and the law fingerprint need one).
74
+ * Refusals: incomplete head coverage returns the structured no-verdict
75
+ * envelope `./coverage-verdict.mjs` builds (#608) — status "no-verdict",
76
+ * exit 3, a `coverage` block naming every file and site the run could not
77
+ * judge where a parser and `--output` can read it; the rest are throws →
78
+ * exit 3 upstream: a manifest that fails shape or reference validation, an
79
+ * unreadable/malformed/foreign-schema baseline, incomplete baseline coverage,
80
+ * a provider mismatch, an unregistered-plugin graph over polyglot manifests,
81
+ * and a run with no boundary law (constraints and the law fingerprint need
82
+ * one).
79
83
  *
80
84
  * This module computes and returns; `../../cli.mjs`'s `runChange` owns argv,
81
85
  * output destination and the process exit code (`./README.md`).
82
86
  */
83
- import { classifyDelta } from "./delta-classify.mjs";
87
+ import { classifyDelta, edgeEvolutionIdentity } from "./delta-classify.mjs";
84
88
  import { computeDiff } from "./diff.mjs";
85
89
  import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
86
90
  import {
87
91
  CONSTRAINT_ORDER,
88
92
  CONSTRAINT_ROW_NAMES,
93
+ edgePairKey,
89
94
  findChangeIntentReferenceViolations,
90
95
  readChangeIntent,
91
96
  } from "./change-intent.mjs";
92
97
  import {
93
98
  evidenceGraphToProjectGraph,
94
- refuseUnjudgeableHead,
99
+ refusePluginGapHead,
95
100
  sourceProjectAttributor,
96
101
  } from "./delta.mjs";
97
102
  import { providerMismatch, readEvidenceSnapshot } from "./delta-snapshot.mjs";
103
+ import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
104
+ import { blindSpotRows } from "../analysis/source-util.mjs";
98
105
  import { cyclicProjects } from "../governance/fitness-rules.mjs";
99
106
  import { fitnessVerdict } from "../governance/verdict.mjs";
100
107
  import { buildDecision } from "../report/evidence.mjs";
@@ -107,6 +114,7 @@ import { referenceTime } from "../governance/clock.mjs";
107
114
  import {
108
115
  classifyEvolution,
109
116
  declarationDigest,
117
+ escapeIdentityField,
110
118
  eventDedupeKey,
111
119
  eventId,
112
120
  EVOLUTION_EVENT_SCHEMA_VERSION,
@@ -145,27 +153,26 @@ function cmpFacts(a, b) {
145
153
  * The identity string a delta-classified violation entry carries into the
146
154
  * event's `findings` — the same identity fields the delta classification
147
155
  * emits (`messageId`, `sourceProject`, `target`), serialized deterministically
148
- * so the ref is stable across runs over the same transition.
156
+ * so the ref is stable across runs over the same transition. Fields are
157
+ * escaped through `escapeIdentityField`, so a `:` inside a project name no
158
+ * longer reads as the field separator and two distinct entries never share
159
+ * one id (#628). The sentinel is the ONE literal written unescaped: it is
160
+ * this function's own spelling for "absent", so a source project literally
161
+ * named `-` escapes to `\-` instead of merging with it — while an absent
162
+ * source project still spells exactly what earlier versions spelled, byte
163
+ * for byte.
149
164
  *
150
- * @param {{messageId: string, sourceProject: string|null, target: string}} entry
151
- * @returns {string}
152
- */
153
- function violationFindingId(entry) {
154
- return `${entry.messageId}:${entry.sourceProject ?? "-"}:${entry.target}`;
155
- }
156
-
157
- /**
158
- * The identity string an observed edge carries into the event's `observed`
159
- * and `affected` — `(source, target, type)`, the triple `./diff.mjs`'s
160
- * `edgeIdentityKey` owns, spelled for a human reader (`>` separator, optional
161
- * type suffix). Two spellings of one triple never diverge because the triple
162
- * itself is the input.
165
+ * Exported as the ONE spelling of this id — the same arrangement
166
+ * `edgeEvolutionIdentity` holds for edge identity — so a consumer composing
167
+ * the command layer cannot grow a second one that drifts.
163
168
  *
164
- * @param {{source: string, target: string, type?: string}} edge
169
+ * @param {{messageId: string, sourceProject: string|null, target: string}} entry
165
170
  * @returns {string}
166
171
  */
167
- function edgeIdentityString(edge) {
168
- return `${edge.source}>${edge.target}${edge.type === undefined || edge.type === "" ? "" : `:${edge.type}`}`;
172
+ export function violationFindingId(entry) {
173
+ const sourceProject =
174
+ entry.sourceProject == null ? "-" : escapeIdentityField(entry.sourceProject);
175
+ return `${escapeIdentityField(entry.messageId)}:${sourceProject}:${escapeIdentityField(entry.target)}`;
169
176
  }
170
177
 
171
178
  /**
@@ -173,6 +180,11 @@ function edgeIdentityString(edge) {
173
180
  * mapped from `computeDiff`'s output and the metadata comparison — the same
174
181
  * lists the reconciliation already consumed, never recomputed.
175
182
  *
183
+ * The edges' identity strings come from `edgeEvolutionIdentity`
184
+ * (`./delta-classify.mjs`) — the ONE spelling the evolution events'
185
+ * `observed.edges`/`affected.boundaries` use. A local spelling here would be
186
+ * a second definition of "same edge", and two definitions drift.
187
+ *
176
188
  * @param {{addedProjects: object[], removedProjects: object[],
177
189
  * changedProjects: object[], addedEdges: object[], removedEdges: object[]}} structural
178
190
  * @param {{policyChanged: boolean|null, policyOneSided: boolean,
@@ -189,8 +201,8 @@ function observedFrom(structural, meta) {
189
201
  changed: structural.changedProjects.map((project) => project.name),
190
202
  };
191
203
  const edges = {
192
- added: structural.addedEdges.map(edgeIdentityString),
193
- removed: structural.removedEdges.map(edgeIdentityString),
204
+ added: structural.addedEdges.map(edgeEvolutionIdentity),
205
+ removed: structural.removedEdges.map(edgeEvolutionIdentity),
194
206
  };
195
207
  return {
196
208
  architectureChanged:
@@ -240,9 +252,13 @@ function observedFrom(structural, meta) {
240
252
  export function reconcileMaterialDelta(intent, delta) {
241
253
  const addedProjectNames = new Set(intent.projects.add);
242
254
  const removedProjectNames = new Set(intent.projects.remove);
243
- const edgeKey = ({ from, to }) => `${from}\u0000${to}`;
244
- const addEdgeKeys = new Set(intent.edges.add.map(edgeKey));
245
- const removeEdgeKeys = new Set(intent.edges.remove.map(edgeKey));
255
+ // The declared row's identity is `./change-intent.mjs`'s `edgePairKey` — the
256
+ // ONE spelling, shared with the grammar's own duplicate detection. A local
257
+ // spelling here was the live defect (#613): it agreed byte-for-byte until a
258
+ // shape moved, and the reconciliation would then stop recognizing the
259
+ // declarations it was handed while every command-local test stayed green.
260
+ const addEdgeKeys = new Set(intent.edges.add.map(edgePairKey));
261
+ const removeEdgeKeys = new Set(intent.edges.remove.map(edgePairKey));
246
262
 
247
263
  /** @type {object[]} */
248
264
  const matched = [];
@@ -266,7 +282,7 @@ export function reconcileMaterialDelta(intent, delta) {
266
282
  to: edge.target,
267
283
  type: edge.type,
268
284
  });
269
- if (addEdgeKeys.has(edgeKey({ from: edge.source, to: edge.target }))) matched.push(row);
285
+ if (addEdgeKeys.has(edgePairKey({ from: edge.source, to: edge.target }))) matched.push(row);
270
286
  else unexpected.push(row);
271
287
  }
272
288
  for (const edge of delta.removedEdges) {
@@ -276,7 +292,7 @@ export function reconcileMaterialDelta(intent, delta) {
276
292
  to: edge.target,
277
293
  type: edge.type,
278
294
  });
279
- if (removeEdgeKeys.has(edgeKey({ from: edge.source, to: edge.target }))) matched.push(row);
295
+ if (removeEdgeKeys.has(edgePairKey({ from: edge.source, to: edge.target }))) matched.push(row);
280
296
  else unexpected.push(row);
281
297
  }
282
298
  // No declaration surface in this version: a metadata change to a project
@@ -295,10 +311,10 @@ export function reconcileMaterialDelta(intent, delta) {
295
311
  const observedAdded = new Set(delta.addedProjects.map((p) => p.name));
296
312
  const observedRemoved = new Set(delta.removedProjects.map((p) => p.name));
297
313
  const observedAddEdges = new Set(
298
- delta.addedEdges.map((edge) => edgeKey({ from: edge.source, to: edge.target })),
314
+ delta.addedEdges.map((edge) => edgePairKey({ from: edge.source, to: edge.target })),
299
315
  );
300
316
  const observedRemoveEdges = new Set(
301
- delta.removedEdges.map((edge) => edgeKey({ from: edge.source, to: edge.target })),
317
+ delta.removedEdges.map((edge) => edgePairKey({ from: edge.source, to: edge.target })),
302
318
  );
303
319
 
304
320
  // Declared but never observed — the unfulfilled half, kept apart from
@@ -316,12 +332,12 @@ export function reconcileMaterialDelta(intent, delta) {
316
332
  }
317
333
  }
318
334
  for (const edge of intent.edges.add) {
319
- if (!observedAddEdges.has(edgeKey(edge))) {
335
+ if (!observedAddEdges.has(edgePairKey(edge))) {
320
336
  missingExpected.push(factRow({ kind: "edge-added", from: edge.from, to: edge.to }));
321
337
  }
322
338
  }
323
339
  for (const edge of intent.edges.remove) {
324
- if (!observedRemoveEdges.has(edgeKey(edge))) {
340
+ if (!observedRemoveEdges.has(edgePairKey(edge))) {
325
341
  missingExpected.push(factRow({ kind: "edge-removed", from: edge.from, to: edge.to }));
326
342
  }
327
343
  }
@@ -490,8 +506,14 @@ function judgeDeclaredConstraints(intent, io) {
490
506
  * change event's `debt` diff judges the intent over this run's base and
491
507
  * head graphs and would be untestable without it.
492
508
  * @returns {Promise<{status: "ok"|"findings"|"no-verdict",
493
- * changeIntent: object, coverage: object, report: {text: string, json: string}}>}
494
- * @throws {Error} on every refusal the module header lists.
509
+ * changeIntent?: object, coverage: object, report: {text: string, json: string}}>}
510
+ * `status: "no-verdict"` from the coverage refusal (#608) carries no
511
+ * `changeIntent` payload — the reconciliation was withheld, and the
512
+ * envelope's `coverage` block plus its `decision.reason` are the whole
513
+ * answer.
514
+ * @throws {Error} on every refusal the module header lists. Incomplete head
515
+ * coverage returns the structured no-verdict envelope instead of throwing
516
+ * (#608); the unregistered-plugin graph keeps its throw.
495
517
  */
496
518
  export async function changeCommand(
497
519
  baselinePath,
@@ -530,7 +552,23 @@ export async function changeCommand(
530
552
  );
531
553
  }
532
554
 
533
- refuseUnjudgeableHead(commandContext, "reconcile a change intent");
555
+ // The plugin-gap refusal stays a throw; the coverage refusal returns the one
556
+ // structured envelope `./coverage-verdict.mjs` builds (#608) — status
557
+ // "no-verdict", exit 3, a `coverage` block naming every file and site the
558
+ // run could not judge — instead of the throw `refuseUnjudgeableHead` used to
559
+ // carry here. Reconciling a declaration over a half-read tree would answer
560
+ // "undeclared" about architecture the run never observed, and that refusal
561
+ // belongs in-band, where a parser and `--output` can read it.
562
+ refusePluginGapHead(commandContext, "reconcile a change intent");
563
+ const completeness = coverageVerdict(commandContext);
564
+ if (!completeness.complete) {
565
+ return coverageRefusal({
566
+ command: "change",
567
+ commandContext,
568
+ what: "reconciling a change intent",
569
+ decision: true,
570
+ });
571
+ }
534
572
 
535
573
  const intent = await (readIntent ? readIntent(intentPath) : readChangeIntent(intentPath));
536
574
 
@@ -718,7 +756,12 @@ export async function changeCommand(
718
756
  analyzedFiles: analysis.analyzed,
719
757
  imports: analysis.imports.length,
720
758
  notAnalyzed: [],
721
- blindSpots: [],
759
+ // The positioned failures the run SAW — including the dynamic and external
760
+ // sites that never withhold a verdict. This used to be hardcoded `[]`
761
+ // (#609): a declared limit named nowhere is a disclosure gap, and
762
+ // `blindSpotRows` is the one mapping every other command's coverage block
763
+ // carries.
764
+ blindSpots: blindSpotRows(analysis.failures),
722
765
  notes,
723
766
  };
724
767
 
@@ -745,7 +788,14 @@ export async function changeCommand(
745
788
  reason: entry.reason,
746
789
  }));
747
790
  const evolution = classifyEvolution({
748
- observed,
791
+ // The raw triples, not the mapped strings `observed` carries (that object
792
+ // is the event's stored record): `classifyEvolution` owns the identity
793
+ // spelling and takes the triples, so `affected.boundaries` is mapped
794
+ // inside it under the one spelling rather than trusted from the caller.
795
+ observed: {
796
+ ...observed,
797
+ edges: { added: structural.addedEdges, removed: structural.removedEdges },
798
+ },
749
799
  ...(classification === null
750
800
  ? {}
751
801
  : {
@@ -12,7 +12,12 @@
12
12
  import { statSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
 
15
- import { fileFailure, isWholeFileFailure } from "../analysis/source-util.mjs";
15
+ import {
16
+ blindSpotRows,
17
+ fileFailure,
18
+ isWholeFileFailure,
19
+ unresolvableLiteralCount,
20
+ } from "../analysis/source-util.mjs";
16
21
  import { tsconfigPathsFacts } from "../analysis/typescript.mjs";
17
22
  import { stripTrailingSlashes } from "../path-util.mjs";
18
23
  import { suppressionCovers } from "../config.mjs";
@@ -188,7 +193,7 @@ function declaredEdgeManifest({ provider, graph }, sourceProject) {
188
193
  * intentUnresolved: number, intentUnresolvedDecisionRefs: number, fitnessFail: number,
189
194
  * fitnessUnknown: number, customRuleFail: number, customRuleUnknown: number,
190
195
  * customRuleEvidence: {rule: string, bytes: Uint8Array}[], customRulesDeclared: boolean,
191
- * analyzed: number, unchecked: number, waived?: number}>}
196
+ * analyzed: number, unchecked: number, blindSpots: number, waived?: number}>}
192
197
  */
193
198
  export async function check(options, { cwd, readGraph, listFiles = listTrackedFiles }) {
194
199
  const commandContext = resolveCommandContext(
@@ -196,7 +201,7 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
196
201
  { readGraph, listFiles },
197
202
  );
198
203
  const { root, graph, workspace, tracked } = commandContext;
199
- const { imports, exemptedFiles } = commandContext.analysis;
204
+ const { imports, exemptedFiles, unsupportedLanguageFiles } = commandContext.analysis;
200
205
  const failures = [...commandContext.analysis.failures];
201
206
  const analyzed = commandContext.analysis.analyzed;
202
207
 
@@ -622,6 +627,18 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
622
627
  failures.filter(isWholeFileFailure).map((failure) => failure.sourceFile),
623
628
  ).size;
624
629
 
630
+ // Sites the run saw but never judged (#595): the file was analyzed, this
631
+ // site was not, and a pass over it would claim a verdict the run does not
632
+ // hold. Counted beside `unchecked` from one classifier
633
+ // (`unresolvableLiteralCount`) so the exit, the report and the envelope all
634
+ // agree from one number. The two classes that withholds nothing are
635
+ // excluded from the count: the declared dynamic limit — a non-literal
636
+ // `import()` argument, unknowable in principle — and the external
637
+ // bare-package site, whose resolvability depends on an installed dependency
638
+ // tree a workspace legitimately may not have (`isDynamicSiteFailure` and
639
+ // `isExternalSiteFailure` in source-util.mjs own the class line).
640
+ const blindSpotCount = unresolvableLiteralCount(failures);
641
+
625
642
  // A row of the boundary law that covers nothing is a boundary that stopped
626
643
  // being enforced, and — unlike a missing `reason`, which only a human can
627
644
  // judge — it is machine-detectable. Two tables can be dead, and both are
@@ -838,8 +855,38 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
838
855
  },
839
856
  ]
840
857
  : []),
858
+ // Files a project owns that no analyzer claims (#601): skipped before
859
+ // reading, so they land in no failure list — without this row the run
860
+ // would present the judged surface as the whole story. Disclosure, not
861
+ // failure: the row names them and their extensions, and leaves
862
+ // `complete` to the surface that WAS judged. Sorted, because the row's
863
+ // bytes must not vary with `git ls-files`' order (E-F10).
864
+ ...(unsupportedLanguageFiles.length > 0
865
+ ? [{ kind: "unsupported-language", files: [...unsupportedLanguageFiles].sort() }]
866
+ : []),
841
867
  ];
842
868
 
869
+ // One verdict computation for both faces: the JSON envelope spreads it and
870
+ // the text report renders its `reasons` beside the headline — a second call
871
+ // here would let the two faces disagree about a run neither re-derives from
872
+ // the other (`../verdict.mjs`'s header owns that argument).
873
+ const verdict = verdictFor({
874
+ violations: violations.length,
875
+ declaredEdgeFindings,
876
+ goWorkDrift,
877
+ tsconfigPathsDead,
878
+ intentFindings,
879
+ intentUnresolved,
880
+ intentUnresolvedDecisionRefs: intentUnresolvedDecisionRefRows.length,
881
+ unchecked,
882
+ analyzed,
883
+ blindSpots: blindSpotCount,
884
+ fitnessFail,
885
+ fitnessUnknown,
886
+ customRuleFail,
887
+ customRuleUnknown,
888
+ });
889
+
843
890
  const report =
844
891
  options.format === "json"
845
892
  ? renderJson(
@@ -861,36 +908,20 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
861
908
  // `decision` — the four-state verb of the same counts — so the
862
909
  // envelope's `decision.verdict` and its `status` are built from
863
910
  // exactly one computation and can never disagree.
864
- ...verdictFor({
865
- violations: violations.length,
866
- declaredEdgeFindings,
867
- goWorkDrift,
868
- tsconfigPathsDead,
869
- intentFindings,
870
- intentUnresolved,
871
- intentUnresolvedDecisionRefs: intentUnresolvedDecisionRefRows.length,
872
- unchecked,
873
- fitnessFail,
874
- fitnessUnknown,
875
- customRuleFail,
876
- customRuleUnknown,
877
- }),
911
+ ...verdict,
878
912
  coverage: {
879
- complete: unchecked === 0,
913
+ // Complete means the run judged everything in scope: no
914
+ // whole-file failure (unchecked), no unresolvable site (#595),
915
+ // and at least one file analyzed (#599 — a run that judged
916
+ // nothing has no verdict to claim).
917
+ complete: unchecked === 0 && blindSpotCount === 0 && analyzed > 0,
880
918
  projects: Object.keys(graph.nodes).length,
881
919
  analyzedFiles: analyzed,
882
920
  imports: imports.length,
883
921
  notAnalyzed: failures
884
922
  .filter(isWholeFileFailure)
885
923
  .map(({ sourceFile, reason }) => ({ file: sourceFile, reason })),
886
- blindSpots: failures
887
- .filter((failure) => !isWholeFileFailure(failure))
888
- .map(({ sourceFile, line, column, reason }) => ({
889
- file: sourceFile,
890
- line,
891
- column,
892
- reason,
893
- })),
924
+ blindSpots: blindSpotRows(failures),
894
925
  notes,
895
926
  coverageGaps,
896
927
  },
@@ -1020,6 +1051,10 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
1020
1051
  // inspected" fact does (`../report/text.mjs`'s `formatReport`).
1021
1052
  notes,
1022
1053
  coverageGaps,
1054
+ // `formatReport` (text) renders these beside the headline; the SARIF
1055
+ // face files the same facts as warning notifications. Both faces of
1056
+ // one run name the same clauses, in the same order.
1057
+ coverageIncomplete: verdict.reasons,
1023
1058
  // `formatReport` (text) reads this to annotate an unresolved
1024
1059
  // decisionRef inline; `formatSarif` files each one as a warning
1025
1060
  // notification. Both faces of one run name the same citations, in
@@ -1052,5 +1087,9 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
1052
1087
  customRulesDeclared: customRules !== null,
1053
1088
  analyzed,
1054
1089
  unchecked,
1090
+ // The site-level count `verdictFor` needs: `cli.mjs` passes this whole
1091
+ // return through `verdictFor` for the process's exit code, so a count the
1092
+ // envelope saw but the exit code did not would let the two disagree.
1093
+ blindSpots: blindSpotCount,
1055
1094
  };
1056
1095
  }
@@ -45,6 +45,72 @@ export const NOT_EVALUATED = EVALUATION_STATUS.NOT_EVALUATED;
45
45
  export const UNSUPPORTED = EVALUATION_STATUS.UNSUPPORTED;
46
46
  export const REFUSED = EVALUATION_STATUS.REFUSED;
47
47
 
48
+ // ---------------------------------------------------------------------------
49
+ // Evaluation contract types — which gates are required per evaluation type
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * The evaluation contract types that determine which Evidence-Complete gates
54
+ * are required for `overallComplete`.
55
+ *
56
+ * - `canonical`: Standard architecture evaluation (no mutations, no scenario).
57
+ * Gates NOT required: mutationCoverage, surfaceParity, baseIdentityValid.
58
+ * - `scenario`: Hypothetical scenario evaluation. ALL gates required.
59
+ *
60
+ * @type {Readonly<{CANONICAL: string, SCENARIO: string}>}
61
+ */
62
+ export const EVALUATION_CONTRACT_TYPES = Object.freeze({
63
+ CANONICAL: "canonical",
64
+ SCENARIO: "scenario",
65
+ });
66
+
67
+ /**
68
+ * Which Evidence-Complete gates are required for each contract type.
69
+ * A gate not listed here is still tracked and reported but does NOT block
70
+ * `overallComplete` — it is explicitly not applicable for that evaluation type.
71
+ *
72
+ * @type {Readonly<Object<string, ReadonlySet<string>>>}
73
+ */
74
+ export const REQUIRED_GATES_FOR_CONTRACT = Object.freeze({
75
+ [EVALUATION_CONTRACT_TYPES.CANONICAL]: Object.freeze(
76
+ new Set([
77
+ "domainCoverage",
78
+ "claimEvidenceCoverage",
79
+ "causalCoverage",
80
+ "provenanceCoverage",
81
+ "hiddenGapCount",
82
+ "falseCompleteCount",
83
+ "deterministic",
84
+ ]),
85
+ ),
86
+ [EVALUATION_CONTRACT_TYPES.SCENARIO]: Object.freeze(
87
+ new Set([
88
+ "domainCoverage",
89
+ "claimEvidenceCoverage",
90
+ "causalCoverage",
91
+ "provenanceCoverage",
92
+ "mutationCoverage",
93
+ "surfaceParity",
94
+ "hiddenGapCount",
95
+ "falseCompleteCount",
96
+ "baseIdentityValid",
97
+ "deterministic",
98
+ ]),
99
+ ),
100
+ });
101
+
102
+ /**
103
+ * Returns true when the given gate key is required for the given contract type.
104
+ *
105
+ * @param {string} gateKey The gate key (e.g. "domainCoverage").
106
+ * @param {string} [contractType] The evaluation contract type.
107
+ * Defaults to SCENARIO (most restrictive).
108
+ * @returns {boolean}
109
+ */
110
+ export function isGateRequired(gateKey, contractType = EVALUATION_CONTRACT_TYPES.SCENARIO) {
111
+ const required = REQUIRED_GATES_FOR_CONTRACT[contractType];
112
+ return required ? required.has(gateKey) : true;
113
+ }
48
114
  // ---------------------------------------------------------------------------
49
115
  // Evidence-Complete gate names — the canonical roster
50
116
  // ---------------------------------------------------------------------------
@@ -69,6 +135,7 @@ export const EVIDENCE_COMPLETE_GATES = Object.freeze([
69
135
 
70
136
  /**
71
137
  * @typedef {object} EvidenceCompleteContract
138
+ * @property {string} contractType The evaluation contract type (canonical | scenario).
72
139
  * @property {number} domainCoverage Ratio of evaluated required domains to required domains (0-1).
73
140
  * @property {number} claimEvidenceCoverage Ratio of claims with valid evidence to material claims (0-1).
74
141
  * @property {number} causalCoverage Ratio of consequences with complete causal chain to material consequences (0-1).
@@ -82,16 +149,16 @@ export const EVIDENCE_COMPLETE_GATES = Object.freeze([
82
149
  * @property {string} overallStatus Overall Evidence-Complete status: "complete" | "incomplete" | "not_evaluated".
83
150
  * @property {boolean} overallComplete True only when ALL required gates pass.
84
151
  * @property {object} gates Individual gate statuses, keyed by gate name.
85
- * @property {object} gates.domainCoverage Gate status.
86
- * @property {object} gates.claimEvidenceCoverage Gate status.
87
- * @property {object} gates.causalCoverage Gate status.
88
- * @property {object} gates.provenanceCoverage Gate status.
89
- * @property {object} gates.mutationCoverage Gate status.
90
- * @property {object} gates.surfaceParity Gate status.
91
- * @property {object} gates.hiddenGapCount Gate status.
92
- * @property {object} gates.falseCompleteCount Gate status.
93
- * @property {object} gates.baseIdentityValid Gate status.
94
- * @property {object} gates.deterministic Gate status.
152
+ * @property {object} gates.domainCoverage Gate status with {value, pass, required}.
153
+ * @property {object} gates.claimEvidenceCoverage Gate status with {value, pass, required}.
154
+ * @property {object} gates.causalCoverage Gate status with {value, pass, required}.
155
+ * @property {object} gates.provenanceCoverage Gate status with {value, pass, required}.
156
+ * @property {object} gates.mutationCoverage Gate status with {value, pass, required}.
157
+ * @property {object} gates.surfaceParity Gate status with {value, pass, required}.
158
+ * @property {object} gates.hiddenGapCount Gate status with {value, pass, required}.
159
+ * @property {object} gates.falseCompleteCount Gate status with {value, pass, required}.
160
+ * @property {object} gates.baseIdentityValid Gate status with {value, pass, required}.
161
+ * @property {object} gates.deterministic Gate status with {value, pass, required}.
95
162
  */
96
163
 
97
164
  // ---------------------------------------------------------------------------
@@ -306,8 +373,10 @@ export function buildCompleteness({
306
373
  const statuses = Object.values(domains).map((d) => d.status);
307
374
  const domainOverallComplete = statuses.every((s) => s === EVALUATION_STATUS.EVALUATED);
308
375
 
309
- // If an Evidence-Complete contract is provided, enforce it as a gate
310
- let ecComplete = true;
376
+ // If an Evidence-Complete contract is provided, enforce it as a gate.
377
+ // When no contract is provided, overallComplete MUST be false — the
378
+ // evaluation has not proven its evidence gates.
379
+ let ecComplete = false;
311
380
  let falseCompleteCount = 0;
312
381
  if (evidenceComplete) {
313
382
  ecComplete = evidenceComplete.overallComplete;
@@ -346,6 +415,10 @@ export function buildCompleteness({
346
415
  /**
347
416
  * Builds an Evidence-Complete contract from the individual gate values.
348
417
  *
418
+ * Only gates required for the given `contractType` are considered for
419
+ * `overallComplete`. Gates not required are still tracked and reported
420
+ * but do NOT block completeness.
421
+ *
349
422
  * @param {object} gates
350
423
  * @param {number} [gates.domainCoverage] Ratio (0-1).
351
424
  * @param {number} [gates.claimEvidenceCoverage] Ratio (0-1).
@@ -357,6 +430,8 @@ export function buildCompleteness({
357
430
  * @param {number} [gates.falseCompleteCount] Count (0 = pass).
358
431
  * @param {boolean} [gates.baseIdentityValid] Boolean (true = pass).
359
432
  * @param {boolean} [gates.deterministic] Boolean (true = pass).
433
+ * @param {string} [gates.contractType] Evaluation contract type for gate
434
+ * requirements (defaults to SCENARIO, the most restrictive).
360
435
  * @returns {EvidenceCompleteContract}
361
436
  */
362
437
  export function buildEvidenceComplete({
@@ -370,8 +445,9 @@ export function buildEvidenceComplete({
370
445
  falseCompleteCount = -1,
371
446
  baseIdentityValid = false,
372
447
  deterministic = false,
448
+ contractType = EVALUATION_CONTRACT_TYPES.SCENARIO,
373
449
  } = {}) {
374
- const gates = {
450
+ const rawGates = {
375
451
  domainCoverage: { value: domainCoverage, pass: domainCoverage === 1 },
376
452
  claimEvidenceCoverage: { value: claimEvidenceCoverage, pass: claimEvidenceCoverage === 1 },
377
453
  causalCoverage: { value: causalCoverage, pass: causalCoverage === 1 },
@@ -384,9 +460,21 @@ export function buildEvidenceComplete({
384
460
  deterministic: { value: deterministic, pass: deterministic === true },
385
461
  };
386
462
 
387
- const allPass = Object.values(gates).every((g) => g.pass);
463
+ // Only required gates block overallComplete
464
+ const allRequiredPass = Object.keys(rawGates)
465
+ .filter((key) => isGateRequired(key, contractType))
466
+ .every((key) => rawGates[key].pass);
467
+ // Annotate each gate with whether it is required for this contract type
468
+ /** @type {any} */
469
+ const gates = Object.fromEntries(
470
+ Object.entries(rawGates).map(([key, gate]) => [
471
+ key,
472
+ { ...gate, required: isGateRequired(key, contractType) },
473
+ ]),
474
+ );
388
475
 
389
476
  return {
477
+ contractType,
390
478
  domainCoverage,
391
479
  claimEvidenceCoverage,
392
480
  causalCoverage,
@@ -397,8 +485,8 @@ export function buildEvidenceComplete({
397
485
  falseCompleteCount,
398
486
  baseIdentityValid,
399
487
  deterministic,
400
- overallStatus: allPass ? "complete" : "incomplete",
401
- overallComplete: allPass,
488
+ overallStatus: allRequiredPass ? "complete" : "incomplete",
489
+ overallComplete: allRequiredPass,
402
490
  gates,
403
491
  };
404
492
  }
@@ -414,16 +502,22 @@ export function buildEvidenceComplete({
414
502
  export function assertEvidenceComplete(ec) {
415
503
  if (ec.overallComplete) return;
416
504
 
505
+ const contractType = ec.contractType || EVALUATION_CONTRACT_TYPES.SCENARIO;
417
506
  const failures = [];
418
507
  for (const gate of EVIDENCE_COMPLETE_GATES) {
419
508
  const g = ec.gates[gate.key];
509
+ // Skip non-required gates for this contract type
510
+ if (g.required === false) continue;
420
511
  if (!g.pass) {
421
512
  failures.push(`${gate.label}: ${JSON.stringify(g.value)} (expected pass)`);
422
513
  }
423
514
  }
424
515
 
516
+ if (failures.length === 0) return;
517
+
425
518
  throw new Error(
426
519
  `Evidence-Complete contract not satisfied.\n` +
520
+ ` Contract type: ${contractType}\n` +
427
521
  ` Overall: ${ec.overallStatus}\n` +
428
522
  ` Failed gates:\n ${failures.join("\n ")}`,
429
523
  );
@@ -571,15 +665,28 @@ export function buildScenarioCompleteness({
571
665
  evidenceComplete,
572
666
  });
573
667
 
574
- // Recompute overall with scenario domains included
668
+ // Recompute overall: base domains + EC gate + scenario domains.
669
+ // baseResult.overallComplete already includes the evidenceComplete gate,
670
+ // so reusing it prevents the silent-complete defect.
671
+ const overallComplete =
672
+ baseResult.overallComplete &&
673
+ changesDomain.status === EVALUATION_STATUS.EVALUATED &&
674
+ baseDomain.status === EVALUATION_STATUS.EVALUATED &&
675
+ mutationDomain.status === EVALUATION_STATUS.EVALUATED;
575
676
  const allStatuses = [
576
677
  ...Object.values(baseResult.domains).map((d) => d.status),
577
678
  changesDomain.status,
578
679
  baseDomain.status,
579
680
  mutationDomain.status,
580
681
  ];
581
- const overallComplete = allStatuses.every((s) => s === EVALUATION_STATUS.EVALUATED);
582
- const overallStatus = worstStatus(...allStatuses);
682
+ // Include EC gate status in overall status: when evidenceComplete is
683
+ // provided and fails, overall status must reflect that.
684
+ const ecStatus = evidenceComplete
685
+ ? evidenceComplete.overallComplete
686
+ ? EVALUATION_STATUS.EVALUATED
687
+ : EVALUATION_STATUS.NOT_EVALUATED
688
+ : EVALUATION_STATUS.NOT_EVALUATED;
689
+ const overallStatus = worstStatus(...allStatuses, ecStatus);
583
690
 
584
691
  return {
585
692
  domains: baseResult.domains,