@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
@@ -35,9 +35,9 @@
35
35
  * under project roots, `impact` refuses loudly rather than returning a result
36
36
  * whose dependents silently under-represent the real architecture.
37
37
  */
38
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
39
38
  import { UsageError } from "../errors.mjs";
40
39
  import { computeImpactConstraints } from "./edge-constraints.mjs";
40
+ import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
41
41
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
42
42
  import { formatImpactReport } from "../report/impact-text.mjs";
43
43
  import { resolveProvenance } from "./provenance.mjs";
@@ -123,11 +123,14 @@ export function computeImpact(projectName, graph) {
123
123
  * @param {object} commandContext From `resolveCommandContext`.
124
124
  * @param {object} [config] The loaded boundary config. When provided,
125
125
  * constraint context and violations for each dependent edge are computed.
126
- * @returns {{status: "ok"|"no-verdict", impact: object, coverage: object,
126
+ * @returns {{status: "ok"|"no-verdict", impact?: object, coverage: object,
127
127
  * report: {text: string, json: string}}}
128
+ * `status: "no-verdict"` carries no `impact` payload — the verdict was
129
+ * withheld, and the envelope's `coverage` block is the whole answer (#608).
128
130
  * @throws {Error} when an Nx workspace has polyglot manifests but the plugin
129
- * is not registered, or when the named project does not exist in the graph,
130
- * or when the graph has incomplete coverage.
131
+ * is not registered, or when the named project does not exist in the graph.
132
+ * Incomplete coverage returns the structured no-verdict envelope instead of
133
+ * throwing (#608).
131
134
  */
132
135
  export function impactCommand(projectName, commandContext, config = null) {
133
136
  const { root, provider, marker, graph, pluginGap } = commandContext;
@@ -150,22 +153,18 @@ export function impactCommand(projectName, commandContext, config = null) {
150
153
  // project before the run invests in anything else.
151
154
  const impact = computeImpact(projectName, graph);
152
155
 
153
- const notAnalyzed = commandContext.analysis.failures
154
- .filter(isWholeFileFailure)
155
- .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
156
-
157
- if (notAnalyzed.length > 0) {
158
- throw new Error(
159
- `archkeep: the graph has incomplete coverage — ${notAnalyzed.length} file` +
160
- `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so the impact set may ` +
161
- `under-represent the real architecture. Fix the unanalyzed files and re-run.`,
162
- );
156
+ // The impact set is a claim about the tree the run read, refused through the
157
+ // one structured contract `./coverage-verdict.mjs` builds (#608): the
158
+ // verdict is withheld in-band status "no-verdict", exit 3, a `coverage`
159
+ // block naming every file and site the run could not judge — where a parser
160
+ // and `--output` can read it, not on stderr where only a human can.
161
+ const completeness = coverageVerdict(commandContext);
162
+ if (!completeness.complete) {
163
+ return coverageRefusal({ command: "impact", commandContext, what: "computing impact" });
163
164
  }
164
- const blindSpots = commandContext.analysis.failures
165
- .filter((f) => !isWholeFileFailure(f))
166
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason }));
165
+ const blindSpots = completeness.blindSpots;
167
166
 
168
- const complete = true; // whole-file failures already threw above
167
+ const complete = true; // the incompleteness cases all returned above
169
168
  const status = "ok";
170
169
  const exitCode = 0;
171
170
 
@@ -64,7 +64,11 @@ import { statSync } from "node:fs";
64
64
  import { join } from "node:path";
65
65
 
66
66
  import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
67
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
67
+ import {
68
+ blindSpotRows,
69
+ isWholeFileFailure,
70
+ unresolvableLiteralCount,
71
+ } from "../analysis/source-util.mjs";
68
72
  import { tsconfigPathsFacts } from "../analysis/typescript.mjs";
69
73
  import { compareGoWork, parseGoWorkUse } from "../go-work.mjs";
70
74
  import { judgeTsconfigPaths } from "../tsconfig-paths.mjs";
@@ -497,7 +501,10 @@ export async function planContextCommand(
497
501
  (a.messageId < b.messageId ? -1 : a.messageId > b.messageId ? 1 : 0),
498
502
  );
499
503
 
500
- const complete = notAnalyzed.length === 0;
504
+ // An unresolvable literal site is work the run saw but never judged
505
+ // (#595, narrowed): a plan over it would present edges the run does not
506
+ // hold, so it defeats completeness the way a whole-file failure does.
507
+ const complete = notAnalyzed.length === 0 && unresolvableLiteralCount(failures) === 0;
501
508
  const status = complete ? "ok" : "no-verdict";
502
509
  const exitCode = complete ? 0 : 3;
503
510
 
@@ -525,9 +532,7 @@ export async function planContextCommand(
525
532
  analyzedFiles: wholeTree.analyzed,
526
533
  imports: wholeTree.imports.length,
527
534
  notAnalyzed,
528
- blindSpots: failures
529
- .filter((failure) => !isWholeFileFailure(failure))
530
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
535
+ blindSpots: blindSpotRows(failures),
531
536
  // Intent's own coverage notes ride the same seam `check` threads them on
532
537
  // (today only an `"optional": true` allowed row whose statement is absent),
533
538
  // so the plan's text and JSON reports read the same notes `check` does.
@@ -43,9 +43,10 @@
43
43
  * comparison (never `localeCompare`), so two runs over an unchanged tree and
44
44
  * intent produce byte-identical text and JSON.
45
45
  */
46
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
46
+ import { blindSpotRows } from "../analysis/source-util.mjs";
47
47
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
48
48
  import { resolveProvenance } from "./provenance.mjs";
49
+ import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
49
50
  import { judgeIntent } from "../architecture-intent/judge.mjs";
50
51
  import { computeIntentFingerprint } from "../architecture-intent/intent-fingerprint.mjs";
51
52
  import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
@@ -84,9 +85,12 @@ function intentRows(intent) {
84
85
  * @param {{loadIntentOverride?: (root: string) => Promise<object>}} [io]
85
86
  * Injectable intent loader for tests.
86
87
  * @param {{propose?: boolean}} [options] `--propose` adds the ranked candidate list.
87
- * @returns {Promise<{status: "ok", reconcile: object, coverage: object,
88
+ * @returns {Promise<{status: "ok"|"no-verdict", reconcile?: object, coverage: object,
88
89
  * report: {text: string, json: string}}>}
89
- * @throws {Error} on every condition the header lists, all exit-3 class.
90
+ * `status: "no-verdict"` carries no `reconcile` payload the verdict was
91
+ * withheld, and the envelope's `coverage` block is the whole answer (#608).
92
+ * @throws {Error} on every condition the header lists except the coverage one,
93
+ * which returns instead of throwing.
90
94
  */
91
95
  export async function reconcileCommand(commandContext, io = {}, options = {}) {
92
96
  const { root, provider, marker, analysis } = commandContext;
@@ -94,17 +98,12 @@ export async function reconcileCommand(commandContext, io = {}, options = {}) {
94
98
  refuseIncompleteGraph(commandContext);
95
99
 
96
100
  // A reconcile verdict cannot be established over a tree it could not fully
97
- // read — the same fail-closed condition `drift` enforces.
98
- const notAnalyzed = analysis.failures
99
- .filter(isWholeFileFailure)
100
- .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
101
-
102
- if (notAnalyzed.length > 0) {
103
- throw new Error(
104
- `archkeep: reconcile has incomplete coverage — ${notAnalyzed.length} file` +
105
- `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so every "absent" score ` +
106
- `would be ambiguous between "gone" and "never seen". Fix the unanalyzed files and re-run.`,
107
- );
101
+ // read — the same fail-closed condition `drift` enforces, refused through
102
+ // the same structured envelope `./coverage-verdict.mjs` builds (#608): the
103
+ // verdict is withheld in-band, where a parser and `--output` can read it.
104
+ const completeness = coverageVerdict(commandContext);
105
+ if (!completeness.complete) {
106
+ return coverageRefusal({ command: "reconcile", commandContext, what: "reconciling" });
108
107
  }
109
108
 
110
109
  const intent = await (io.loadIntentOverride ?? loadIntent)(root, {
@@ -146,9 +145,7 @@ export async function reconcileCommand(commandContext, io = {}, options = {}) {
146
145
  // Reconcile reads only the graph — provider failures are the same blind
147
146
  // spots every other command reports, and a blind spot never prevents a
148
147
  // verdict.
149
- blindSpots: analysis.failures
150
- .filter((failure) => !isWholeFileFailure(failure))
151
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
148
+ blindSpots: blindSpotRows(analysis.failures),
152
149
  // Coverage notes (e.g. an `optional: true` allowed row the team has not
153
150
  // built yet) ride here so "optional and absent" never reads as "never
154
151
  // checked".
@@ -33,12 +33,21 @@
33
33
  */
34
34
  import { computeImpact } from "./impact.mjs";
35
35
  import { computeImpactConstraints } from "./edge-constraints.mjs";
36
- import { buildDecisionImpact, buildEvolutionAlignment } from "./evaluation-primitives.mjs";
36
+ import {
37
+ buildDecisionImpact,
38
+ buildEvolutionAlignment,
39
+ decisionProvenanceCoverage,
40
+ } from "./evaluation-primitives.mjs";
37
41
  import { resolveProvenance } from "./provenance.mjs";
38
42
  import {
39
- buildScenarioCompleteness,
40
43
  buildGovernanceCompleteness,
44
+ buildScenarioCompleteness,
45
+ buildEvidenceComplete,
46
+ createDomain,
47
+ EVALUATED,
48
+ NOT_EVALUATED,
41
49
  evaluationStatus,
50
+ EVALUATION_CONTRACT_TYPES,
42
51
  } from "./completeness.mjs";
43
52
 
44
53
  // ---------------------------------------------------------------------------
@@ -105,8 +114,8 @@ export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "depende
105
114
  * @property {string} evidenceChain.scenarioState The state after applying changes ("scenario").
106
115
  * @property {object} evidenceChain.delta The computed differences.
107
116
  * @property {object} [governanceImpact] Governance re-evaluation results.
108
- * @property {boolean} governanceImpact.findingsReEvaluated Whether findings were re-evaluated.
109
- * @property {boolean} governanceImpact.debtReEvaluated Whether debt was re-evaluated.
117
+ * @property {boolean} governanceImpact.findingsFiltered Whether precomputed findings were filtered into the scenario state.
118
+ * @property {boolean} governanceImpact.debtFiltered Whether precomputed debt was filtered into the scenario state.
110
119
  * @property {boolean} governanceImpact.governanceComplete Whether all governance data was provided.
111
120
  * @property {number} governanceImpact.scenarioFindingsCount Number of findings in the scenario state.
112
121
  * @property {number} governanceImpact.scenarioDebtCount Number of debt entries in the scenario state.
@@ -587,17 +596,19 @@ export function evaluateScenario(
587
596
  // hypothetical graph) is NOT governance re-evaluation. True re-evaluation
588
597
  // would run the full check pipeline against the hypothetical graph.
589
598
  // When we only filter, governance is NOT_EVALUATED.
590
- const findingsReEvaluated = availableFindings !== null;
591
- const debtReEvaluated = availableDebt !== null;
599
+ const findingsFiltered = availableFindings !== null;
600
+ const debtFiltered = availableDebt !== null;
592
601
 
593
- // Mark governance as NOT_EVALUATED when not truly re-evaluated
602
+ // Filtering is NOT re-evaluation (the header above), so the status is
603
+ // NOT_EVALUATED on both paths — no re-evaluation pipeline exists to pass.
604
+ // Telling a consumer "evaluated" for a filter is the mislabel this refuses.
594
605
  const findingsStatus = evaluationStatus({
595
- evaluated: false, // filtering is NOT evaluation
596
- notEvaluated: !findingsReEvaluated,
606
+ evaluated: false,
607
+ notEvaluated: true,
597
608
  });
598
609
  const debtStatus = evaluationStatus({
599
- evaluated: false, // filtering is NOT evaluation
600
- notEvaluated: !debtReEvaluated,
610
+ evaluated: false,
611
+ notEvaluated: true,
601
612
  });
602
613
 
603
614
  // Build governance completeness
@@ -614,12 +625,78 @@ export function evaluateScenario(
614
625
  const refusedCount = refused.length;
615
626
  const mutationCoverageComplete = totalChanges === appliedCount && refusedCount === 0;
616
627
 
617
- // Build overall scenario completeness
628
+ // Build the scenario's domain statuses FIRST — the Evidence-Complete
629
+ // contract below derives its hidden-gap gate from them, so a domain that
630
+ // is NOT_EVALUATED without a note flips that gate, and a domain that
631
+ // skips with a stated reason does not.
632
+ // structural — always evaluated (scenario builds a complete graph).
633
+ // constraint, boundary, decision — require a boundary config; decision
634
+ // follows the config exactly as the canonical face reads it, because
635
+ // the scenario runs decision impact (buildDecisionImpact above) when
636
+ // one is present. The condition this replaces, `config.decisionRefs`,
637
+ // named a field no workspace can declare — it held the decision domain
638
+ // at NOT_EVALUATED forever, a permanent hidden gap on every configured
639
+ // workspace.
640
+ // findings, debt — never re-evaluated in a scenario; the notes say so,
641
+ // which is what keeps them disclosed and out of the hidden-gap count.
642
+ // evidence — always evaluated (we build the EC contract).
643
+ const hasConfig = config !== null;
644
+ const configGapNote =
645
+ "No boundary config — constraint, boundary and decision rules not evaluated";
646
+ const configGated = () =>
647
+ hasConfig ? createDomain(EVALUATED) : createDomain(NOT_EVALUATED, configGapNote);
648
+ const scenarioDomains = {
649
+ structural: createDomain(EVALUATED),
650
+ constraint: configGated(),
651
+ boundary: configGated(),
652
+ decision: configGated(),
653
+ findings:
654
+ governanceCompleteness.findings.status === NOT_EVALUATED
655
+ ? createDomain(NOT_EVALUATED, "Findings not re-evaluated in scenario")
656
+ : governanceCompleteness.findings,
657
+ debt:
658
+ governanceCompleteness.debt.status === NOT_EVALUATED
659
+ ? createDomain(NOT_EVALUATED, "Debt not re-evaluated in scenario")
660
+ : governanceCompleteness.debt,
661
+ evidence: createDomain(EVALUATED),
662
+ };
663
+
664
+ // Hidden gaps: NOT_EVALUATED domains without a stated reason — the same
665
+ // derivation `deriveEvidenceGates` runs for the canonical face, so neither
666
+ // face can pass the gate on a literal while the other fails on facts.
667
+ let scenarioHiddenGapCount = 0;
668
+ for (const domainStatus of Object.values(scenarioDomains)) {
669
+ if (domainStatus.status === NOT_EVALUATED && !domainStatus.note) {
670
+ scenarioHiddenGapCount++;
671
+ }
672
+ }
673
+
674
+ // Derive evidence gates for scenario evaluation and build Evidence-Complete contract.
675
+ // Scenario mutation is deterministic: same inputs → same outputs (pure graph clone + apply).
676
+ // surfaceParity: the scenario applied all requested changes (refused===0), so the
677
+ // hypothetical surface is internally consistent — no surface drift from the plan.
678
+ const surfaceParity = refusedCount === 0 ? 1 : 0;
679
+
680
+ const evidenceComplete = buildEvidenceComplete({
681
+ domainCoverage: currentDecisionImpact !== null ? 1 : 0,
682
+ claimEvidenceCoverage: config !== null ? 1 : 0,
683
+ causalCoverage: currentConstraintImpact !== null ? 1 : 0,
684
+ provenanceCoverage: decisionProvenanceCoverage(currentDecisionImpact?.decisions),
685
+ mutationCoverage: mutationCoverageComplete ? 1 : 0,
686
+ surfaceParity,
687
+ hiddenGapCount: scenarioHiddenGapCount,
688
+ falseCompleteCount: 0,
689
+ baseIdentityValid: base.identityVerified,
690
+ deterministic: true,
691
+ contractType: EVALUATION_CONTRACT_TYPES.SCENARIO,
692
+ });
618
693
  const scenarioCompleteness = buildScenarioCompleteness({
619
694
  changesComplete: mutationCoverageComplete,
620
695
  baseIdentityVerified: base.identityVerified,
621
696
  mutationCoverageComplete,
622
697
  governance: governanceCompleteness,
698
+ evidenceComplete,
699
+ domains: scenarioDomains,
623
700
  });
624
701
 
625
702
  return {
@@ -655,9 +732,9 @@ export function evaluateScenario(
655
732
  ...(scenarioDebt !== null ? { debt: scenarioDebt } : {}),
656
733
  },
657
734
  governanceImpact: {
658
- findingsReEvaluated,
659
- debtReEvaluated,
660
- governanceComplete: false, // filtering is NOT evaluation
735
+ findingsFiltered,
736
+ debtFiltered,
737
+ governanceComplete: false,
661
738
  scenarioFindingsCount: scenarioFindings?.length ?? 0,
662
739
  scenarioDebtCount: scenarioDebt?.length ?? 0,
663
740
  findingsStatus,
@@ -665,7 +742,7 @@ export function evaluateScenario(
665
742
  },
666
743
  delta,
667
744
  completeness: scenarioCompleteness,
668
- complete: mutationCoverageComplete,
745
+ complete: scenarioCompleteness.overallComplete,
669
746
  notes,
670
747
  };
671
748
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { resolveProvenance } from "./provenance.mjs";
13
13
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
14
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
14
+ import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
15
15
  import { evaluateScenario, parseScenarioInput } from "./scenario-evaluation.mjs";
16
16
  export { parseScenarioInput } from "./scenario-evaluation.mjs";
17
17
 
@@ -23,7 +23,10 @@ export { parseScenarioInput } from "./scenario-evaluation.mjs";
23
23
  * @param {string} scenarioJson The scenario description as JSON.
24
24
  * @param {object} commandContext From `resolveCommandContext`.
25
25
  * @param {object} [config] The loaded boundary config.
26
- * @returns {{status: string, scenario: object, coverage: object, report: {text: string, json: string}}}
26
+ * @returns {{status: "ok"|"no-verdict", scenario?: object, coverage: object,
27
+ * report: {text: string, json: string}}} `scenario` is absent under
28
+ * `status: "no-verdict"` — the coverage refusal (#608) withholds the
29
+ * evaluation, and the envelope's `coverage` block is the whole answer.
27
30
  */
28
31
  export function scenarioCommand(projectName, scenarioJson, commandContext, config = null) {
29
32
  const { root, provider, marker, graph, pluginGap } = commandContext;
@@ -44,17 +47,12 @@ export function scenarioCommand(projectName, scenarioJson, commandContext, confi
44
47
  // Parse the scenario input
45
48
  const scenarioInput = parseScenarioInput(scenarioJson);
46
49
 
47
- // Check coverage
48
- const notAnalyzed = commandContext.analysis.failures
49
- .filter(isWholeFileFailure)
50
- .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
51
-
52
- if (notAnalyzed.length > 0) {
53
- throw new Error(
54
- `archkeep: the graph has incomplete coverage — ${notAnalyzed.length} file` +
55
- `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so the scenario may ` +
56
- `under-represent the real architecture. Fix the unanalyzed files and re-run.`,
57
- );
50
+ // Check coverage — refused through the one structured contract
51
+ // `./coverage-verdict.mjs` builds (#608): the evaluation is withheld
52
+ // in-band, where a parser and `--output` can read it.
53
+ const completeness = coverageVerdict(commandContext);
54
+ if (!completeness.complete) {
55
+ return coverageRefusal({ command: "scenario", commandContext, what: "evaluating a scenario" });
58
56
  }
59
57
 
60
58
  // Evaluate
@@ -66,9 +64,7 @@ export function scenarioCommand(projectName, scenarioJson, commandContext, confi
66
64
  analyzedFiles: commandContext.analysis.analyzed,
67
65
  imports: commandContext.analysis.imports.length,
68
66
  notAnalyzed: [],
69
- blindSpots: commandContext.analysis.failures
70
- .filter((f) => !isWholeFileFailure(f))
71
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
67
+ blindSpots: completeness.blindSpots,
72
68
  notes: [
73
69
  "scenario evaluation is virtual and not authoritative — run `check` for the real verdict",
74
70
  "per-edge verdicts cover only depConstraints (3 of 15 violation types)",
@@ -103,7 +99,7 @@ export function scenarioCommand(projectName, scenarioJson, commandContext, confi
103
99
  result,
104
100
  });
105
101
 
106
- const text = formatScenarioReport(scenario);
102
+ const text = formatScenarioReport(scenario, coverage);
107
103
 
108
104
  return {
109
105
  status: "ok",
@@ -119,15 +115,29 @@ export function scenarioCommand(projectName, scenarioJson, commandContext, confi
119
115
  /**
120
116
  * Formats a scenario evaluation as terminal text.
121
117
  *
118
+ * The coverage line rides the same `coverageIncompleteReasons` clauses every
119
+ * other text face uses (`../verdict.mjs`), so a terminal reader is told how
120
+ * much of the tree the comparison inspected, in the one wording the JSON
121
+ * envelope's status also speaks (#609).
122
+ *
122
123
  * @param {object} scenario The scenario evaluation result.
124
+ * @param {object} coverage The run's coverage block.
123
125
  * @returns {string}
124
126
  */
125
- function formatScenarioReport(scenario) {
127
+ function formatScenarioReport(scenario, coverage) {
126
128
  const lines = [];
127
129
 
128
130
  lines.push(`Scenario evaluation for "${scenario.project}"`);
129
131
  lines.push(`${"=".repeat(50)}`);
130
132
  lines.push(`Virtual: ${scenario.virtual} | Not authoritative: ${scenario.notAuthoritative}`);
133
+ // The "how much was inspected" line, derived from the same coverage block
134
+ // the envelope carries — never re-counted here.
135
+ lines.push(
136
+ `Coverage: ${coverage.imports} import${coverage.imports === 1 ? "" : "s"} in ` +
137
+ `${coverage.analyzedFiles} file${coverage.analyzedFiles === 1 ? "" : "s"} across ` +
138
+ `${coverage.projects} project${coverage.projects === 1 ? "" : "s"}` +
139
+ (coverage.complete ? "" : " — coverage incomplete"),
140
+ );
131
141
  lines.push("");
132
142
 
133
143
  if (scenario.changes.length > 0) {
@@ -37,10 +37,11 @@
37
37
  * `now` and the output is reproducible byte-for-byte. Defaults to the wall
38
38
  * clock, the same injection `evaluate` uses for waiver expiry.
39
39
  */
40
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
40
+ import { blindSpotRows } from "../analysis/source-util.mjs";
41
41
  import { suppressionCovers } from "../config.mjs";
42
42
  import { referenceTime } from "../governance/clock.mjs";
43
43
  import { isWaiver, remainingMs, waiverStatus } from "../governance/waiver.mjs";
44
+ import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
44
45
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
45
46
  import { formatWaiversReport } from "../report/waivers-text.mjs";
46
47
  import { partitionUnownedCoverage } from "./coverage-acceptance.mjs";
@@ -150,10 +151,13 @@ export function computeWaivers(suppressions, rawViolations, now = referenceTime(
150
151
  * clock, and — from `cli.mjs`'s `runWaivers` — the workspace-relative path
151
152
  * the run's law actually resolved from, so the `coverage.unowned` matching
152
153
  * below subtracts the same configuration files `check` subtracts.
153
- * @returns {Promise<{status: "ok", waivers: object, report: {text: string, json: string}}>}
154
- * @throws {Error} whenever the run's law is malformed, or the tree has
155
- * whole-file analysis failures exit-3 class, the same posture `check` takes
156
- * on a malformed config and `impact`/`drift` take on incomplete coverage.
154
+ * @returns {Promise<{status: "ok"|"no-verdict", waivers?: object, coverage: object,
155
+ * report: {text: string, json: string}}>}
156
+ * `status: "no-verdict"` carries no `waivers` payload the verdict was
157
+ * withheld, and the envelope's `coverage` block is the whole answer (#608).
158
+ * @throws {Error} whenever the run's law is malformed — exit-3 class, the same
159
+ * posture `check` takes on a malformed config. Incomplete coverage returns
160
+ * the structured no-verdict envelope instead of throwing (#608).
157
161
  */
158
162
  export async function waiversCommand(commandContext, boundaryConfig, io = {}) {
159
163
  const { root, provider, marker, analysis, graph } = commandContext;
@@ -180,25 +184,23 @@ export async function waiversCommand(commandContext, boundaryConfig, io = {}) {
180
184
  // A waiver surface over a tree it could not fully read is a lottery ticket,
181
185
  // not a surface: a file the analyzer never judged contributes no raw
182
186
  // violation, so every waiver that names it reads as stale and the report
183
- // says "covers nothing" about a finding the run never looked at. Refuse
184
- // loudly on whole-file failures, the same posture `impact`, `drift`, and
185
- // `history` take — "could not look" must never read as "looked and found
186
- // nothing" (`./impact.mjs`'s refusal names the same silence). A whole-file
187
- // failure whose file a `coverage.unowned` row accepts is withdrawn first,
188
- // exactly as `check` withdraws it (`./check.mjs`'s `acceptedUnclaimed`):
189
- // its state is a recorded acceptance this very report is about to name,
190
- // not a hole the run failed to look at.
191
- const notAnalyzed = analysis.failures
192
- .filter(isWholeFileFailure)
193
- .filter(({ sourceFile }) => !unownedCoverage.acceptedFiles.has(sourceFile))
194
- .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
195
-
196
- if (notAnalyzed.length > 0) {
197
- throw new Error(
198
- `archkeep: waivers has incomplete coverage — ${notAnalyzed.length} file` +
199
- `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so every waiver naming one ` +
200
- `would read as covering nothing it never saw. Fix the unanalyzed files and re-run.`,
201
- );
187
+ // says "covers nothing" about a finding the run never looked at. Refused
188
+ // through the one structured contract `./coverage-verdict.mjs` builds
189
+ // (#608) — "could not look" must never read as "looked and found nothing".
190
+ // A whole-file failure whose file a `coverage.unowned` row accepts is
191
+ // withdrawn first, exactly as `check` withdraws it (`./check.mjs`'s
192
+ // `acceptedUnclaimed`): its state is a recorded acceptance this very report
193
+ // is about to name, not a hole the run failed to look at.
194
+ const completeness = coverageVerdict(commandContext, {
195
+ acceptedFiles: unownedCoverage.acceptedFiles,
196
+ });
197
+ if (!completeness.complete) {
198
+ return coverageRefusal({
199
+ command: "waivers",
200
+ commandContext,
201
+ what: "measuring the waiver surface",
202
+ acceptedFiles: unownedCoverage.acceptedFiles,
203
+ });
202
204
  }
203
205
 
204
206
  // F07: a waiver surface measured against a graph that cannot see the
@@ -226,10 +228,13 @@ export async function waiversCommand(commandContext, boundaryConfig, io = {}) {
226
228
  projects: Object.keys(graph.nodes).length,
227
229
  analyzedFiles: analysis.analyzed,
228
230
  imports: analysis.imports.length,
229
- notAnalyzed,
230
- blindSpots: analysis.failures
231
- .filter((failure) => !isWholeFileFailure(failure))
232
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
231
+ // The withdrawn list — whole-file failures a `coverage.unowned` row
232
+ // accepts are already named in `result.unownedAcceptances` below, so
233
+ // repeating them here would double-count the same acceptance. On this
234
+ // path the list is empty by construction: anything unwithdrawn refused
235
+ // above.
236
+ notAnalyzed: completeness.notAnalyzed,
237
+ blindSpots: blindSpotRows(analysis.failures),
233
238
  // `remainingMs` reflects the wall clock at the moment of THIS run, not the
234
239
  // workspace — it is expected to differ between two runs of an unchanged
235
240
  // tree, by design (`../governance/clock.mjs`). Disclosed here, in-band,
@@ -282,6 +287,9 @@ export async function waiversCommand(commandContext, boundaryConfig, io = {}) {
282
287
  return {
283
288
  status: "ok",
284
289
  waivers: result,
290
+ // The same `coverage` block the envelope carries, so both return shapes —
291
+ // this one and the no-verdict refusal's (#608) — expose it under one key.
292
+ coverage,
285
293
  report: {
286
294
  text: formatWaiversReport(result),
287
295
  json: renderJson(envelope),