@ecoma-io/archkeep 0.22.2 → 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.
Files changed (63) hide show
  1. package/package.json +40 -13
  2. package/src/analysis/jvm/packages.mjs +0 -17
  3. package/src/analysis/manifest-util.mjs +14 -5
  4. package/src/analysis/markdown.mjs +340 -0
  5. package/src/analysis/source-util.mjs +5 -4
  6. package/src/analysis/typescript.mjs +146 -0
  7. package/src/architecture-intent/judge.mjs +1 -1
  8. package/src/architecture-intent/model.mjs +3 -12
  9. package/src/commands/README.md +16 -7
  10. package/src/commands/change-intent.mjs +2 -11
  11. package/src/commands/check.mjs +234 -12
  12. package/src/commands/completeness.mjs +0 -32
  13. package/src/commands/context-command.mjs +13 -21
  14. package/src/commands/context.mjs +46 -47
  15. package/src/commands/coverage-verdict.mjs +12 -2
  16. package/src/commands/delta-snapshot.mjs +1 -5
  17. package/src/commands/diff.mjs +1 -1
  18. package/src/commands/discover.mjs +7 -3
  19. package/src/commands/evaluation-primitives.mjs +6 -2
  20. package/src/commands/explain.mjs +17 -20
  21. package/src/commands/graph.mjs +7 -0
  22. package/src/commands/health.mjs +4 -0
  23. package/src/commands/impact-reachability.mjs +104 -0
  24. package/src/commands/impact.mjs +9 -71
  25. package/src/commands/plan-context-command.mjs +5 -1
  26. package/src/commands/policy.mjs +4 -4
  27. package/src/commands/provenance.mjs +8 -2
  28. package/src/commands/scenario-evaluation.mjs +1 -1
  29. package/src/config.mjs +171 -17
  30. package/src/custom-rules/evidence.mjs +1 -1
  31. package/src/custom-rules/host.mjs +2 -2
  32. package/src/custom-rules/values.mjs +8 -3
  33. package/src/errors.mjs +24 -2
  34. package/src/eslint-config.mjs +2 -5
  35. package/src/fixtures/evolution-lifecycle/workspace.mjs +0 -5
  36. package/src/governance/adr-registry.mjs +33 -17
  37. package/src/governance/decision-graph.mjs +1 -1
  38. package/src/governance/evolution-store.mjs +36 -18
  39. package/src/governance/fitness-registry.mjs +1 -14
  40. package/src/governance/profile-registry.mjs +20 -23
  41. package/src/governance/provenance-record.mjs +1 -11
  42. package/src/governance/reconcile-score.mjs +0 -3
  43. package/src/governance/row-schema.mjs +1 -14
  44. package/src/governance/verdict.mjs +168 -4
  45. package/src/intent/intent-manifest.json +16 -16
  46. package/src/lsp/diagnose.mjs +2 -2
  47. package/src/lsp/server.mjs +1 -1
  48. package/src/lsp/workspace-index.mjs +3 -3
  49. package/src/options.mjs +1 -1
  50. package/src/providers/model-gate.mjs +59 -0
  51. package/src/providers/moon.mjs +6 -6
  52. package/src/providers/native/model.mjs +2 -16
  53. package/src/report/README.md +13 -7
  54. package/src/report/evidence.mjs +11 -168
  55. package/src/report/json.mjs +10 -7
  56. package/src/report/sarif.mjs +29 -4
  57. package/src/report/text.mjs +39 -0
  58. package/src/rules/README.md +18 -9
  59. package/src/{commands → rules}/edge-constraints.mjs +18 -12
  60. package/src/rules/index.mjs +30 -0
  61. package/src/values.mjs +49 -0
  62. package/src/verdict.mjs +58 -7
  63. package/src/workspace.mjs +29 -0
package/src/config.mjs CHANGED
@@ -125,6 +125,7 @@ import { pathToFileURL } from "node:url";
125
125
  import { containmentViolation, pathEscapes } from "./containment.mjs";
126
126
 
127
127
  import { loadEslintBoundaryConfig } from "./eslint-config.mjs";
128
+ import { describe, isPlainObject, isStringArray } from "./values.mjs";
128
129
  import { findFitnessViolations } from "./governance/fitness-registry.mjs";
129
130
  import { declaredFitnessNames, stripRuleFitnessPrefix } from "./governance/adr-registry.mjs";
130
131
  import { GOVERNANCE_ROW_KEYS, rowSchemaViolations } from "./governance/row-schema.mjs";
@@ -258,18 +259,10 @@ const ROW_LIST_MATCHERS = {
258
259
 
259
260
  const ROW_LIST_KEYS = Object.keys(ROW_LIST_MATCHERS);
260
261
 
261
- /** @type {(value: unknown) => value is string[]} */
262
- const isStringArray = (value) =>
263
- Array.isArray(value) && value.every((item) => typeof item === "string");
264
-
265
262
  /** @type {(value: unknown) => value is [string, string][]} */
266
263
  const isTagPairArray = (value) =>
267
264
  Array.isArray(value) && value.every((pair) => isStringArray(pair) && pair.length === 2);
268
265
 
269
- /** @type {(value: unknown) => value is Record<string, unknown>} */
270
- const isPlainObject = (value) =>
271
- typeof value === "object" && value !== null && !Array.isArray(value);
272
-
273
266
  /**
274
267
  * What is wrong with the entries of one string list, each message naming the
275
268
  * entry's own index so a long list points at the offender rather than at
@@ -646,6 +639,155 @@ function findCoverageViolations(value) {
646
639
  return violations;
647
640
  }
648
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
+
649
791
  /**
650
792
  * The grammar a custom rule's `name` is written in: lowercase letters and
651
793
  * digits, single `-` separators, nothing else.
@@ -934,13 +1076,6 @@ function findCustomRuleViolations(list, io) {
934
1076
  return violations;
935
1077
  }
936
1078
 
937
- /** A value's type, for an error message that shows what was actually there. */
938
- function describe(value) {
939
- if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
940
- if (value === null) return "null";
941
- return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
942
- }
943
-
944
1079
  /**
945
1080
  * Everything wrong with a loaded boundary config, as messages; empty when it
946
1081
  * is well-formed. Pure, so a test drives it without a file on disk.
@@ -959,6 +1094,7 @@ export function findBoundaryConfigViolations(module, io = {}) {
959
1094
  fitness,
960
1095
  customRules,
961
1096
  coverage,
1097
+ markdown,
962
1098
  } = module;
963
1099
  // F05: the resolution half of the governance block (`row-schema.mjs`'s
964
1100
  // `io.resolve`) was validator-only until now — no production caller passed
@@ -1014,6 +1150,19 @@ export function findBoundaryConfigViolations(module, io = {}) {
1014
1150
  violations.push(...findCoverageViolations(coverage));
1015
1151
  }
1016
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
+
1017
1166
  // Absent means "nothing is suppressed", which is the only default that fails
1018
1167
  // toward reporting — unlike the eight options above, where a missing value
1019
1168
  // would be a second copy of something ESLint also reads and this module has
@@ -1109,7 +1258,10 @@ export function findBoundaryConfigViolations(module, io = {}) {
1109
1258
  * `coverage.exempt` is that provider's one channel for the same decision —
1110
1259
  * and the refusal lives in `./commands/policy.mjs`'s `resolvePolicy` and
1111
1260
  * `./providers/native/model.mjs`'s inline-policy check, because only they
1112
- * 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.
1113
1265
  *
1114
1266
  * The name says `.json` and the list binds both file dialects: `loadModulePolicy`
1115
1267
  * runs the same check over an ES module's exports, which is what makes a
@@ -1123,6 +1275,7 @@ const JSON_POLICY_KEYS = [
1123
1275
  "fitness",
1124
1276
  "customRules",
1125
1277
  "coverage",
1278
+ "markdown",
1126
1279
  ];
1127
1280
 
1128
1281
  /**
@@ -1199,7 +1352,7 @@ export function policyKeyViolations(parsed, { allowSchema }) {
1199
1352
  * inline one.
1200
1353
  * @param {string[]} [extraViolations] Violations the caller already found that
1201
1354
  * `findBoundaryConfigViolations` does not check on its own.
1202
- * @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 }}
1203
1356
  * `fitness` and `customRules` are present only when the config declares
1204
1357
  * them — a workspace without one carries no key, the same "absent is a
1205
1358
  * decision" posture `cli.mjs`'s `check` uses for a missing
@@ -1223,6 +1376,7 @@ export function policyFrom(parsed, sourceLabel, extraViolations = []) {
1223
1376
  ...(parsed.fitness === undefined ? {} : { fitness: parsed.fitness }),
1224
1377
  ...(parsed.customRules === undefined ? {} : { customRules: parsed.customRules }),
1225
1378
  ...(parsed.coverage === undefined ? {} : { coverage: parsed.coverage }),
1379
+ ...(parsed.markdown === undefined ? {} : { markdown: parsed.markdown }),
1226
1380
  };
1227
1381
  }
1228
1382
 
@@ -21,7 +21,7 @@
21
21
  * from "the pipeline never collected any", so it answers `pass` for a reason
22
22
  * nobody earned. A throw is a bug in the caller that composed the bundle, not
23
23
  * a fact about the workspace, which is why it is an exception rather than a
24
- * violation list — the same posture `../report/evidence.mjs`'s `buildDecision`
24
+ * violation list — the same posture `../governance/verdict.mjs`'s `buildDecision`
25
25
  * takes when a verdict and its counts disagree.
26
26
  *
27
27
  * ## Byte-determinism, and the two orders that are NOT normalized here
@@ -725,8 +725,8 @@ export async function evaluateCustomRule({
725
725
  * What is wrong with a verdict, or `null`.
726
726
  *
727
727
  * The obligations are the four-state vocabulary's, not this host's invention
728
- * (`../governance/verdict.mjs` states I1–I5 and `../report/evidence.mjs`
729
- * enforces the same ones for a command's own decision): `fail` names what
728
+ * (`../governance/verdict.mjs` states I1–I5 and enforces them for a command's
729
+ * own decision through `buildDecision`): `fail` names what
730
730
  * failed, `pass` names nothing, `unknown` names why it could not tell,
731
731
  * `not_applicable` names why it did not apply. A rule that breaks one of them
732
732
  * has returned a shape, not a judgment — hence "hollow", and hence a refusal
@@ -16,11 +16,16 @@
16
16
  *
17
17
  * The rendering is fixed here and nowhere else: `an array of N`, `null`,
18
18
  * `undefined`, and otherwise the value's type followed by its JSON.
19
+ *
20
+ * The plain-object guard is defined one floor down, in `../values.mjs` — the
21
+ * package-wide vocabulary every layer's validators share — and re-exported
22
+ * here so this layer's importers keep their spelling. It cannot be defined
23
+ * here: `./host.mjs` imports `../config.mjs` and `../governance/verdict.mjs`,
24
+ * so a guard defined in this directory would sit above the modules that need
25
+ * to reach it.
19
26
  */
20
27
 
21
- /** @type {(value: unknown) => value is Record<string, any>} */
22
- export const isPlainObject = (value) =>
23
- value !== null && typeof value === "object" && !Array.isArray(value);
28
+ export { isPlainObject } from "../values.mjs";
24
29
 
25
30
  /** @type {(value: unknown) => boolean} */
26
31
  export const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
package/src/errors.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * `UsageError` covers exactly the refusals those regexes matched: a path
14
14
  * outside the workspace or matching no tracked file (`./workspace.mjs`'s
15
- * `selectFiles`), an unknown project name (`./commands/impact.mjs`'s
15
+ * `selectFiles`), an unknown project name (`./commands/impact-reachability.mjs`'s
16
16
  * `computeImpact`, `./commands/context-command.mjs`'s
17
17
  * `collectProjectContext`), and a malformed `file:line:column` site string
18
18
  * (`./commands/explain.mjs`'s `parseSite`). They are one mistake in four
@@ -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;
@@ -58,11 +58,6 @@ export function commit(root, message) {
58
58
  return git(root, "rev-parse", "HEAD").trim();
59
59
  }
60
60
 
61
- /** Resolves the current HEAD of the fixture. */
62
- export function headOf(root) {
63
- return git(root, "rev-parse", "HEAD").trim();
64
- }
65
-
66
61
  /**
67
62
  * Opens a brand-new throwaway native git workspace (never the repository's own
68
63
  * tree). `archkeep.json` declares two Go projects on two layers, exactly the
@@ -91,6 +91,8 @@ 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";
95
+ import { describe } from "../values.mjs";
94
96
 
95
97
  /** The directory, relative to a workspace root, where ADR files live. */
96
98
  export const ADR_DIR = "docs/adr";
@@ -137,13 +139,6 @@ const FRONTMATTER_KEYS = Object.freeze([
137
139
  "updated",
138
140
  ]);
139
141
 
140
- /** A value's type, for an error message that shows what was actually there. */
141
- function describe(value) {
142
- if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
143
- if (value === null) return "null";
144
- return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
145
- }
146
-
147
142
  /** The `---`-delimited frontmatter block's text, or null when the file has none. */
148
143
  function frontmatterBlock(text) {
149
144
  if (!text.startsWith("---")) return null;
@@ -494,12 +489,17 @@ export function validateLineage(records) {
494
489
  * throws; the caller maps that to exit 3, never to an empty list.
495
490
  *
496
491
  * @param {string} root Absolute workspace root.
497
- * @param {{readdirSync?: (path: string) => string[], readFileSync?: (path: string, encoding: "utf8") => string,
492
+ * @param {{existsSync?: (path: string) => boolean, readdirSync?: (path: string) => string[],
493
+ * readFileSync?: (path: string, encoding: "utf8") => string,
498
494
  * lstatSync?: (path: string) => {isSymbolicLink: () => boolean}, realpathSync?: (path: string) => string,
499
495
  * tracked?: string[]}} [io]
500
- * Injectable filesystem seams, defaulting to the sync `node:fs` calls this
501
- * module uses so the CLI stays event-loop-simple. Tests inject an in-memory
502
- * tree. `tracked` is the `git ls-files` list (`../workspace.mjs`'s
496
+ * Injectable filesystem seams, defaulting to `defaultAdrIo` (the sync
497
+ * `node:fs` calls this module uses) so the CLI stays event-loop-simple.
498
+ * Tests inject an in-memory tree.
499
+ * `existsSync` gates the containment probe below: an in-memory root does not
500
+ * exist on disk, and probing a nonexistent path's ancestry would walk up to
501
+ * a real parent and misread it as an escape.
502
+ * `tracked` is the `git ls-files` list (`../workspace.mjs`'s
503
503
  * `listTrackedFiles`); when provided, a directory entry whose `docs/adr/<name>`
504
504
  * path is not in it is excluded before it is ever validated — see this
505
505
  * module's header for why, and `../architecture-intent/model.mjs`'s
@@ -512,10 +512,11 @@ export function validateLineage(records) {
512
512
  * @throws {Error} on an unreadable registry.
513
513
  */
514
514
  export function loadAdrRegistry(root, io = {}) {
515
- const readDir = io.readdirSync ?? readdirSync;
516
- const readFile = io.readFileSync ?? readFileSync;
517
- const lstat = io.lstatSync ?? lstatSync;
518
- const realpath = io.realpathSync ?? realpathSync;
515
+ const dirExists = io.existsSync ?? defaultAdrIo.existsSync;
516
+ const readDir = io.readdirSync ?? defaultAdrIo.readdirSync;
517
+ const readFile = io.readFileSync ?? defaultAdrIo.readFileSync;
518
+ const lstat = io.lstatSync ?? defaultAdrIo.lstatSync;
519
+ const realpath = io.realpathSync ?? defaultAdrIo.realpathSync;
519
520
  const dir = join(root, ADR_DIR);
520
521
 
521
522
  /** @type {string[]} */
@@ -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));
@@ -581,7 +582,7 @@ export function loadAdrRegistry(root, io = {}) {
581
582
  // drives is keyed by a fixture path that does not exist on disk, and
582
583
  // probing a nonexistent root's ancestry would walk up to a real parent
583
584
  // directory and misread it as an escape. Real roots only.
584
- existsSync(root) &&
585
+ dirExists(root) &&
585
586
  containmentViolation(root, filePath, { lstatSync: lstat, realpathSync: realpath }) !== null
586
587
  ) {
587
588
  continue;
@@ -774,3 +775,18 @@ export function unresolvedDecisionRefRows(rows, byId, knownFitness) {
774
775
  }
775
776
  return unresolved;
776
777
  }
778
+
779
+ /**
780
+ * The default io: the sync `node:fs` calls `loadAdrRegistry` makes. This is
781
+ * the only place in this module the filesystem is named directly — every
782
+ * function reads through an injected `io` whose missing seams fall back here,
783
+ * so a test drives an in-memory tree without mocking the fs module and the
784
+ * module body never touches the disk on its own.
785
+ */
786
+ const defaultAdrIo = Object.freeze({
787
+ existsSync,
788
+ lstatSync,
789
+ readdirSync,
790
+ readFileSync,
791
+ realpathSync,
792
+ });
@@ -12,7 +12,7 @@
12
12
  * ## Descriptive by contract (Wave 2 scope)
13
13
  *
14
14
  * This module NEVER gates. It decides nothing about whether a finding IS one
15
- * (the rule that produced it owns that, `../report/evidence.mjs`'), never
15
+ * (the rule that produced it owns that, `./verdict.mjs`), never
16
16
  * changes `check`'s exit code, and never turns green or red on its own. It
17
17
  * reports the graph the registry and the caller's row/finding facts describe
18
18
  * — or, where a reference cannot resolve, names the gap. The `adr`/`report`
@@ -40,17 +40,18 @@
40
40
  */
41
41
 
42
42
  import {
43
- mkdirSync as defaultMkdir,
44
- readdirSync as defaultReaddir,
45
- readFileSync as defaultReadFile,
46
- renameSync as defaultRename,
47
- writeFileSync as defaultWriteFile,
48
- lstatSync as defaultLstat,
49
- realpathSync as defaultRealpath,
43
+ lstatSync,
44
+ mkdirSync,
45
+ readdirSync,
46
+ readFileSync,
47
+ realpathSync,
48
+ renameSync,
49
+ writeFileSync,
50
50
  } from "node:fs";
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,
@@ -205,13 +206,13 @@ function validateEventRecord(parsed, path) {
205
206
  export function writeEvent(dir, event, io = {}) {
206
207
  validateEventForWrite(event);
207
208
 
208
- const readDir = io.readdirSync ?? defaultReaddir;
209
- const readFile = io.readFileSync ?? defaultReadFile;
210
- const writeFile = io.writeFileSync ?? defaultWriteFile;
211
- const rename = io.renameSync ?? defaultRename;
212
- const makeDir = io.mkdirSync ?? defaultMkdir;
213
- const lstat = io.lstatSync ?? defaultLstat;
214
- const realpath = io.realpathSync ?? defaultRealpath;
209
+ const readDir = io.readdirSync ?? defaultEvolutionIo.readdirSync;
210
+ const readFile = io.readFileSync ?? defaultEvolutionIo.readFileSync;
211
+ const writeFile = io.writeFileSync ?? defaultEvolutionIo.writeFileSync;
212
+ const rename = io.renameSync ?? defaultEvolutionIo.renameSync;
213
+ const makeDir = io.mkdirSync ?? defaultEvolutionIo.mkdirSync;
214
+ const lstat = io.lstatSync ?? defaultEvolutionIo.lstatSync;
215
+ const realpath = io.realpathSync ?? defaultEvolutionIo.realpathSync;
215
216
 
216
217
  // Resolved once: the identical string feeds the containment check and the
217
218
  // actual write (`../containment.mjs`, "One contract binds the WRITE call
@@ -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);
@@ -316,8 +317,8 @@ export function writeEvent(dir, event, io = {}) {
316
317
  * @throws {Error} on the first unreadable or malformed event file.
317
318
  */
318
319
  export function readEvents(dir, io = {}) {
319
- const readDir = io.readdirSync ?? defaultReaddir;
320
- const readFile = io.readFileSync ?? defaultReadFile;
320
+ const readDir = io.readdirSync ?? defaultEvolutionIo.readdirSync;
321
+ const readFile = io.readFileSync ?? defaultEvolutionIo.readFileSync;
321
322
 
322
323
  const dirAbs = resolve(dir);
323
324
 
@@ -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 },
@@ -360,3 +361,20 @@ export function readEvents(dir, io = {}) {
360
361
  }
361
362
  return events;
362
363
  }
364
+
365
+ /**
366
+ * The default io: the sync `node:fs` calls `writeEvent` and `readEvents` make.
367
+ * This is the only place in this module the filesystem is named directly —
368
+ * every store operation reads and writes through an injected `io` whose
369
+ * missing seams fall back here, so a test drives an in-memory store without
370
+ * mocking the fs module and the module body never touches the disk on its own.
371
+ */
372
+ const defaultEvolutionIo = Object.freeze({
373
+ lstatSync,
374
+ mkdirSync,
375
+ readdirSync,
376
+ readFileSync,
377
+ realpathSync,
378
+ renameSync,
379
+ writeFileSync,
380
+ });
@@ -50,6 +50,7 @@
50
50
  import { isValidSelector, resolveMembers } from "../architecture-intent/selectors.mjs";
51
51
  import { languageOf } from "../analysis/registry.mjs";
52
52
  import { canonicalizeJson } from "../canonical.mjs";
53
+ import { describe, isPlainObject } from "../values.mjs";
53
54
  import { GOVERNANCE_ROW_KEYS, rowSchemaViolations } from "./row-schema.mjs";
54
55
  import { fitnessVerdict, isVerdict } from "./verdict.mjs";
55
56
  import {
@@ -62,9 +63,6 @@ import {
62
63
  tagConformance,
63
64
  } from "./fitness-rules.mjs";
64
65
 
65
- /** The one `fitness` list key in the boundary config. */
66
- export const FITNESS_KEY = "fitness";
67
-
68
66
  /** The condition types the registry can evaluate. */
69
67
  export const CONDITION_TYPES = Object.freeze([
70
68
  "cycle-free",
@@ -87,17 +85,6 @@ const LAYER_DIRECTIONS = Object.freeze(["forbidden", "required"]);
87
85
  /** The one `toDependents` a `tag-conformance` row may carry. */
88
86
  const TAG_DEPENDENT_DIRECTIONS = Object.freeze(["only", "never"]);
89
87
 
90
- /** @type {(value: unknown) => value is Record<string, unknown>} */
91
- const isPlainObject = (value) =>
92
- value !== null && typeof value === "object" && !Array.isArray(value);
93
-
94
- /** A value's type, for an error message that shows what was actually there. */
95
- function describe(value) {
96
- if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
97
- if (value === null) return "null";
98
- return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
99
- }
100
-
101
88
  function unknownKeys(obj, allowed) {
102
89
  return Object.keys(obj).filter((key) => !allowed.includes(key));
103
90
  }