@ecoma-io/archkeep 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/cli.mjs +156 -66
  2. package/package.json +1 -1
  3. package/src/analysis/contract.md +32 -5
  4. package/src/analysis/source-util.mjs +107 -0
  5. package/src/analysis/typescript.mjs +86 -5
  6. package/src/commands/change.mjs +59 -28
  7. package/src/commands/check.mjs +65 -26
  8. package/src/commands/completeness.mjs +126 -19
  9. package/src/commands/context-command.mjs +13 -5
  10. package/src/commands/context.mjs +31 -4
  11. package/src/commands/coverage-verdict.mjs +184 -0
  12. package/src/commands/debt.mjs +18 -15
  13. package/src/commands/delta-classify.mjs +13 -18
  14. package/src/commands/delta.mjs +95 -33
  15. package/src/commands/diff.mjs +31 -24
  16. package/src/commands/discover.mjs +30 -10
  17. package/src/commands/drift.mjs +21 -21
  18. package/src/commands/edge-constraints.mjs +47 -1
  19. package/src/commands/evaluation-primitives.mjs +194 -2
  20. package/src/commands/evolution.mjs +27 -10
  21. package/src/commands/explain.mjs +14 -13
  22. package/src/commands/fitness.mjs +20 -19
  23. package/src/commands/graph.mjs +14 -5
  24. package/src/commands/health.mjs +12 -5
  25. package/src/commands/history.mjs +29 -15
  26. package/src/commands/impact.mjs +17 -18
  27. package/src/commands/plan-context-command.mjs +10 -5
  28. package/src/commands/reconcile.mjs +14 -17
  29. package/src/commands/scenario-evaluation.mjs +93 -16
  30. package/src/commands/scenario.mjs +28 -18
  31. package/src/commands/waivers.mjs +36 -28
  32. package/src/governance/evolution-event.mjs +62 -9
  33. package/src/intent/intent-manifest.json +83 -39
  34. package/src/report/json.mjs +32 -5
  35. package/src/report/text.mjs +82 -12
  36. package/src/verdict.mjs +78 -36
  37. package/src/workspace.mjs +126 -2
@@ -29,7 +29,11 @@
29
29
  */
30
30
  import { createHash } from "node:crypto";
31
31
 
32
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
32
+ import {
33
+ blindSpotRows,
34
+ isWholeFileFailure,
35
+ unresolvableLiteralCount,
36
+ } from "../analysis/source-util.mjs";
33
37
  import { canonicalizeJson } from "../canonical.mjs";
34
38
  import { DEFAULT_WORKSPACE_LAYOUT } from "../rules/specifiers.mjs";
35
39
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
@@ -222,7 +226,14 @@ export function graphCommand(commandContext, { config = null } = {}) {
222
226
  .filter(isWholeFileFailure)
223
227
  .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
224
228
 
225
- const complete = notAnalyzed.length === 0;
229
+ // An unresolvable import site was seen but never judged (#595): the edges
230
+ // out of it may be missing from this snapshot, so the snapshot must not
231
+ // claim `complete` over it. It still reports — status no-verdict, exit 3 —
232
+ // naming the site in `coverage.blindSpots`, the same contract `check` runs.
233
+ const blindSpots = blindSpotRows(commandContext.analysis.failures);
234
+ const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
235
+
236
+ const complete = notAnalyzed.length === 0 && blindSpotCount === 0;
226
237
  const status = complete ? "ok" : "no-verdict";
227
238
  const exitCode = complete ? 0 : 3;
228
239
 
@@ -245,9 +256,7 @@ export function graphCommand(commandContext, { config = null } = {}) {
245
256
  analyzedFiles: commandContext.analysis.analyzed,
246
257
  imports: commandContext.analysis.imports.length,
247
258
  notAnalyzed,
248
- blindSpots: commandContext.analysis.failures
249
- .filter((f) => !isWholeFileFailure(f))
250
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
259
+ blindSpots,
251
260
  notes: [],
252
261
  };
253
262
 
@@ -43,7 +43,11 @@
43
43
  * It does not print, and it does not decide the process's exit code —
44
44
  * `../../cli.mjs` owns those (`./README.md`).
45
45
  */
46
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
46
+ import {
47
+ blindSpotRows,
48
+ isWholeFileFailure,
49
+ unresolvableLiteralCount,
50
+ } from "../analysis/source-util.mjs";
47
51
  import { buildDependencies, buildProjects } from "./graph.mjs";
48
52
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
49
53
  import { formatHealthReport } from "../report/health-text.mjs";
@@ -108,7 +112,12 @@ export function healthCommand(commandContext, io = {}) {
108
112
  const edges = buildDependencies(graph.dependencies);
109
113
 
110
114
  // The run's coverage facts, the same shape every command's envelope carries.
111
- const fileComplete = analysis.failures.filter(isWholeFileFailure).length === 0;
115
+ // An unresolvable site is a fact the run saw but never judged (#595)
116
+ // metrics measured over it would read precision the run does not have,
117
+ // so it defeats file completeness the way a whole-file failure does.
118
+ const fileComplete =
119
+ analysis.failures.filter(isWholeFileFailure).length === 0 &&
120
+ unresolvableLiteralCount(analysis.failures) === 0;
112
121
  // The graph is complete only when the files are AND the graph actually sees
113
122
  // every polyglot edge — an Nx workspace with an unregistered plugin carries
114
123
  // a graph with no Go/Rust/Python edges, which `graph`/`impact` refuse and
@@ -123,9 +132,7 @@ export function healthCommand(commandContext, io = {}) {
123
132
  notAnalyzed: analysis.failures
124
133
  .filter(isWholeFileFailure)
125
134
  .map(({ sourceFile, reason }) => ({ file: sourceFile, reason })),
126
- blindSpots: analysis.failures
127
- .filter((f) => !isWholeFileFailure(f))
128
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
135
+ blindSpots: blindSpotRows(analysis.failures),
129
136
  notes: graphComplete
130
137
  ? []
131
138
  : [
@@ -74,12 +74,16 @@ import { createHash } from "node:crypto";
74
74
  import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
75
75
  import { basename, join, resolve } from "node:path";
76
76
 
77
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
77
+ import {
78
+ blindSpotRows,
79
+ isWholeFileFailure,
80
+ unresolvableLiteralCount,
81
+ } from "../analysis/source-util.mjs";
78
82
  import { containmentViolation } from "../containment.mjs";
79
83
  import { classifyEvolution } from "../governance/evolution-event.mjs";
80
84
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
81
85
  import { formatHistoryReport } from "../report/history-text.mjs";
82
- import { computeDiff, edgeIdentityKey, parseBaseline } from "./diff.mjs";
86
+ import { computeDiff, parseBaseline } from "./diff.mjs";
83
87
  import { buildDependencies, buildProjects } from "./graph.mjs";
84
88
  import { resolveProvenance } from "./provenance.mjs";
85
89
  import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
@@ -397,8 +401,12 @@ export function classifyTransition(from, to) {
397
401
  changed: diff.changedProjects.map((project) => project.name),
398
402
  },
399
403
  edges: {
400
- added: diff.addedEdges.map(edgeIdentityKey),
401
- removed: diff.removedEdges.map(edgeIdentityKey),
404
+ // The raw triples — `classifyEvolution` maps them through its own
405
+ // identity spelling (`edgeEvolutionIdentity`), so `affected.boundaries`
406
+ // carries the canonical strings every other event surface uses, not
407
+ // this module's diff-internal key spelling.
408
+ added: diff.addedEdges,
409
+ removed: diff.removedEdges,
402
410
  },
403
411
  policyChanged: meta.policyChanged,
404
412
  policyOneSided: meta.policyOneSided,
@@ -554,10 +562,23 @@ export function historyCommand(
554
562
  const notAnalyzed = commandContext.analysis.failures
555
563
  .filter(isWholeFileFailure)
556
564
  .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
557
- if (notAnalyzed.length > 0) {
565
+
566
+ const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
567
+ if (notAnalyzed.length > 0 || blindSpotCount > 0) {
558
568
  throw new Error(
559
- `archkeep: the head graph has incomplete coverage — ${notAnalyzed.length} file` +
560
- `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so a captured snapshot ` +
569
+ `archkeep: the head graph has incomplete coverage — ` +
570
+ [
571
+ notAnalyzed.length > 0
572
+ ? `${notAnalyzed.length} file${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed`
573
+ : null,
574
+ blindSpotCount > 0
575
+ ? `${blindSpotCount} import site${blindSpotCount === 1 ? "" : "s"} could not be resolved`
576
+ : null,
577
+ ]
578
+ .filter(Boolean)
579
+ .join(", ") +
580
+ `, so
581
+ a captured snapshot ` +
561
582
  `would under-represent the real architecture. Fix the unanalyzed files and re-run.`,
562
583
  );
563
584
  }
@@ -608,14 +629,7 @@ export function historyCommand(
608
629
  analyzedFiles: commandContext.analysis.analyzed,
609
630
  imports: commandContext.analysis.imports.length,
610
631
  notAnalyzed: [],
611
- blindSpots: commandContext.analysis.failures
612
- .filter((f) => !isWholeFileFailure(f))
613
- .map(({ sourceFile, line, column, reason }) => ({
614
- file: sourceFile,
615
- line,
616
- column,
617
- reason,
618
- })),
632
+ blindSpots: blindSpotRows(commandContext.analysis.failures),
619
633
  notes: [],
620
634
  },
621
635
  result: { ...head, policy: headPolicy ?? undefined },
@@ -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) {