@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
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The ONE incomplete-coverage refusal contract, and the one completeness
3
+ * every command's verdict is a claim about (#608).
4
+ *
5
+ * #602 gave the graph family (`check`, `graph`, `discover`, `explain`,
6
+ * `context`) a structured refusal: `status: "no-verdict"`, exit 3, a
7
+ * `coverage` block naming what the run could not read — in the envelope,
8
+ * where a parser and `--output` can read it. Every command here that refuses
9
+ * the same condition refused it by throwing: a stderr sentence, an empty
10
+ * stdout, no envelope, nothing under `--output`. Same refusal class, two
11
+ * machine contracts — while `docs/reference/exit-codes.md` already documents
12
+ * the structured one for every descriptive command. The functions here are
13
+ * the shared construction the throw family now goes through, so the two
14
+ * families cannot drift again:
15
+ *
16
+ * - `coverageVerdict` computes the one completeness the envelope law
17
+ * (`../report/json.mjs`) already enforces — no whole-file failure, no
18
+ * unjudged blind spot, at least one file analyzed — and the status/exit
19
+ * pair that completeness implies. The failure classes are read through
20
+ * `../analysis/source-util.mjs`'s classifiers, never an inline filter: the
21
+ * class line is theirs alone to own.
22
+ * - `coverageRefusal` builds the refusal result itself: the envelope through
23
+ * `jsonEnvelope` (so the envelope invariants hold by construction, and a
24
+ * refusal cannot accidentally claim `complete: true`), and a text face
25
+ * whose coverage lines come from `coverageIncompleteReasons` — the same
26
+ * clauses `check`'s text report renders — so a terminal reader is told the
27
+ * same thing the envelope tells a parser.
28
+ *
29
+ * What still throws: the plugin-gap refusal (a graph that under-represents
30
+ * the workspace is a different refusal, kept loud in every command that has
31
+ * one — `drift.mjs`'s `refuseIncompleteGraph` and `delta.mjs`'s
32
+ * `refusePluginGapHead` are the two shared guards), input refusals (a
33
+ * malformed baseline file, a missing intent), and the capture modes
34
+ * (`delta --capture`, `history --capture`) whose product is a snapshot file
35
+ * with no envelope contract to refuse through.
36
+ *
37
+ * @module
38
+ */
39
+
40
+ import {
41
+ blindSpotRows,
42
+ isWholeFileFailure,
43
+ unresolvableLiteralCount,
44
+ } from "../analysis/source-util.mjs";
45
+ import { EXIT, coverageIncompleteReasons } from "../verdict.mjs";
46
+ import { buildDecision } from "../report/evidence.mjs";
47
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
48
+ import { formatCoverageIncomplete } from "../report/text.mjs";
49
+ import { resolveProvenance } from "./provenance.mjs";
50
+
51
+ /**
52
+ * @typedef {object} CoverageVerdict
53
+ * @property {{file: string, reason: string}[]} notAnalyzed One row per
54
+ * whole-file failure — the files the run never read.
55
+ * @property {object[]} blindSpots Every positioned failure, all permanent
56
+ * classes — the run's disclosure of every site it saw and did not judge.
57
+ * @property {number} blindSpotCount The count of sites that WITHHOLD the
58
+ * verdict (unresolvable literals referencing the workspace; dynamic and
59
+ * external sites disclosed but exit-neutral).
60
+ * @property {boolean} complete Whether the run judged everything in scope.
61
+ * @property {"ok"|"no-verdict"} status The verdict the completeness implies.
62
+ * @property {0|3} exitCode The exit code the status implies.
63
+ */
64
+
65
+ /**
66
+ * The one completeness verdict over a command context's analysis.
67
+ *
68
+ * The three completeness axes are exactly the envelope law's: a whole-file
69
+ * failure is a file no verdict was reached on; an unresolvable literal site
70
+ * is a question the resolver was asked and could not answer; a zero-analyzed
71
+ * run judged nothing (#599). Dynamic and external sites are declared limits —
72
+ * disclosed in `blindSpots`, never withholding — because
73
+ * `unresolvableLiteralCount` draws that class line once, and this function
74
+ * reads the count rather than re-deriving it.
75
+ *
76
+ * @param {object} commandContext From `resolveCommandContext`.
77
+ * @param {{acceptedFiles?: Set<string>}} [io] `acceptedFiles` names whole-
78
+ * file failures a `coverage.unowned` row already accepts (`waivers` and
79
+ * `check` withdraw those first): the state is a recorded acceptance the
80
+ * report itself names, not a hole the run failed to look at. Absent ⇒ no
81
+ * withdrawal, which is every command but `waivers`.
82
+ * @returns {CoverageVerdict}
83
+ */
84
+ export function coverageVerdict(commandContext, { acceptedFiles } = {}) {
85
+ const { analysis } = commandContext;
86
+ const notAnalyzed = analysis.failures
87
+ .filter(isWholeFileFailure)
88
+ .filter(({ sourceFile }) => !(acceptedFiles?.has(sourceFile) ?? false))
89
+ .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
90
+ const blindSpots = blindSpotRows(analysis.failures);
91
+ const blindSpotCount = unresolvableLiteralCount(analysis.failures);
92
+ const complete = notAnalyzed.length === 0 && blindSpotCount === 0 && analysis.analyzed > 0;
93
+ return {
94
+ notAnalyzed,
95
+ blindSpots,
96
+ blindSpotCount,
97
+ complete,
98
+ status: complete ? "ok" : "no-verdict",
99
+ exitCode: complete ? EXIT.ok : EXIT.error,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * The structured refusal itself: what a command returns over an analysis it
105
+ * could not fully read, instead of throwing.
106
+ *
107
+ * The envelope carries no `result`: the verdict was withheld, so there is no
108
+ * verdict payload to ride beside the refusal — the `coverage` block IS the
109
+ * payload, and `jsonEnvelope`'s law would refuse any `ok`-shaped claim this
110
+ * object might otherwise smuggle through. `decision` is accepted for the two
111
+ * commands whose envelopes already carry one (`delta`, `change`); an
112
+ * "unknown" decision must name its reason (I3), so callers pass `true` and
113
+ * the reason is joined from the same clause list the text face renders.
114
+ *
115
+ * @param {{command: string, commandContext: object, what: string,
116
+ * acceptedFiles?: Set<string>, decision?: boolean}} input
117
+ * `command` is the envelope's command name, `what` names the question the
118
+ * command was asking (the text face's one free clause), `acceptedFiles`
119
+ * forwards to `coverageVerdict` (see above), and `decision: true` attaches
120
+ * the canonical `unknown` decision.
121
+ * @returns {{status: "no-verdict", coverage: object,
122
+ * report: {text: string, json: string}}}
123
+ */
124
+ export function coverageRefusal({ command, commandContext, what, acceptedFiles, decision }) {
125
+ const verdict = coverageVerdict(commandContext, { acceptedFiles });
126
+ // A refusal over a complete coverage would be a `no-verdict` claim about a
127
+ // run that looked at everything — the silent direction in the other dress.
128
+ // Every caller gates on `coverageVerdict(...).complete` first; this is the
129
+ // one place that refuses to build the lie if a caller stops gating.
130
+ if (verdict.complete) {
131
+ throw new Error(
132
+ `coverageRefusal: ${command}'s coverage is complete — there is nothing to refuse`,
133
+ );
134
+ }
135
+ /** @type {"no-verdict"} The completeness is false, so this is the status. */
136
+ const status = "no-verdict";
137
+ const reasons = coverageIncompleteReasons({
138
+ unchecked: verdict.notAnalyzed.length,
139
+ blindSpots: verdict.blindSpotCount,
140
+ analyzed: commandContext.analysis.analyzed,
141
+ });
142
+ const coverage = {
143
+ complete: false,
144
+ projects: Object.keys(commandContext.graph.nodes).length,
145
+ analyzedFiles: commandContext.analysis.analyzed,
146
+ imports: commandContext.analysis.imports.length,
147
+ notAnalyzed: verdict.notAnalyzed,
148
+ blindSpots: verdict.blindSpots,
149
+ notes: [],
150
+ };
151
+ const envelope = jsonEnvelope({
152
+ command,
153
+ context: {
154
+ root: commandContext.root,
155
+ provider: commandContext.provider,
156
+ marker: commandContext.marker,
157
+ provenance: resolveProvenance(commandContext.root),
158
+ },
159
+ status,
160
+ exitCode: verdict.exitCode,
161
+ coverage,
162
+ result: undefined,
163
+ ...(decision === true
164
+ ? {
165
+ decision: buildDecision({
166
+ status,
167
+ coverageComplete: false,
168
+ findings: 0,
169
+ reason: reasons.join("; "),
170
+ }),
171
+ }
172
+ : {}),
173
+ });
174
+ return {
175
+ status,
176
+ coverage,
177
+ report: {
178
+ text:
179
+ `${command}: no verdict — ${what} needs a workspace this run could fully read\n` +
180
+ formatCoverageIncomplete(reasons),
181
+ json: renderJson(envelope),
182
+ },
183
+ };
184
+ }
@@ -45,12 +45,13 @@
45
45
  * when the ledger was taken; the aging itself is snapshot-relative
46
46
  * (`../governance/debt-ledger.mjs`).
47
47
  */
48
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
48
+ import { blindSpotRows } from "../analysis/source-util.mjs";
49
49
  import { judgeIntent } from "../architecture-intent/judge.mjs";
50
50
  import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
51
51
  import { computeDebtLedger } from "../governance/debt-ledger.mjs";
52
52
  import { readEvents } from "../governance/evolution-store.mjs";
53
53
  import { formatDebtReport } from "../report/debt-text.mjs";
54
+ import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
54
55
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
55
56
  import { resolveProvenance } from "./provenance.mjs";
56
57
  import { buildObserved, refuseIncompleteGraph } from "./drift.mjs";
@@ -75,9 +76,12 @@ import { readSnapshots } from "./history.mjs";
75
76
  * the `history`/`drift` pattern. `events` is the event-store directory the
76
77
  * `--events <dir>` CLI flag resolves; absent ⇒ no lifecycle refs (the
77
78
  * ledger says so in `lifecycle.note`), never guessed.
78
- * @returns {Promise<{status: "ok", ledger: object, coverage: object,
79
+ * @returns {Promise<{status: "ok"|"no-verdict", ledger?: object, coverage: object,
79
80
  * report: {text: string, json: string}}>}
80
- * @throws {Error} on every condition the header lists, all exit-3 class.
81
+ * `status: "no-verdict"` carries no `ledger` payload the verdict was
82
+ * withheld, and the envelope's `coverage` block is the whole answer (#608).
83
+ * @throws {Error} on every condition the header lists except the coverage one,
84
+ * which returns instead of throwing.
81
85
  */
82
86
  export async function debtCommand(dir, commandContext, options = {}) {
83
87
  const { root, provider, marker, analysis } = commandContext;
@@ -86,15 +90,16 @@ export async function debtCommand(dir, commandContext, options = {}) {
86
90
 
87
91
  refuseIncompleteGraph(commandContext);
88
92
 
89
- const notAnalyzed = analysis.failures
90
- .filter(isWholeFileFailure)
91
- .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
92
- if (notAnalyzed.length > 0) {
93
- throw new Error(
94
- `archkeep: debt has incomplete coverage ${notAnalyzed.length} file` +
95
- `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so every "project missing" ` +
96
- `would be ambiguous between "gone" and "never seen". Fix the unanalyzed files and re-run.`,
97
- );
93
+ // The ledger is a claim about the tree the run read, refused through the one
94
+ // structured contract `./coverage-verdict.mjs` builds (#608). This gate used
95
+ // to check whole-file failures only and then claim `coverage.complete: true`
96
+ // beside `blindSpots` that could carry an unjudged site — which the envelope
97
+ // law refuses as a programming error, so a blind-spot-only tree crashed
98
+ // here. The unified completeness refuses it as the verdict-withholding state
99
+ // it is, before the snapshot directory is read.
100
+ const completeness = coverageVerdict(commandContext);
101
+ if (!completeness.complete) {
102
+ return coverageRefusal({ command: "debt", commandContext, what: "aging the debt ledger" });
98
103
  }
99
104
 
100
105
  // The snapshot directory is the sole source of truth for aging; a directory
@@ -173,9 +178,7 @@ export async function debtCommand(dir, commandContext, options = {}) {
173
178
  analyzedFiles: analysis.analyzed,
174
179
  imports: analysis.imports.length,
175
180
  notAnalyzed: [],
176
- blindSpots: analysis.failures
177
- .filter((failure) => !isWholeFileFailure(failure))
178
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
181
+ blindSpots: blindSpotRows(analysis.failures),
179
182
  // The ledger names what it could not age — a reader can tell "aged" from
180
183
  // "observed, not yet aged". The second note discloses `sampleTime`'s own
181
184
  // nature: it reflects the wall clock at the moment of THIS run, not the
@@ -593,20 +593,11 @@ export function deltaFindings(delta) {
593
593
  };
594
594
  }
595
595
 
596
- /**
597
- * The identity string of one graph edge, in the design's canonical spelling
598
- * `source>target:type` (the `(source, target, type)` identity the design
599
- * §1 names, printed the way `docs/concepts/evolution.md`'s example shows).
600
- * The ONE spelling the delta's event `observed.edges`/`affected.boundaries`
601
- * use — a second spelling somewhere would be a second definition of "same
602
- * edge", and two definitions drift.
603
- *
604
- * @param {{source: string, target: string, type: string}} edge
605
- * @returns {string}
606
- */
607
- export function edgeEvolutionIdentity({ source, target, type }) {
608
- return `${source}>${target}:${type}`;
609
- }
596
+ // The identity spelling's ONE home is `../governance/evolution-event.mjs` —
597
+ // `classifyEvolution` owns it and maps every `observed.edges` entry through
598
+ // it. This re-export keeps delta's importers on the same name; it defines
599
+ // nothing.
600
+ export { edgeEvolutionIdentity } from "../governance/evolution-event.mjs";
610
601
 
611
602
  /**
612
603
  * The delta event's per-constraint verdict deltas (design §1 `fitness`),
@@ -675,12 +666,16 @@ export function deltaVerdictDeltas(delta) {
675
666
  *
676
667
  * @param {object} delta The `deltaCommand` result payload.
677
668
  * @param {{projects?: {added: string[], removed: string[], changed: string[]},
678
- * edges?: {added: string[], removed: string[]}, codeDrift?: boolean}} [signals]
669
+ * edges?: {added: {source: string, target: string, type: string}[],
670
+ * removed: {source: string, target: string, type: string}[]},
671
+ * codeDrift?: boolean}} [signals]
679
672
  * The structural-change and drift signals a delta run derives from the two
680
673
  * graphs it holds (the graph diff is `diff`'s vocabulary, shared here, never
681
- * re-derived) — `projects`/`edges` carry identity strings (`edgeEvolutionIdentity`
682
- * for edges), and `codeDrift` is the delta's computed "provenance advanced,
683
- * no arch/policy change" fact. Absent signals are empty, so a delta that
674
+ * re-derived) — `edges` carries the raw `{source, target, type}` triples,
675
+ * which `classifyEvolution` maps through `edgeEvolutionIdentity` itself (the
676
+ * identity string is its output spelling, never an input), and `codeDrift`
677
+ * is the delta's computed "provenance advanced, no arch/policy change" fact.
678
+ * Absent signals are empty, so a delta that
684
679
  * computed none reads as a violation-only mapping.
685
680
  * @returns {{classifications: string[], disposition: "accepted"|"rejected"|"no-verdict",
686
681
  * notes: string[], affected: {projects: string[], boundaries: string[],
@@ -23,8 +23,12 @@
23
23
  * exit 1, beside `check` and `fitness`; `./change.mjs` later became the
24
24
  * fourth, over a different question — declared intent versus observed delta.
25
25
  *
26
- * Refusals (each a throw, exit 3 upstream — a delta that could not honestly
27
- * classify must never read as "no change"):
26
+ * Refusals — a delta that could not honestly classify must never read as
27
+ * "no change". Incomplete CURRENT coverage on the compare side returns the
28
+ * structured no-verdict envelope `./coverage-verdict.mjs` builds (#608) —
29
+ * status "no-verdict", exit 3, a `coverage` block naming every file and site
30
+ * the run could not judge — where a parser and `--output` can read it; the
31
+ * rest are throws, exit 3 upstream:
28
32
  * - a baseline that cannot be read, parsed, or holds a foreign schemaVersion
29
33
  * (`./delta-snapshot.mjs`'s loader owns those);
30
34
  * - a provider mismatch between baseline and this run (`providerMismatch`) —
@@ -32,8 +36,8 @@
32
36
  * across two different project models is not trustworthy: the same tree
33
37
  * attributed to different projects would classify a rename as an
34
38
  * introduced/resolved pair the code does not contain;
35
- * - incomplete CURRENT coverage a delta over a half-analyzed head is not a
36
- * verdict, the same posture `check` takes on `unchecked` files;
39
+ * - incomplete coverage on the CAPTURE side (`refuseUnjudgeableHead`), where
40
+ * there is no envelope to withhold a baseline write has no verdict;
37
41
  * - an Nx workspace with polyglot manifests but no plugin registration — the
38
42
  * same silently-under-representing graph `graph`/`diff` refuse.
39
43
  *
@@ -62,7 +66,11 @@
62
66
  */
63
67
  import { createRequire } from "node:module";
64
68
 
65
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
69
+ import {
70
+ blindSpotRows,
71
+ isWholeFileFailure,
72
+ unresolvableLiteralCount,
73
+ } from "../analysis/source-util.mjs";
66
74
  import { stripTrailingSlashes } from "../path-util.mjs";
67
75
  import { referenceTime } from "../governance/clock.mjs";
68
76
  import {
@@ -97,6 +105,7 @@ import {
97
105
  } from "./delta-snapshot.mjs";
98
106
  import { computeDiff } from "./diff.mjs";
99
107
  import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
108
+ import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
100
109
  import { resolveProvenance } from "./provenance.mjs";
101
110
  import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
102
111
 
@@ -105,20 +114,21 @@ const require = createRequire(import.meta.url);
105
114
  const { name: TOOL_NAME, version: TOOL_VERSION } = require("../../package.json");
106
115
 
107
116
  /**
108
- * Refuses the two head states no delta side may be built over, shared by both
109
- * modes: the unregistered-plugin graph and incomplete analysis coverage.
117
+ * Refuses the unregistered-plugin head state no delta side may be built over:
118
+ * on an Nx workspace whose `nx.json` does not register this plugin but whose
119
+ * tracked files include polyglot manifests under project roots.
110
120
  *
111
121
  * Exported since `change` arrived because that command builds its comparison
112
- * over the same two head states — a graph that under-represents the tree and
113
- * an analysis with holes would reconcile a declaration against architecture
114
- * nobody observed — and a second copy of the refusal is where the two
115
- * commands would drift into answering "may this head be judged?" differently.
122
+ * over the same head state — a graph that under-represents the tree would
123
+ * reconcile a declaration against architecture nobody observed and a second
124
+ * copy of the refusal is where the two commands would drift into answering
125
+ * "may this head be judged?" differently.
116
126
  *
117
127
  * @param {object} commandContext From `resolveCommandContext`.
118
128
  * @param {string} activity Which mode is refusing, for the message.
119
- * @throws {Error} on either condition.
129
+ * @throws {Error} on the unregistered-plugin graph.
120
130
  */
121
- export function refuseUnjudgeableHead(commandContext, activity) {
131
+ export function refusePluginGapHead(commandContext, activity) {
122
132
  const { provider, pluginGap } = commandContext;
123
133
  if (provider === "nx" && !pluginGap.registered && pluginGap.manifests.length > 0) {
124
134
  throw new Error(
@@ -130,11 +140,45 @@ export function refuseUnjudgeableHead(commandContext, activity) {
130
140
  `manifests if they are not in use.`,
131
141
  );
132
142
  }
143
+ }
144
+
145
+ /**
146
+ * Refuses, as a throw, the two head states no delta side may be built over:
147
+ * the unregistered-plugin graph (`refusePluginGapHead`) and incomplete
148
+ * analysis coverage.
149
+ *
150
+ * The coverage half stayed a throw through #602's unification of the
151
+ * graph-family refusal, and it stays one here — but only for the CAPTURE
152
+ * side: `captureDelta` writes a baseline, and a write that cannot be honest
153
+ * has no verdict to withhold, so stderr is the only face it has. The compare
154
+ * side (`deltaCommand`, `changeCommand`) refuses coverage through
155
+ * `./coverage-verdict.mjs`'s structured envelope (#608) instead and calls only
156
+ * `refusePluginGapHead`, so a withheld verdict reaches a parser and
157
+ * `--output` in-band.
158
+ *
159
+ * @param {object} commandContext From `resolveCommandContext`.
160
+ * @param {string} activity Which mode is refusing, for the message.
161
+ * @throws {Error} on either condition.
162
+ */
163
+ export function refuseUnjudgeableHead(commandContext, activity) {
164
+ refusePluginGapHead(commandContext, activity);
133
165
  const notAnalyzed = commandContext.analysis.failures.filter(isWholeFileFailure);
134
- if (notAnalyzed.length > 0) {
166
+
167
+ const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
168
+ if (notAnalyzed.length > 0 || blindSpotCount > 0) {
135
169
  throw new Error(
136
- `archkeep: cannot ${activity} — ${notAnalyzed.length} file` +
137
- `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so the evidence would ` +
170
+ `archkeep: cannot ${activity} — ` +
171
+ [
172
+ notAnalyzed.length > 0
173
+ ? `${notAnalyzed.length} file${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed`
174
+ : null,
175
+ blindSpotCount > 0
176
+ ? `${blindSpotCount} import site${blindSpotCount === 1 ? "" : "s"} could not be resolved`
177
+ : null,
178
+ ]
179
+ .filter(Boolean)
180
+ .join(", ") +
181
+ `, so the evidence would ` +
138
182
  `miss violations living there and a later classification would misread the gap as a ` +
139
183
  `code change. Fix the unanalyzed files and re-run.`,
140
184
  );
@@ -181,14 +225,7 @@ export function captureDelta(commandContext, { config }) {
181
225
  complete: true,
182
226
  analyzedFiles: analysis.analyzed,
183
227
  notAnalyzed: [],
184
- blindSpots: analysis.failures
185
- .filter((failure) => !isWholeFileFailure(failure))
186
- .map(({ sourceFile, line, column, reason }) => ({
187
- file: sourceFile,
188
- line,
189
- column,
190
- reason,
191
- })),
228
+ blindSpots: blindSpotRows(analysis.failures),
192
229
  },
193
230
  graph,
194
231
  records: analysis.imports,
@@ -387,15 +424,21 @@ const short = (fingerprint) =>
387
424
  * `loadIntent`; absent intent ⇒ no ids, an in-band note says so), and the
388
425
  * custom-rule host's two injectable seams, passed through to
389
426
  * `customRulesForDelta`.
390
- * @returns {Promise<{status: "ok"|"findings"|"no-verdict", delta: object,
427
+ * @returns {Promise<{status: "ok"|"findings"|"no-verdict", delta?: object,
391
428
  * coverage: object,
392
- * eventWrite: {id: string, duplicate: boolean}|null,
393
- * report: {text: string, json: string, sarif: string}}>} `delta` carries
429
+ * eventWrite?: {id: string, duplicate: boolean}|null,
430
+ * report: {text: string, json: string, sarif?: string}}>} `delta` carries
394
431
  * the additive `classifications`/`affected` fields (design §1); `eventWrite`
395
432
  * is `null` unless `eventOut` was given, then the store's answer for the
396
- * event that was (or already was) recorded.
433
+ * event that was (or already was) recorded. `status: "no-verdict"` from the
434
+ * coverage refusal (#608) carries neither `delta` nor `eventWrite` — the
435
+ * comparison was withheld before any event work, and the envelope's
436
+ * `coverage` block plus its `decision.reason` are the whole answer; the
437
+ * report then has no `sarif` face (there are no findings to render).
397
438
  * @throws {Error} on every refusal the module header lists, and on a
398
439
  * custom-rule LOAD failure (`./custom-rules.mjs` argues the split).
440
+ * Incomplete head coverage returns the structured no-verdict envelope
441
+ * instead of throwing (#608); the unregistered-plugin graph keeps its throw.
399
442
  */
400
443
  export async function deltaCommand(
401
444
  baselinePath,
@@ -411,7 +454,22 @@ export async function deltaCommand(
411
454
  ) {
412
455
  const { root, provider, marker, graph, analysis, tracked } = commandContext;
413
456
 
414
- refuseUnjudgeableHead(commandContext, "compute a delta");
457
+ // The plugin-gap refusal stays a throw; the coverage refusal returns the one
458
+ // structured envelope `./coverage-verdict.mjs` builds (#608): a verdict
459
+ // withheld because the run could not read the tree belongs in-band, where a
460
+ // parser and `--output` can read it — the same contract the graph family has
461
+ // run since #602. The capture side keeps the throw (`refuseUnjudgeableHead`
462
+ // above), because a baseline write has no envelope to withhold.
463
+ refusePluginGapHead(commandContext, "compute a delta");
464
+ const completeness = coverageVerdict(commandContext);
465
+ if (!completeness.complete) {
466
+ return coverageRefusal({
467
+ command: "delta",
468
+ commandContext,
469
+ what: "computing a delta",
470
+ decision: true,
471
+ });
472
+ }
415
473
  if (!config) {
416
474
  throw new Error(
417
475
  "archkeep: cannot compute a delta without a boundary config — both sides are re-judged " +
@@ -630,7 +688,13 @@ export async function deltaCommand(
630
688
  };
631
689
  const evolution = classifyDeltaEvolution(deltaPayload, {
632
690
  projects: structural.projects,
633
- edges: structural.edges,
691
+ // The raw triples, not `structural.edges` (the mapped identity strings):
692
+ // `classifyEvolution` owns the spelling and takes the triples. The
693
+ // envelope's `structural` and the event's `observed` keep the mapped
694
+ // strings this run derived above — one mapping per command, and the
695
+ // classification's `affected.boundaries` comes out under the same
696
+ // spelling because it is mapped inside `classifyEvolution` itself.
697
+ edges: { added: structuralDiff.addedEdges, removed: structuralDiff.removedEdges },
634
698
  codeDrift,
635
699
  });
636
700
 
@@ -683,9 +747,7 @@ export async function deltaCommand(
683
747
  analyzedFiles: analysis.analyzed,
684
748
  imports: analysis.imports.length,
685
749
  notAnalyzed: [],
686
- blindSpots: analysis.failures
687
- .filter((failure) => !isWholeFileFailure(failure))
688
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
750
+ blindSpots: blindSpotRows(analysis.failures),
689
751
  notes,
690
752
  };
691
753
 
@@ -40,9 +40,9 @@
40
40
  */
41
41
  import { readFileSync } from "node:fs";
42
42
 
43
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
44
43
  import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
45
44
  import { computeRuleImpact } from "./edge-constraints.mjs";
45
+ import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
46
46
  import { SCHEMA_VERSION } from "../report/json.mjs";
47
47
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
48
48
  import { formatDiffReport } from "../report/diff-text.mjs";
@@ -349,11 +349,14 @@ export function computeDiff(baseline, head) {
349
349
  * When omitted, reads from the real filesystem. `config` is the loaded
350
350
  * boundary config; when provided, rule-impact analysis is computed alongside
351
351
  * the structural diff.
352
- * @returns {{status: "ok"|"no-verdict", diff: object, coverage: object,
353
- * report: {text: string, json: string}}}
354
- * @throws {Error} when the baseline cannot be read or is incomplete, when
355
- * the head is incomplete, or when an Nx workspace has polyglot manifests
356
- * but the plugin is not registered.
352
+ * @returns {{status: "ok"|"no-verdict", diff?: object, coverage: object,
353
+ * report: {text: string, json: string}}} `diff` is absent under
354
+ * `status: "no-verdict"` the coverage refusal (#608) withholds the
355
+ * comparison, and the envelope's `coverage` block is the whole answer.
356
+ * @throws {Error} when the baseline cannot be read or is incomplete, or when
357
+ * an Nx workspace has polyglot manifests but the plugin is not registered
358
+ * (the inline throw in the body). An incomplete head returns the
359
+ * structured no-verdict envelope instead of throwing (#608).
357
360
  */
358
361
  export function diffCommand(
359
362
  baselinePath,
@@ -379,19 +382,18 @@ export function diffCommand(
379
382
  // is a caller error, not a workspace fact, and it should name the file.
380
383
  const baseline = readBaseline(baselinePath);
381
384
 
382
- // Refuse an incomplete head same reasoning as the incomplete baseline
383
- // refusal: every "added" or "removed" entry would be ambiguous.
384
- const notAnalyzed = commandContext.analysis.failures
385
- .filter(isWholeFileFailure)
386
- .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
387
-
388
- if (notAnalyzed.length > 0) {
389
- throw new Error(
390
- `archkeep: the head graph has incomplete coverage — ${notAnalyzed.length} file` +
391
- `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so every "added" or ` +
392
- `"removed" entry in the diff would be ambiguous between a real change and a coverage ` +
393
- `gap. Fix the unanalyzed files and re-run.`,
394
- );
385
+ // Refuse an incomplete head through the one structured contract
386
+ // `./coverage-verdict.mjs` builds (#608): the diff is withheld in-band
387
+ // status "no-verdict", exit 3, a `coverage` block naming every file and
388
+ // site the run could not judge — where a parser and `--output` can read it.
389
+ // The reasoning is the incomplete-baseline refusal's: every "added" or
390
+ // "removed" entry would be ambiguous. That baseline refusal stays a throw —
391
+ // `parseBaseline` above throws it through the default reader — because a
392
+ // baseline nobody captured correctly is a caller error about a file, not a
393
+ // coverage fact about THIS tree.
394
+ const completeness = coverageVerdict(commandContext);
395
+ if (!completeness.complete) {
396
+ return coverageRefusal({ command: "diff", commandContext, what: "diffing the head graph" });
395
397
  }
396
398
 
397
399
  const head = buildHeadSnapshot(commandContext);
@@ -404,9 +406,7 @@ export function diffCommand(
404
406
  analyzedFiles: commandContext.analysis.analyzed,
405
407
  imports: commandContext.analysis.imports.length,
406
408
  notAnalyzed: [],
407
- blindSpots: commandContext.analysis.failures
408
- .filter((f) => !isWholeFileFailure(f))
409
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
409
+ blindSpots: completeness.blindSpots,
410
410
  notes: [],
411
411
  };
412
412
 
@@ -417,6 +417,12 @@ export function diffCommand(
417
417
  projects: baseline.projects.length,
418
418
  edges: baseline.dependencies.length,
419
419
  toolVersion: baseline.toolVersion,
420
+ // The provenance the baseline itself recorded — which commit, remote,
421
+ // and dirtiness the captured side names (#609). The head side's own
422
+ // provenance rides the envelope's `workspace.provenance`; without the
423
+ // baseline's, a consumer could not tell WHICH revision the empty or
424
+ // non-empty diff is measured against.
425
+ provenance: baseline.provenance ?? null,
420
426
  },
421
427
  head: { projects: head.projects.length, edges: head.dependencies.length },
422
428
  addedProjects: diff.addedProjects,
@@ -525,8 +531,9 @@ export function diffCommand(
525
531
  // no depConstraints violations were introduced or resolved on the changed
526
532
  // edges. Run `check` for the complete verdict.
527
533
  coverage.notes.push(
528
- "per-edge rule-impact covers only depConstraints (3 of 15 violation types). " +
529
- "A dependency with no rule-impact may still violate npm-ban, circular-dependency, " +
534
+ "per-edge rule-impact covers only depConstraints (3 of 15 violation types; standing " +
535
+ "edges adjacent to a tags-changed project are re-judged under both sides' tags). A " +
536
+ "dependency with no rule-impact may still violate npm-ban, circular-dependency, " +
530
537
  "lazy-load, or other rules that require import-site details. Run check for the " +
531
538
  "complete verdict.",
532
539
  );