@ecoma-io/archkeep 0.23.0 → 0.24.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/src/config.mjs CHANGED
@@ -639,6 +639,155 @@ function findCoverageViolations(value) {
639
639
  return violations;
640
640
  }
641
641
 
642
+ /**
643
+ * The one edge kind a markdown marker row can declare. The name is the claim
644
+ * the row makes about the graph: the captured symbol is resolved to the project
645
+ * that exports it, and the edge runs from the document's own project to that
646
+ * project. Kept as data rather than inlined at the check site so a second kind
647
+ * (`docs/reference/policy-schema.md`, "markdown") extends this list and nothing
648
+ * else — and so a row naming a kind this reader cannot draw is refused at load,
649
+ * where a law that would silently never run belongs.
650
+ *
651
+ * @type {Readonly<string[]>}
652
+ */
653
+ export const MARKDOWN_EDGE_KINDS = Object.freeze(["resolvedExportOwner"]);
654
+
655
+ /**
656
+ * One markdown marker row's problems, prefixed with its index.
657
+ *
658
+ * `pattern` is a regular-expression SOURCE matched against one line of a
659
+ * matched document at a time — never a glob, and never a whole-document
660
+ * match — because the position a violation reports must be a real
661
+ * `file:line:column` a developer can open. Its FIRST capture group must name
662
+ * the exported symbol the marker claims; a pattern with no capture group can
663
+ * match every line it is aimed at and still resolve nothing, which is a law
664
+ * that reads as enforced while testing nothing — refused here for the reason
665
+ * every dead shape in this file is refused.
666
+ *
667
+ * @param {object} row
668
+ * @param {number} index
669
+ * @returns {string[]}
670
+ */
671
+ function markdownMarkerRowViolations(row, index) {
672
+ const at = `markdown.markers[${index}]`;
673
+ if (!isPlainObject(row)) return [`${at}: must be an object, got ${describe(row)}`];
674
+
675
+ const violations = [];
676
+ if (typeof row.pattern !== "string" || row.pattern === "") {
677
+ violations.push(
678
+ `${at}.pattern: must be a non-empty regular-expression source matched against one line of ` +
679
+ `a matched document, got ${describe(row.pattern)}`,
680
+ );
681
+ } else {
682
+ let compiled = null;
683
+ try {
684
+ compiled = new RegExp(row.pattern, "u");
685
+ } catch (cause) {
686
+ violations.push(
687
+ `${at}.pattern: '${row.pattern}' is not a valid regular expression under the 'u' flag ` +
688
+ `(${cause?.message ?? cause})`,
689
+ );
690
+ }
691
+ if (compiled !== null) {
692
+ // The capture count, read off the standard `pattern|` trick: matching the
693
+ // empty string yields one element per capture group plus the whole match,
694
+ // so a pattern that cannot capture anything reports `length === 1` here.
695
+ // Compiled in its own try, because the appended `|` can fail on a source
696
+ // the pattern alone accepted; the compile violation above is already
697
+ // reported in that case, and a second one would name the same row twice.
698
+ let captures;
699
+ try {
700
+ captures = (new RegExp(`${row.pattern}|`, "u").exec("")?.length ?? 1) - 1;
701
+ } catch {
702
+ captures = 1; // already refused above by the pattern's own compile check
703
+ }
704
+ if (captures < 1) {
705
+ violations.push(
706
+ `${at}.pattern: '${row.pattern}' has no capture group — the first capture group must ` +
707
+ `name the exported symbol the marker claims, and a pattern that captures nothing ` +
708
+ `resolves nothing while reading as enforced`,
709
+ );
710
+ }
711
+ }
712
+ }
713
+ if (!MARKDOWN_EDGE_KINDS.includes(row.edge)) {
714
+ violations.push(
715
+ `${at}.edge: ${describe(row.edge)} is not an edge kind this reader can draw — expected ` +
716
+ `${MARKDOWN_EDGE_KINDS.map((kind) => `"${kind}"`).join(", ")} (the captured symbol is ` +
717
+ `resolved to the project exporting it, and the edge runs there from the document's own ` +
718
+ `project)`,
719
+ );
720
+ }
721
+ for (const key of Object.keys(row)) {
722
+ if (key !== "pattern" && key !== "edge") {
723
+ violations.push(`${at}.${key}: not a markdown marker field — expected 'pattern' and 'edge'`);
724
+ }
725
+ }
726
+ return violations;
727
+ }
728
+
729
+ /**
730
+ * The markdown document track's problems, as messages; empty when it is
731
+ * well-formed. Pure, so a test drives it without a file on disk.
732
+ *
733
+ * `include` names the documents the track reads, by workspace-relative glob —
734
+ * the same glob machinery `boundarySuppressions` rows and `coverage.unowned`
735
+ * rows match with (`./rules/match.mjs`'s `safeMatchesGlob`), complexity-checked
736
+ * here at load for the reason a suppression's `path` is. An ABSOLUTE pattern is
737
+ * refused rather than left to match nothing forever: it can never match a
738
+ * workspace-relative document path, so declaring one is a law aimed outside
739
+ * the tree it governs (the family test is `ABSOLUTE_ARTIFACT_PATH`'s — the same
740
+ * "absolute in either path family" question, asked of a glob instead of a file).
741
+ *
742
+ * @param {unknown} value The parsed `markdown` value.
743
+ * @returns {string[]}
744
+ */
745
+ function findMarkdownViolations(value) {
746
+ if (!isPlainObject(value)) {
747
+ return [`markdown: must be an object carrying 'include' and 'markers', got ${describe(value)}`];
748
+ }
749
+ const violations = [];
750
+ if (!Array.isArray(value.include) || value.include.length === 0) {
751
+ violations.push(
752
+ `markdown.include: must be a non-empty array of workspace-relative glob patterns naming ` +
753
+ `the documents the track reads, got ${describe(value.include)}`,
754
+ );
755
+ } else {
756
+ value.include.forEach((pattern, index) => {
757
+ const at = `markdown.include[${index}]`;
758
+ if (typeof pattern !== "string" || pattern === "") {
759
+ violations.push(
760
+ `${at}: must be a non-empty workspace-relative glob, got ${describe(pattern)}`,
761
+ );
762
+ } else if (ABSOLUTE_ARTIFACT_PATH.test(pattern)) {
763
+ violations.push(
764
+ `${at}: '${pattern}' is an absolute path — an include glob is matched against ` +
765
+ `workspace-relative document paths, so an absolute one can never match`,
766
+ );
767
+ } else {
768
+ const problem = globComplexityError(pattern);
769
+ if (problem) violations.push(`${at}: '${pattern}' ${problem}`);
770
+ }
771
+ });
772
+ }
773
+ if (!Array.isArray(value.markers) || value.markers.length === 0) {
774
+ violations.push(
775
+ `markdown.markers: must be a non-empty array of {pattern, edge} rows, got ` +
776
+ `${describe(value.markers)}`,
777
+ );
778
+ } else {
779
+ value.markers.forEach((row, index) =>
780
+ violations.push(...markdownMarkerRowViolations(row, index)),
781
+ );
782
+ }
783
+ for (const key of Object.keys(value)) {
784
+ if (key !== "include" && key !== "markers") {
785
+ violations.push(`markdown.${key}: not a markdown field — expected 'include' and 'markers'`);
786
+ }
787
+ }
788
+ return violations;
789
+ }
790
+
642
791
  /**
643
792
  * The grammar a custom rule's `name` is written in: lowercase letters and
644
793
  * digits, single `-` separators, nothing else.
@@ -945,6 +1094,7 @@ export function findBoundaryConfigViolations(module, io = {}) {
945
1094
  fitness,
946
1095
  customRules,
947
1096
  coverage,
1097
+ markdown,
948
1098
  } = module;
949
1099
  // F05: the resolution half of the governance block (`row-schema.mjs`'s
950
1100
  // `io.resolve`) was validator-only until now — no production caller passed
@@ -1000,6 +1150,19 @@ export function findBoundaryConfigViolations(module, io = {}) {
1000
1150
  violations.push(...findCoverageViolations(coverage));
1001
1151
  }
1002
1152
 
1153
+ // The markdown document track — the seventh top-level law, declared here and
1154
+ // executed at graph level (`../analysis/markdown.mjs`): machine-readable
1155
+ // markers inside tracked markdown documents, each resolved to an edge from
1156
+ // the document's own project to the project that exports the named symbol.
1157
+ // Absent means "no document track" — the workspace decision this key exists
1158
+ // to state, and the state every config-absent run must stay byte-identical
1159
+ // to. Present and malformed is refused here, loudly, for the reason the two
1160
+ // blocks above state: a row this reader cannot understand is a document law
1161
+ // that would not run while the policy still says it does.
1162
+ if (markdown !== undefined) {
1163
+ violations.push(...findMarkdownViolations(markdown));
1164
+ }
1165
+
1003
1166
  // Absent means "nothing is suppressed", which is the only default that fails
1004
1167
  // toward reporting — unlike the eight options above, where a missing value
1005
1168
  // would be a second copy of something ESLint also reads and this module has
@@ -1095,7 +1258,10 @@ export function findBoundaryConfigViolations(module, io = {}) {
1095
1258
  * `coverage.exempt` is that provider's one channel for the same decision —
1096
1259
  * and the refusal lives in `./commands/policy.mjs`'s `resolvePolicy` and
1097
1260
  * `./providers/native/model.mjs`'s inline-policy check, because only they
1098
- * know which provider is reading.
1261
+ * know which provider is reading. `markdown` is the seventh: the document
1262
+ * track (`findMarkdownViolations` above owns the shape), read into the graph
1263
+ * by `../analysis/markdown.mjs` on every provider, because documents belong
1264
+ * to projects under all three.
1099
1265
  *
1100
1266
  * The name says `.json` and the list binds both file dialects: `loadModulePolicy`
1101
1267
  * runs the same check over an ES module's exports, which is what makes a
@@ -1109,6 +1275,7 @@ const JSON_POLICY_KEYS = [
1109
1275
  "fitness",
1110
1276
  "customRules",
1111
1277
  "coverage",
1278
+ "markdown",
1112
1279
  ];
1113
1280
 
1114
1281
  /**
@@ -1185,7 +1352,7 @@ export function policyKeyViolations(parsed, { allowSchema }) {
1185
1352
  * inline one.
1186
1353
  * @param {string[]} [extraViolations] Violations the caller already found that
1187
1354
  * `findBoundaryConfigViolations` does not check on its own.
1188
- * @returns {{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object }}
1355
+ * @returns {{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object, markdown?: object }}
1189
1356
  * `fitness` and `customRules` are present only when the config declares
1190
1357
  * them — a workspace without one carries no key, the same "absent is a
1191
1358
  * decision" posture `cli.mjs`'s `check` uses for a missing
@@ -1209,6 +1376,7 @@ export function policyFrom(parsed, sourceLabel, extraViolations = []) {
1209
1376
  ...(parsed.fitness === undefined ? {} : { fitness: parsed.fitness }),
1210
1377
  ...(parsed.customRules === undefined ? {} : { customRules: parsed.customRules }),
1211
1378
  ...(parsed.coverage === undefined ? {} : { coverage: parsed.coverage }),
1379
+ ...(parsed.markdown === undefined ? {} : { markdown: parsed.markdown }),
1212
1380
  };
1213
1381
  }
1214
1382
 
package/src/errors.mjs CHANGED
@@ -23,7 +23,10 @@
23
23
  * (`../AGENTS.md`, check's four exit codes).
24
24
  *
25
25
  * One class, nothing else exported: a second class needs a catch site that
26
- * treats two of these mistakes differently, and none does.
26
+ * treats two of these mistakes differently, and none does. Beside it sits the
27
+ * one error-SHAPE predicate the engine shares (`isEnoent` below) — a test of
28
+ * what a caught value looks like, not a decision about what one means, which
29
+ * is why it lives with the error primitives rather than at any catch site.
27
30
  */
28
31
  export class UsageError extends Error {
29
32
  /**
@@ -34,3 +37,22 @@ export class UsageError extends Error {
34
37
  this.name = "UsageError";
35
38
  }
36
39
  }
40
+
41
+ /**
42
+ * Is this caught value the filesystem's "no such file or directory"?
43
+ *
44
+ * Node's `fs` throws the raw error with `code: "ENOENT"` set directly;
45
+ * `./process.mjs`'s `runProcess` wraps its child's failure and carries the
46
+ * original on `cause` — so the shape arrives both ways, and every site that
47
+ * distinguishes "absent" from "could not read" was spelling the test by hand
48
+ * (`#652`). The two legs together are the one definition; a catch site adds
49
+ * its own meaning on top (absent store, absent registry, install Moon), which
50
+ * is exactly the logic this predicate does not own.
51
+ *
52
+ * @param {unknown} error The caught value, of any shape.
53
+ * @returns {boolean}
54
+ */
55
+ export function isEnoent(error) {
56
+ const thrown = /** @type {{code?: unknown, cause?: {code?: unknown}}|null|undefined} */ (error);
57
+ return thrown?.code === "ENOENT" || thrown?.cause?.code === "ENOENT";
58
+ }
@@ -71,6 +71,8 @@
71
71
  import { createRequire } from "node:module";
72
72
  import { pathToFileURL } from "node:url";
73
73
 
74
+ import { isPlainObject } from "./values.mjs";
75
+
74
76
  /** The severities ESLint itself recognises for a rule entry. */
75
77
  const KNOWN_SEVERITIES = new Set(["off", "warn", "error", 0, 1, 2]);
76
78
  const OFF_SEVERITIES = new Set(["off", 0]);
@@ -94,11 +96,6 @@ const OFF_SEVERITIES = new Set(["off", 0]);
94
96
  // one — a stricter refusal than strictly necessary, not a silent guess.
95
97
  const BARE_EXTENSION_GLOB = /^\*\*\/\*\.[A-Za-z0-9]+$/u;
96
98
 
97
- /** @type {(value: unknown) => value is Record<string, unknown>} */
98
- function isPlainObject(value) {
99
- return typeof value === "object" && value !== null && !Array.isArray(value);
100
- }
101
-
102
99
  /** A rule entry's severity — the bare value itself, or the `[severity, …]` pair's first element. */
103
100
  function severityOf(value) {
104
101
  return Array.isArray(value) ? value[0] : value;
@@ -91,6 +91,7 @@ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "
91
91
  import { join } from "node:path";
92
92
 
93
93
  import { containmentViolation } from "../containment.mjs";
94
+ import { isEnoent } from "../errors.mjs";
94
95
  import { describe } from "../values.mjs";
95
96
 
96
97
  /** The directory, relative to a workspace root, where ADR files live. */
@@ -538,7 +539,7 @@ export function loadAdrRegistry(root, io = {}) {
538
539
  // deliberately silent, for the reason this module's header states.
539
540
  names = readDir(dir);
540
541
  } catch (cause) {
541
- if (cause?.code === "ENOENT") return { records: [], byId: new Map() };
542
+ if (isEnoent(cause)) return { records: [], byId: new Map() };
542
543
  throw new Error(`archkeep: cannot read ${ADR_DIR}: ${cause?.message ?? cause}`, { cause });
543
544
  }
544
545
  names.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
@@ -51,6 +51,7 @@ import {
51
51
  import { join, resolve } from "node:path";
52
52
 
53
53
  import { containmentViolation } from "../containment.mjs";
54
+ import { isEnoent } from "../errors.mjs";
54
55
  import {
55
56
  eventDedupeKey,
56
57
  eventId,
@@ -222,7 +223,7 @@ export function writeEvent(dir, event, io = {}) {
222
223
  try {
223
224
  names = readDir(dirAbs);
224
225
  } catch (cause) {
225
- if (cause?.code === "ENOENT") {
226
+ if (isEnoent(cause)) {
226
227
  // An absent optional store is an empty store: the caller's first event.
227
228
  makeDir(dirAbs, { recursive: true });
228
229
  names = readDir(dirAbs);
@@ -325,7 +326,7 @@ export function readEvents(dir, io = {}) {
325
326
  try {
326
327
  names = readDir(dirAbs);
327
328
  } catch (cause) {
328
- if (cause?.code === "ENOENT") return [];
329
+ if (isEnoent(cause)) return [];
329
330
  throw new Error(
330
331
  `archkeep: cannot read the event store '${dirAbs}': ${cause?.message ?? cause}`,
331
332
  { cause },
@@ -63,9 +63,6 @@ import {
63
63
  tagConformance,
64
64
  } from "./fitness-rules.mjs";
65
65
 
66
- /** The one `fitness` list key in the boundary config. */
67
- export const FITNESS_KEY = "fitness";
68
-
69
66
  /** The condition types the registry can evaluate. */
70
67
  export const CONDITION_TYPES = Object.freeze([
71
68
  "cycle-free",
@@ -59,9 +59,6 @@
59
59
  import { originViolations } from "./provenance-record.mjs";
60
60
  import { describe, isPlainObject } from "../values.mjs";
61
61
 
62
- /** The shape of any `origin.on` producer. Re-exported for a row owner's own docs. */
63
- export { clockViolations as clockValidation } from "./clock.mjs";
64
-
65
62
  /** The four governance keys a row may carry, in the order reports list them. */
66
63
  export const GOVERNANCE_ROW_KEYS = Object.freeze([
67
64
  "origin",
@@ -20,7 +20,7 @@
20
20
  "type": "architecture-test",
21
21
  "path": "src/conformance/boundary.test.mjs",
22
22
  "assertion": "SHIPPED_PACKAGES allow-list prevents unapproved dependencies; specifiersIn() walk catches all imports including createRequire; no provider import found in core layers",
23
- "sha256": "12d4e179f88de2622a41e56f42b2bab1c88a677ec2232430752138c57ba5fb18"
23
+ "sha256": "e4e24ed9f36e31126249c7d079b3656d17ac78ee4f12033321467ffa6666f033"
24
24
  },
25
25
  {
26
26
  "type": "source-evidence",
@@ -104,13 +104,13 @@
104
104
  "type": "source-evidence",
105
105
  "path": "src/commands/graph.mjs",
106
106
  "assertion": "Plain string comparison, never localeCompare; INTERNAL_DATA_FIELDS stripped; SCHEMA_VERSION = 2",
107
- "sha256": "25ece527ff5ea847f5863314a12c50c95a80f995d36d3aeb756ca022dd72f283"
107
+ "sha256": "72a28c1edcbcb209f74bd10a1a3691e25aaa848bac4bd54aceeacf8d415c8f13"
108
108
  },
109
109
  {
110
110
  "type": "source-evidence",
111
111
  "path": "src/commands/graph.mjs",
112
112
  "assertion": "computePolicyFingerprint produces SHA-256 of canonicalized policy",
113
- "sha256": "25ece527ff5ea847f5863314a12c50c95a80f995d36d3aeb756ca022dd72f283"
113
+ "sha256": "72a28c1edcbcb209f74bd10a1a3691e25aaa848bac4bd54aceeacf8d415c8f13"
114
114
  }
115
115
  ],
116
116
  "status": "proven"
@@ -248,7 +248,7 @@
248
248
  "type": "source-evidence",
249
249
  "path": "src/commands/context-command.mjs",
250
250
  "assertion": "coverage.notes warns that per-edge violations cover only depConstraints (3 of 15 violation types)",
251
- "sha256": "1f054400e864c381e65904bc20095f4eb6df3198b19ffcb985895af7ec31d80d"
251
+ "sha256": "f906671ff4a5720d80ea5a3dab2779f1cfd3648fbc1e6a1f3a6d0e6efef21feb"
252
252
  },
253
253
  {
254
254
  "type": "source-evidence",
@@ -286,7 +286,7 @@
286
286
  "type": "source-evidence",
287
287
  "path": "src/commands/graph.mjs",
288
288
  "assertion": "Plain string comparison throughout; never localeCompare",
289
- "sha256": "25ece527ff5ea847f5863314a12c50c95a80f995d36d3aeb756ca022dd72f283"
289
+ "sha256": "72a28c1edcbcb209f74bd10a1a3691e25aaa848bac4bd54aceeacf8d415c8f13"
290
290
  }
291
291
  ],
292
292
  "status": "proven"
@@ -312,7 +312,7 @@
312
312
  "type": "source-evidence",
313
313
  "path": "src/providers/moon.mjs",
314
314
  "assertion": "inferWorkspaceLayout returns null for partial layouts — same all-or-nothing contract as Nx and Native",
315
- "sha256": "f461cfaa2c8267a78fa9afa4b3267e92be4a85ff5f370b9e4b613a1fb85fc211"
315
+ "sha256": "2b874da56bda5bc6cfcfb4a984556ace0eafe3a6a98d964c78204bd96cc30e48"
316
316
  },
317
317
  {
318
318
  "type": "behavioral-test",
@@ -31,6 +31,7 @@ import { existsSync } from "node:fs";
31
31
  import { delimiter, join, posix } from "node:path";
32
32
 
33
33
  import { environmentForTree, runProcess } from "../process.mjs";
34
+ import { isEnoent } from "../errors.mjs";
34
35
  import { buildDependencies } from "./native/graph.mjs";
35
36
 
36
37
  /**
@@ -905,16 +906,15 @@ function resolveMoonCli(workspaceRoot, { resolveMoon = () => "moon" } = {}) {
905
906
  * `nxCli` guards against from the other direction, where only a
906
907
  * `MODULE_NOT_FOUND` earns the "not installed" story.
907
908
  *
908
- * `../process.mjs`'s `runProcess` wraps the child's failure and carries the
909
- * original on `cause`, so the code is read from there; the direct `code` is
910
- * read too, for a `run` seam that surfaces a spawn error unwrapped.
909
+ * The shape test itself is `../errors.mjs`'s `isEnoent` the name stays
910
+ * because what this file asks is not "is this ENOENT" but "does this failure
911
+ * carry an install action".
911
912
  *
912
913
  * @param {unknown} error
913
914
  * @returns {boolean}
914
915
  */
915
916
  function isMoonBinaryMissing(error) {
916
- const thrown = /** @type {{code?: unknown, cause?: {code?: unknown}}|null|undefined} */ (error);
917
- return thrown?.code === "ENOENT" || thrown?.cause?.code === "ENOENT";
917
+ return isEnoent(error);
918
918
  }
919
919
 
920
920
  /**
@@ -815,6 +815,36 @@ function annotatedByTable(suppressions, violation, now) {
815
815
  return violation;
816
816
  }
817
817
 
818
+ /**
819
+ * The suppression table applied to verdicts that did not come from
820
+ * `evaluateRun`'s site walk — suppressing rows remove, active waivers mark
821
+ * `waivedBy`, expired ones re-assert with `evidence`, exactly as the site walk
822
+ * applies them, because a verdict must ride the same table whichever walk
823
+ * produced it.
824
+ *
825
+ * The one caller is `./commands/check.mjs`'s markdown document track: its
826
+ * edges are judged per edge through `./edge-constraints.mjs`'s `judgeEdge` —
827
+ * they have no import site for `candidateGroupsFor` to walk, so there is no
828
+ * group chain, only a flat list — but a suppression row that names a document
829
+ * must silence the same verdict here it would silence on an import site, and
830
+ * a waiver over one must annotate it the same way. Unlike the site walk there
831
+ * is no second candidate group to fall through to: an edge's verdicts are all
832
+ * reported together, so a row that removes some leaves the rest standing
833
+ * rather than promoting anything.
834
+ *
835
+ * @param {object[]} suppressions The validated `boundarySuppressions` table.
836
+ * @param {object[]} violations The unfiltered verdicts, in walk order.
837
+ * @param {string} [now] Reference instant for waiver expiry; defaults to the
838
+ * shared governance clock, as `evaluateRun` does.
839
+ * @returns {object[]} The survivors, in input order, annotations applied.
840
+ */
841
+ export function applySuppressionTable(suppressions, violations, now = referenceTime()) {
842
+ if (suppressions.length === 0) return violations;
843
+ return violations
844
+ .filter((violation) => !removedByTable(suppressions, violation, now))
845
+ .map((violation) => annotatedByTable(suppressions, violation, now));
846
+ }
847
+
818
848
  /**
819
849
  * One run of the engine over every import site: the judged verdict per site
820
850
  * plus the raw superset it was picked from.
package/src/verdict.mjs CHANGED
@@ -61,6 +61,33 @@ export function coverageIncompleteReasons({ unchecked, blindSpots, analyzed }) {
61
61
  analyzed === 0 ? "no file in scope could be analyzed — coverage incomplete" : null,
62
62
  ].filter(Boolean);
63
63
  }
64
+
65
+ /**
66
+ * The one completeness predicate — the three coverage axes conjoined, the
67
+ * boolean twin of `coverageIncompleteReasons` directly above, which words the
68
+ * same axes as clauses. `coverageVerdict` (`./commands/coverage-verdict.mjs`)
69
+ * reads its `complete` from here, `verdictFor` reads the decision's
70
+ * `coverageComplete` from here, and `check`'s coverage block reads its
71
+ * `complete` from here — three faces of one claim, so the envelope's
72
+ * `coverage.complete` and its `decision.coverageComplete` cannot disagree
73
+ * about a run neither re-derives from the other.
74
+ *
75
+ * The counts come from the caller because a command's coverage universe is
76
+ * its own: `check`'s is wider than `commandContext.analysis` (the go.work and
77
+ * tsconfig whole-file failures it pushes, the accepted `coverage.unowned`
78
+ * files it withdraws), and each face feeds the counts it is a claim about. A
79
+ * caller that reads plain `commandContext.analysis` should call
80
+ * `coverageVerdict` instead — this predicate is the law's last step, not the
81
+ * place failure classes get decided (`./analysis/source-util.mjs`'s
82
+ * classifiers own that line).
83
+ *
84
+ * @param {{unchecked: number, blindSpotCount: number, analyzed: number}} counts
85
+ * @returns {boolean} Whether the run judged everything in its scope.
86
+ */
87
+ export function coverageComplete({ unchecked, blindSpotCount, analyzed }) {
88
+ return unchecked === 0 && blindSpotCount === 0 && analyzed > 0;
89
+ }
90
+
64
91
  /**
65
92
  * The one place that turns a run's counts into the verdict every format
66
93
  * agrees on. `runCheck` uses it for the process's exit code; `check` uses the
@@ -127,7 +154,11 @@ export function verdictFor({
127
154
  reasons: coverageReasons,
128
155
  decision: buildDecision({
129
156
  status: "findings",
130
- coverageComplete: unchecked === 0 && blindSpots === 0 && analyzed > 0,
157
+ // The one completeness predicate, not a restatement: the decision's
158
+ // `coverageComplete` and the envelope's `coverage.complete` are the
159
+ // same claim about the same counts (`check` feeds both from one
160
+ // object), so they read it from one expression.
161
+ coverageComplete: coverageComplete({ unchecked, blindSpotCount: blindSpots, analyzed }),
131
162
  findings:
132
163
  violations +
133
164
  declaredEdgeFindings +
@@ -188,7 +219,7 @@ export function verdictFor({
188
219
  reasons,
189
220
  decision: buildDecision({
190
221
  status: "no-verdict",
191
- coverageComplete: unchecked === 0 && blindSpots === 0 && analyzed > 0,
222
+ coverageComplete: coverageComplete({ unchecked, blindSpotCount: blindSpots, analyzed }),
192
223
  findings: 0,
193
224
  reason: reasons.join("; "),
194
225
  }),