@ecoma-io/archkeep 0.20.1 → 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 (41) 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 +708 -0
  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 +691 -0
  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-statement.mjs +31 -409
  27. package/src/commands/impact.mjs +18 -18
  28. package/src/commands/plan-context-command.mjs +10 -5
  29. package/src/commands/provenance-command.mjs +33 -2
  30. package/src/commands/reconcile.mjs +14 -17
  31. package/src/commands/scenario-evaluation.mjs +363 -198
  32. package/src/commands/scenario.mjs +32 -21
  33. package/src/commands/waivers.mjs +36 -28
  34. package/src/governance/evolution-event.mjs +62 -9
  35. package/src/governance/provenance-graph.mjs +479 -0
  36. package/src/intent/intent-manifest.json +83 -39
  37. package/src/report/json.mjs +32 -5
  38. package/src/report/provenance-text.mjs +30 -7
  39. package/src/report/text.mjs +82 -12
  40. package/src/verdict.mjs +78 -36
  41. package/src/workspace.mjs +126 -2
@@ -32,7 +32,11 @@
32
32
  * than explaining constraints from a graph whose edges silently under-represent
33
33
  * the real architecture.
34
34
  */
35
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
35
+ import {
36
+ blindSpotRows,
37
+ isWholeFileFailure,
38
+ unresolvableLiteralCount,
39
+ } from "../analysis/source-util.mjs";
36
40
  import { UsageError } from "../errors.mjs";
37
41
  import { judgeEdge } from "./edge-constraints.mjs";
38
42
  import { findConstraintsFor } from "../rules/tags.mjs";
@@ -150,7 +154,13 @@ export function contextCommand(projectName, commandContext, config) {
150
154
  .filter(isWholeFileFailure)
151
155
  .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
152
156
 
153
- const complete = notAnalyzed.length === 0;
157
+ // The same completeness `check` claims (#595, #599): unjudged sites and
158
+ // a zero-analyzed run defeat it here exactly as they do there, so a
159
+ // context report cannot look complete over a tree the run could not
160
+ // fully read.
161
+ const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
162
+ const complete =
163
+ notAnalyzed.length === 0 && blindSpotCount === 0 && commandContext.analysis.analyzed > 0;
154
164
  const status = complete ? "ok" : "no-verdict";
155
165
  const exitCode = complete ? 0 : 3;
156
166
 
@@ -160,9 +170,7 @@ export function contextCommand(projectName, commandContext, config) {
160
170
  analyzedFiles: commandContext.analysis.analyzed,
161
171
  imports: commandContext.analysis.imports.length,
162
172
  notAnalyzed,
163
- blindSpots: commandContext.analysis.failures
164
- .filter((f) => !isWholeFileFailure(f))
165
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
173
+ blindSpots: blindSpotRows(commandContext.analysis.failures),
166
174
  notes: [
167
175
  "per-edge violations cover only depConstraints (3 of 15 violation types). " +
168
176
  "A dependency with no violations here may still violate npm-ban, circular-dependency, " +
@@ -214,11 +214,14 @@ export const WORKSPACE_MARKERS = [
214
214
  * `createWorkspace` returns.
215
215
  * @property {string[]} tracked Every tracked file, from `listFiles(root)`.
216
216
  * @property {{imports: object[], failures: object[], analyzed: number,
217
- * analyzedFiles: string[], exemptedFiles: string[]}} analysis The
217
+ * analyzedFiles: string[], exemptedFiles: string[],
218
+ * unsupportedLanguageFiles: string[]}} analysis The
218
219
  * whole-tree-then-scoped (native) or scoped-then-analyzed (nx/moon) result —
219
220
  * see the branches below for why the order is not the same on all three.
220
221
  * `exemptedFiles` is always `[]` on nx/moon: `coverage.exempt` is a
221
- * native-only `archkeep.json` key.
222
+ * native-only `archkeep.json` key. `unsupportedLanguageFiles` is scoped to
223
+ * the selected files on every branch (#601): a gap row names only files the
224
+ * selected scope could have judged.
222
225
  * @property {{boundaryConfig: string|object, tsConfig: object|undefined,
223
226
  * boundaryConfigDeclared: boolean, profiles?: string, inline?: boolean}} options
224
227
  * What this workspace names its boundary law, its shared tsconfig, and —
@@ -549,6 +552,7 @@ export function resolveCommandContext(
549
552
  let failures;
550
553
  let analyzed;
551
554
  let analyzedFiles;
555
+ let unsupportedLanguageFiles;
552
556
  let pluginGap;
553
557
  let unownedGap;
554
558
  let unclaimedGap;
@@ -664,6 +668,12 @@ export function resolveCommandContext(
664
668
  ...jvmIndexFailures(workspace),
665
669
  ]);
666
670
  analyzedFiles = wholeTreeAnalysis.analyzedFiles.filter((file) => selectedFiles.has(file));
671
+ // Scoped exactly like `analyzedFiles` above (#601): a gap row must name
672
+ // only files the selected scope could have judged, and leaving this
673
+ // undefined here would crash `check`'s gap assembly instead of reporting.
674
+ unsupportedLanguageFiles = wholeTreeAnalysis.unsupportedLanguageFiles.filter((file) =>
675
+ selectedFiles.has(file),
676
+ );
667
677
  analyzed = analyzedFiles.length;
668
678
  // Unaffected by `paths`: an exempted file is by definition unowned by any
669
679
  // project, so it was never a candidate for `owned`/`selected` in the
@@ -787,6 +797,13 @@ export function resolveCommandContext(
787
797
  ]);
788
798
  unclaimedGap = { files: unclaimedFiles };
789
799
  analyzedFiles = wholeTreeAnalysis.analyzedFiles.filter((file) => selectedFiles.has(file));
800
+ // Scoped exactly like the native branch's own filter above (#601): a gap
801
+ // row must name only files the selected scope could have judged, and
802
+ // leaving this undefined here would crash `check`'s gap assembly instead
803
+ // of reporting.
804
+ unsupportedLanguageFiles = wholeTreeAnalysis.unsupportedLanguageFiles.filter((file) =>
805
+ selectedFiles.has(file),
806
+ );
790
807
  analyzed = analyzedFiles.length;
791
808
  // `coverage.exempt` is a native-only key (`../providers/native/coverage.mjs`'s
792
809
  // header: "Nx has no equivalent question") — Moon carries no such list.
@@ -835,7 +852,10 @@ export function resolveCommandContext(
835
852
  paths,
836
853
  { root, cwd, tracked },
837
854
  );
838
- ({ imports, failures, analyzed, analyzedFiles } = analyzeWorkspace(workspace, selected));
855
+ ({ imports, failures, analyzed, analyzedFiles, unsupportedLanguageFiles } = analyzeWorkspace(
856
+ workspace,
857
+ selected,
858
+ ));
839
859
  // Unclaimed analyzable files — `unclaimedFileFailures` above — join
840
860
  // unconditionally, the same workspace-wide posture native's own
841
861
  // `discovered.failures` has, so a scoped `check <path>` cannot hide an
@@ -876,7 +896,14 @@ export function resolveCommandContext(
876
896
  graph,
877
897
  workspace,
878
898
  tracked,
879
- analysis: { imports, failures, analyzed, analyzedFiles, exemptedFiles },
899
+ analysis: {
900
+ imports,
901
+ failures,
902
+ analyzed,
903
+ analyzedFiles,
904
+ exemptedFiles,
905
+ unsupportedLanguageFiles,
906
+ },
880
907
  options,
881
908
  pluginGap,
882
909
  unownedGap,
@@ -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