@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.
- package/cli.mjs +156 -66
- package/package.json +1 -1
- package/src/analysis/contract.md +32 -5
- package/src/analysis/source-util.mjs +107 -0
- package/src/analysis/typescript.mjs +86 -5
- package/src/commands/change.mjs +59 -28
- package/src/commands/check.mjs +65 -26
- package/src/commands/completeness.mjs +126 -19
- package/src/commands/context-command.mjs +13 -5
- package/src/commands/context.mjs +31 -4
- package/src/commands/coverage-verdict.mjs +184 -0
- package/src/commands/debt.mjs +18 -15
- package/src/commands/delta-classify.mjs +13 -18
- package/src/commands/delta.mjs +95 -33
- package/src/commands/diff.mjs +31 -24
- package/src/commands/discover.mjs +30 -10
- package/src/commands/drift.mjs +21 -21
- package/src/commands/edge-constraints.mjs +47 -1
- package/src/commands/evaluation-primitives.mjs +194 -2
- package/src/commands/evolution.mjs +27 -10
- package/src/commands/explain.mjs +14 -13
- package/src/commands/fitness.mjs +20 -19
- package/src/commands/graph.mjs +14 -5
- package/src/commands/health.mjs +12 -5
- package/src/commands/history.mjs +29 -15
- package/src/commands/impact.mjs +17 -18
- package/src/commands/plan-context-command.mjs +10 -5
- package/src/commands/reconcile.mjs +14 -17
- package/src/commands/scenario-evaluation.mjs +93 -16
- package/src/commands/scenario.mjs +28 -18
- package/src/commands/waivers.mjs +36 -28
- package/src/governance/evolution-event.mjs +62 -9
- package/src/intent/intent-manifest.json +83 -39
- package/src/report/json.mjs +32 -5
- package/src/report/text.mjs +82 -12
- package/src/verdict.mjs +78 -36
- package/src/workspace.mjs +126 -2
package/src/commands/graph.mjs
CHANGED
|
@@ -29,7 +29,11 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { createHash } from "node:crypto";
|
|
31
31
|
|
|
32
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
|
package/src/commands/health.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
: [
|
package/src/commands/history.mjs
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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 —
|
|
560
|
-
|
|
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 },
|
package/src/commands/impact.mjs
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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 =
|
|
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; //
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
|
88
|
+
* @returns {Promise<{status: "ok"|"no-verdict", reconcile?: object, coverage: object,
|
|
88
89
|
* report: {text: string, json: string}}>}
|
|
89
|
-
*
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 {
|
|
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.
|
|
109
|
-
* @property {boolean} governanceImpact.
|
|
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
|
|
591
|
-
const
|
|
599
|
+
const findingsFiltered = availableFindings !== null;
|
|
600
|
+
const debtFiltered = availableDebt !== null;
|
|
592
601
|
|
|
593
|
-
//
|
|
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,
|
|
596
|
-
notEvaluated:
|
|
606
|
+
evaluated: false,
|
|
607
|
+
notEvaluated: true,
|
|
597
608
|
});
|
|
598
609
|
const debtStatus = evaluationStatus({
|
|
599
|
-
evaluated: false,
|
|
600
|
-
notEvaluated:
|
|
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
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
governanceComplete: false,
|
|
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:
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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:
|
|
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) {
|