@ecoma-io/archkeep 0.22.2 → 0.23.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 (50) 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/architecture-intent/judge.mjs +1 -1
  5. package/src/architecture-intent/model.mjs +1 -11
  6. package/src/commands/README.md +16 -7
  7. package/src/commands/change-intent.mjs +2 -11
  8. package/src/commands/check.mjs +69 -5
  9. package/src/commands/completeness.mjs +0 -32
  10. package/src/commands/context-command.mjs +1 -1
  11. package/src/commands/context.mjs +46 -47
  12. package/src/commands/diff.mjs +1 -1
  13. package/src/commands/discover.mjs +7 -3
  14. package/src/commands/evaluation-primitives.mjs +6 -2
  15. package/src/commands/impact-reachability.mjs +104 -0
  16. package/src/commands/impact.mjs +9 -71
  17. package/src/commands/scenario-evaluation.mjs +1 -1
  18. package/src/config.mjs +1 -15
  19. package/src/custom-rules/evidence.mjs +1 -1
  20. package/src/custom-rules/host.mjs +2 -2
  21. package/src/custom-rules/values.mjs +8 -3
  22. package/src/errors.mjs +1 -1
  23. package/src/fixtures/evolution-lifecycle/workspace.mjs +0 -5
  24. package/src/governance/adr-registry.mjs +31 -16
  25. package/src/governance/decision-graph.mjs +1 -1
  26. package/src/governance/evolution-store.mjs +33 -16
  27. package/src/governance/fitness-registry.mjs +1 -11
  28. package/src/governance/profile-registry.mjs +20 -23
  29. package/src/governance/provenance-record.mjs +1 -11
  30. package/src/governance/reconcile-score.mjs +0 -3
  31. package/src/governance/row-schema.mjs +1 -11
  32. package/src/governance/verdict.mjs +168 -4
  33. package/src/intent/intent-manifest.json +12 -12
  34. package/src/lsp/diagnose.mjs +2 -2
  35. package/src/lsp/server.mjs +1 -1
  36. package/src/lsp/workspace-index.mjs +3 -3
  37. package/src/options.mjs +1 -1
  38. package/src/providers/model-gate.mjs +59 -0
  39. package/src/providers/moon.mjs +1 -1
  40. package/src/providers/native/model.mjs +2 -16
  41. package/src/report/README.md +13 -7
  42. package/src/report/evidence.mjs +11 -168
  43. package/src/report/json.mjs +10 -7
  44. package/src/report/sarif.mjs +29 -4
  45. package/src/report/text.mjs +39 -0
  46. package/src/rules/README.md +18 -9
  47. package/src/{commands → rules}/edge-constraints.mjs +18 -12
  48. package/src/values.mjs +49 -0
  49. package/src/verdict.mjs +25 -5
  50. package/src/workspace.mjs +29 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Reverse reachability over the project graph — the impact *computation*.
3
+ *
4
+ * Given a project name, `computeImpact` lists every project that transitively
5
+ * depends on it — the set a developer needs to consider before changing that
6
+ * project. It is descriptive: it never exits anything, because a description
7
+ * of what depends on a project is never a finding.
8
+ *
9
+ * The result separates **direct** dependents (projects whose edges point
10
+ * straight at the target) from **transitive** ones (reachable only through
11
+ * another project), and the union of both is `dependents`. An empty
12
+ * `dependents` list is a claim — "nothing depends on this project" — not a
13
+ * shrug, and the reports built on it are worded that way so a reader never
14
+ * mistakes it for silence.
15
+ *
16
+ * This is a plain module rather than part of `./impact.mjs` on purpose: the
17
+ * canonical evaluator (`./evaluation-primitives.mjs`) needs the same
18
+ * reachability walk the `impact` command reports, and `./impact.mjs` drives
19
+ * `./impact-statement.mjs`, which drives that evaluator. Holding the walk in
20
+ * `./impact.mjs` made `evaluation-primitives → impact → impact-statement →
21
+ * evaluation-primitives` the engine's only import cycle (#644) — three
22
+ * modules forced to load together, with no dependency order left to reason
23
+ * about. Both sides import the walk from here instead:
24
+ * `impact → impact-reachability`, `evaluation-primitives →
25
+ * impact-reachability`, and the trio's remaining direction stays one-way.
26
+ * `./impact.mjs` re-exports `computeImpact` so its existing importers keep
27
+ * resolving.
28
+ *
29
+ * Like `./edge-constraints.mjs`, this is a shared computation layer: it holds
30
+ * no argv, prints nothing, and decides no exit code (`./README.md`).
31
+ */
32
+ import { UsageError } from "../errors.mjs";
33
+
34
+ /**
35
+ * Computes the impact set: every project that transitively depends on
36
+ * `projectName`.
37
+ *
38
+ * Builds a reverse adjacency map from the graph's `dependencies`, then walks
39
+ * it breadth-first starting from the target project. The walk does NOT include
40
+ * the target project itself in the dependent set — a project does not depend
41
+ * on itself — but the returned `dependents` array is the union of `direct`
42
+ * and `transitive`, and the report header names the target separately.
43
+ *
44
+ * @param {string} projectName The project whose impact is being queried.
45
+ * @param {object} graph The project graph: `{nodes, dependencies}`.
46
+ * @returns {{project: string, direct: string[], transitive: string[], dependents: string[]}}
47
+ * @throws {UsageError} when `projectName` is not in the graph.
48
+ */
49
+ export function computeImpact(projectName, graph) {
50
+ const nodes = graph.nodes;
51
+ const deps = graph.dependencies;
52
+
53
+ if (!Object.hasOwn(nodes, projectName)) {
54
+ throw new UsageError(
55
+ `archkeep: no project named '${projectName}' in the graph — ` +
56
+ `available projects: ${Object.keys(nodes)
57
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
58
+ .join(", ")}`,
59
+ );
60
+ }
61
+
62
+ // Build reverse adjacency: target → [sources that depend on it]
63
+ const reverseAdj = Object.create(null);
64
+ for (const source of Object.keys(deps)) {
65
+ if (!Object.hasOwn(deps, source)) continue;
66
+ const targets = deps[source];
67
+ for (const edge of targets) {
68
+ if (!Object.hasOwn(reverseAdj, edge.target)) {
69
+ reverseAdj[edge.target] = [];
70
+ }
71
+ reverseAdj[edge.target].push(source);
72
+ }
73
+ }
74
+
75
+ // Direct dependents: projects whose edges point straight at the target.
76
+ // Deduplicate (multiple edges between same pair are possible) and sort.
77
+ const directSet = new Set(reverseAdj[projectName] ?? []);
78
+ const direct = [...directSet].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
79
+
80
+ // BFS through reverse edges to find transitive dependents.
81
+ const visited = new Set(directSet);
82
+ const queue = [...directSet];
83
+ while (queue.length > 0) {
84
+ const current = queue.shift();
85
+ const parents = reverseAdj[current];
86
+ if (parents === undefined) continue;
87
+ for (const parent of parents) {
88
+ if (!visited.has(parent)) {
89
+ visited.add(parent);
90
+ queue.push(parent);
91
+ }
92
+ }
93
+ }
94
+
95
+ // Transitive dependents: reachable through another project, but not direct.
96
+ const transitive = [...visited]
97
+ .filter((name) => !directSet.has(name))
98
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
99
+
100
+ // All dependents: direct + transitive, sorted.
101
+ const dependents = [...visited].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
102
+
103
+ return { project: projectName, direct, transitive, dependents };
104
+ }
@@ -35,85 +35,23 @@
35
35
  * under project roots, `impact` refuses loudly rather than returning a result
36
36
  * whose dependents silently under-represent the real architecture.
37
37
  */
38
- import { UsageError } from "../errors.mjs";
39
- import { computeImpactConstraints } from "./edge-constraints.mjs";
38
+ import { computeImpactConstraints } from "../rules/edge-constraints.mjs";
40
39
  import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
41
40
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
42
41
  import { formatImpactReport } from "../report/impact-text.mjs";
43
42
  import { resolveProvenance } from "./provenance.mjs";
44
43
  import { composeImpactStatement } from "./impact-statement.mjs";
44
+ import { computeImpact } from "./impact-reachability.mjs";
45
45
 
46
46
  /**
47
- * Computes the impact set: every project that transitively depends on
48
- * `projectName`.
49
- *
50
- * Builds a reverse adjacency map from the graph's `dependencies`, then walks
51
- * it breadth-first starting from the target project. The walk does NOT include
52
- * the target project itself in the dependent set — a project does not depend
53
- * on itself — but the returned `dependents` array is the union of `direct`
54
- * and `transitive`, and the report header names the target separately.
55
- *
56
- * @param {string} projectName The project whose impact is being queried.
57
- * @param {object} graph The project graph: `{nodes, dependencies}`.
58
- * @returns {{project: string, direct: string[], transitive: string[], dependents: string[]}}
59
- * @throws {UsageError} when `projectName` is not in the graph.
47
+ * The reachability walk this command reports, shared with the canonical
48
+ * evaluator through `./impact-reachability.mjs` — held there, not here,
49
+ * because this module also drives `./impact-statement.mjs`, which drives that
50
+ * evaluator, and keeping the walk here is what closed the engine's only
51
+ * import cycle (#644). Re-exported so every importer of this module keeps
52
+ * resolving `computeImpact` from where it always has.
60
53
  */
61
- export function computeImpact(projectName, graph) {
62
- const nodes = graph.nodes;
63
- const deps = graph.dependencies;
64
-
65
- if (!Object.hasOwn(nodes, projectName)) {
66
- throw new UsageError(
67
- `archkeep: no project named '${projectName}' in the graph — ` +
68
- `available projects: ${Object.keys(nodes)
69
- .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
70
- .join(", ")}`,
71
- );
72
- }
73
-
74
- // Build reverse adjacency: target → [sources that depend on it]
75
- const reverseAdj = Object.create(null);
76
- for (const source of Object.keys(deps)) {
77
- if (!Object.hasOwn(deps, source)) continue;
78
- const targets = deps[source];
79
- for (const edge of targets) {
80
- if (!Object.hasOwn(reverseAdj, edge.target)) {
81
- reverseAdj[edge.target] = [];
82
- }
83
- reverseAdj[edge.target].push(source);
84
- }
85
- }
86
-
87
- // Direct dependents: projects whose edges point straight at the target.
88
- // Deduplicate (multiple edges between same pair are possible) and sort.
89
- const directSet = new Set(reverseAdj[projectName] ?? []);
90
- const direct = [...directSet].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
91
-
92
- // BFS through reverse edges to find transitive dependents.
93
- const visited = new Set(directSet);
94
- const queue = [...directSet];
95
- while (queue.length > 0) {
96
- const current = queue.shift();
97
- const parents = reverseAdj[current];
98
- if (parents === undefined) continue;
99
- for (const parent of parents) {
100
- if (!visited.has(parent)) {
101
- visited.add(parent);
102
- queue.push(parent);
103
- }
104
- }
105
- }
106
-
107
- // Transitive dependents: reachable through another project, but not direct.
108
- const transitive = [...visited]
109
- .filter((name) => !directSet.has(name))
110
- .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
111
-
112
- // All dependents: direct + transitive, sorted.
113
- const dependents = [...visited].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
114
-
115
- return { project: projectName, direct, transitive, dependents };
116
- }
54
+ export { computeImpact };
117
55
 
118
56
  /**
119
57
  * Runs the `impact` command: resolves the command context, checks the
@@ -32,7 +32,7 @@
32
32
  * @module
33
33
  */
34
34
  import { computeImpact } from "./impact.mjs";
35
- import { computeImpactConstraints } from "./edge-constraints.mjs";
35
+ import { computeImpactConstraints } from "../rules/edge-constraints.mjs";
36
36
  import {
37
37
  buildDecisionImpact,
38
38
  buildEvolutionAlignment,
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
@@ -934,13 +927,6 @@ function findCustomRuleViolations(list, io) {
934
927
  return violations;
935
928
  }
936
929
 
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
930
  /**
945
931
  * Everything wrong with a loaded boundary config, as messages; empty when it
946
932
  * is well-formed. Pure, so a test drives it without a file on disk.
@@ -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
@@ -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,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 { describe } from "../values.mjs";
94
95
 
95
96
  /** The directory, relative to a workspace root, where ADR files live. */
96
97
  export const ADR_DIR = "docs/adr";
@@ -137,13 +138,6 @@ const FRONTMATTER_KEYS = Object.freeze([
137
138
  "updated",
138
139
  ]);
139
140
 
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
141
  /** The `---`-delimited frontmatter block's text, or null when the file has none. */
148
142
  function frontmatterBlock(text) {
149
143
  if (!text.startsWith("---")) return null;
@@ -494,12 +488,17 @@ export function validateLineage(records) {
494
488
  * throws; the caller maps that to exit 3, never to an empty list.
495
489
  *
496
490
  * @param {string} root Absolute workspace root.
497
- * @param {{readdirSync?: (path: string) => string[], readFileSync?: (path: string, encoding: "utf8") => string,
491
+ * @param {{existsSync?: (path: string) => boolean, readdirSync?: (path: string) => string[],
492
+ * readFileSync?: (path: string, encoding: "utf8") => string,
498
493
  * lstatSync?: (path: string) => {isSymbolicLink: () => boolean}, realpathSync?: (path: string) => string,
499
494
  * 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
495
+ * Injectable filesystem seams, defaulting to `defaultAdrIo` (the sync
496
+ * `node:fs` calls this module uses) so the CLI stays event-loop-simple.
497
+ * Tests inject an in-memory tree.
498
+ * `existsSync` gates the containment probe below: an in-memory root does not
499
+ * exist on disk, and probing a nonexistent path's ancestry would walk up to
500
+ * a real parent and misread it as an escape.
501
+ * `tracked` is the `git ls-files` list (`../workspace.mjs`'s
503
502
  * `listTrackedFiles`); when provided, a directory entry whose `docs/adr/<name>`
504
503
  * path is not in it is excluded before it is ever validated — see this
505
504
  * module's header for why, and `../architecture-intent/model.mjs`'s
@@ -512,10 +511,11 @@ export function validateLineage(records) {
512
511
  * @throws {Error} on an unreadable registry.
513
512
  */
514
513
  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;
514
+ const dirExists = io.existsSync ?? defaultAdrIo.existsSync;
515
+ const readDir = io.readdirSync ?? defaultAdrIo.readdirSync;
516
+ const readFile = io.readFileSync ?? defaultAdrIo.readFileSync;
517
+ const lstat = io.lstatSync ?? defaultAdrIo.lstatSync;
518
+ const realpath = io.realpathSync ?? defaultAdrIo.realpathSync;
519
519
  const dir = join(root, ADR_DIR);
520
520
 
521
521
  /** @type {string[]} */
@@ -581,7 +581,7 @@ export function loadAdrRegistry(root, io = {}) {
581
581
  // drives is keyed by a fixture path that does not exist on disk, and
582
582
  // probing a nonexistent root's ancestry would walk up to a real parent
583
583
  // directory and misread it as an escape. Real roots only.
584
- existsSync(root) &&
584
+ dirExists(root) &&
585
585
  containmentViolation(root, filePath, { lstatSync: lstat, realpathSync: realpath }) !== null
586
586
  ) {
587
587
  continue;
@@ -774,3 +774,18 @@ export function unresolvedDecisionRefRows(rows, byId, knownFitness) {
774
774
  }
775
775
  return unresolved;
776
776
  }
777
+
778
+ /**
779
+ * The default io: the sync `node:fs` calls `loadAdrRegistry` makes. This is
780
+ * the only place in this module the filesystem is named directly — every
781
+ * function reads through an injected `io` whose missing seams fall back here,
782
+ * so a test drives an in-memory tree without mocking the fs module and the
783
+ * module body never touches the disk on its own.
784
+ */
785
+ const defaultAdrIo = Object.freeze({
786
+ existsSync,
787
+ lstatSync,
788
+ readdirSync,
789
+ readFileSync,
790
+ realpathSync,
791
+ });
@@ -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,13 +40,13 @@
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
 
@@ -205,13 +205,13 @@ function validateEventRecord(parsed, path) {
205
205
  export function writeEvent(dir, event, io = {}) {
206
206
  validateEventForWrite(event);
207
207
 
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;
208
+ const readDir = io.readdirSync ?? defaultEvolutionIo.readdirSync;
209
+ const readFile = io.readFileSync ?? defaultEvolutionIo.readFileSync;
210
+ const writeFile = io.writeFileSync ?? defaultEvolutionIo.writeFileSync;
211
+ const rename = io.renameSync ?? defaultEvolutionIo.renameSync;
212
+ const makeDir = io.mkdirSync ?? defaultEvolutionIo.mkdirSync;
213
+ const lstat = io.lstatSync ?? defaultEvolutionIo.lstatSync;
214
+ const realpath = io.realpathSync ?? defaultEvolutionIo.realpathSync;
215
215
 
216
216
  // Resolved once: the identical string feeds the containment check and the
217
217
  // actual write (`../containment.mjs`, "One contract binds the WRITE call
@@ -316,8 +316,8 @@ export function writeEvent(dir, event, io = {}) {
316
316
  * @throws {Error} on the first unreadable or malformed event file.
317
317
  */
318
318
  export function readEvents(dir, io = {}) {
319
- const readDir = io.readdirSync ?? defaultReaddir;
320
- const readFile = io.readFileSync ?? defaultReadFile;
319
+ const readDir = io.readdirSync ?? defaultEvolutionIo.readdirSync;
320
+ const readFile = io.readFileSync ?? defaultEvolutionIo.readFileSync;
321
321
 
322
322
  const dirAbs = resolve(dir);
323
323
 
@@ -360,3 +360,20 @@ export function readEvents(dir, io = {}) {
360
360
  }
361
361
  return events;
362
362
  }
363
+
364
+ /**
365
+ * The default io: the sync `node:fs` calls `writeEvent` and `readEvents` make.
366
+ * This is the only place in this module the filesystem is named directly —
367
+ * every store operation reads and writes through an injected `io` whose
368
+ * missing seams fall back here, so a test drives an in-memory store without
369
+ * mocking the fs module and the module body never touches the disk on its own.
370
+ */
371
+ const defaultEvolutionIo = Object.freeze({
372
+ lstatSync,
373
+ mkdirSync,
374
+ readdirSync,
375
+ readFileSync,
376
+ realpathSync,
377
+ renameSync,
378
+ writeFileSync,
379
+ });
@@ -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 {
@@ -87,17 +88,6 @@ const LAYER_DIRECTIONS = Object.freeze(["forbidden", "required"]);
87
88
  /** The one `toDependents` a `tag-conformance` row may carry. */
88
89
  const TAG_DEPENDENT_DIRECTIONS = Object.freeze(["only", "never"]);
89
90
 
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
91
  function unknownKeys(obj, allowed) {
102
92
  return Object.keys(obj).filter((key) => !allowed.includes(key));
103
93
  }
@@ -61,6 +61,7 @@
61
61
  import { readFileSync } from "node:fs";
62
62
 
63
63
  import { policyFrom } from "../config.mjs";
64
+ import { describe, isPlainObject } from "../values.mjs";
64
65
 
65
66
  /**
66
67
  * The top-level keys a profiles file may carry. `version` is checked AFTER
@@ -109,16 +110,6 @@ const BLOCK_KEYS = ["depConstraints", "moduleBoundaryOptions", "boundarySuppress
109
110
  */
110
111
  const NAME_PATTERN = /^[a-zA-Z0-9_-]+$/u;
111
112
 
112
- /** @type {(value: unknown) => value is Record<string, unknown>} */
113
- const isPlainObject = (value) =>
114
- typeof value === "object" && value !== null && !Array.isArray(value);
115
-
116
- function describe(value) {
117
- if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
118
- if (value === null) return "null";
119
- return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
120
- }
121
-
122
113
  /** A profile's declared block, kept ONLY for this command's own data. */
123
114
  export function listNames(registry) {
124
115
  return registry.profiles.map((profile) => profile.name);
@@ -304,23 +295,13 @@ export function resolveProfile(profiles, name, seen = new Set()) {
304
295
  * @param {string} path Absolute path of the profiles file.
305
296
  * @param {{readFile?: (path: string) => string|null}} [io] Injectable read,
306
297
  * the same seam `../../options.mjs`'s readers take; answers `null` when the
307
- * file is not there.
298
+ * file is not there. Defaults to `defaultProfileIo.readFile` — the sync
299
+ * `node:fs` read this module makes, the only place it touches the disk.
308
300
  * @returns {{profiles: object[]}}
309
301
  * @throws {Error} on a missing/unreadable/unparseable file, or on any
310
302
  * profile-registry or reference-graph defect.
311
303
  */
312
- export function loadProfileRegistry(
313
- path,
314
- {
315
- readFile = (p) => {
316
- try {
317
- return readFileSync(p, "utf8");
318
- } catch {
319
- return null;
320
- }
321
- },
322
- } = {},
323
- ) {
304
+ export function loadProfileRegistry(path, { readFile = defaultProfileIo.readFile } = {}) {
324
305
  const text = readFile(path);
325
306
  if (text === null) {
326
307
  throw new Error(`archkeep: cannot read profiles file ${path}`);
@@ -363,3 +344,19 @@ export function profilePolicy(registryPath, profileName, sourceLabel, io = {}) {
363
344
  const effective = resolveProfile(registry.profiles, profileName);
364
345
  return policyFrom(effective, `${sourceLabel} (profile "${profileName}")`);
365
346
  }
347
+
348
+ /**
349
+ * The default io: the sync `node:fs` read `loadProfileRegistry` makes, wrapped
350
+ * in the null-on-missing contract `../../options.mjs`'s readers share. This is
351
+ * the only place in this module the filesystem is named directly — a test
352
+ * injects a `readFile` and the module body never touches the disk on its own.
353
+ */
354
+ const defaultProfileIo = Object.freeze({
355
+ readFile: (p) => {
356
+ try {
357
+ return readFileSync(p, "utf8");
358
+ } catch {
359
+ return null;
360
+ }
361
+ },
362
+ });
@@ -49,6 +49,7 @@
49
49
 
50
50
  import { ADR_STATUSES } from "./adr-registry.mjs";
51
51
  import { clockViolations } from "./clock.mjs";
52
+ import { describe, isPlainObject } from "../values.mjs";
52
53
 
53
54
  /** The only keys a validated `origin` may carry. */
54
55
  export const ORIGIN_KEYS = Object.freeze(["by", "tool", "on"]);
@@ -63,17 +64,6 @@ export const ORIGIN_KEYS = Object.freeze(["by", "tool", "on"]);
63
64
  * `recordOrigin` produced it through the shared clock.
64
65
  */
65
66
 
66
- /** @type {(value: unknown) => value is Record<string, unknown>} */
67
- const isPlainObject = (value) =>
68
- value !== null && typeof value === "object" && !Array.isArray(value);
69
-
70
- /** A value's type, for an error message that shows what was actually there. */
71
- function describe(value) {
72
- if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
73
- if (value === null) return "null";
74
- return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
75
- }
76
-
77
67
  /**
78
68
  * Everything wrong with a raw `origin` record at READ time, as messages; empty
79
69
  * when it is well-formed. Shape only — an `on` committed in a declaration file
@@ -76,9 +76,6 @@ export const SEVERITY_ORDER = Object.freeze({
76
76
  unknown: Infinity,
77
77
  });
78
78
 
79
- /** A scored element's state, in the vocabulary the text and JSON reports share. */
80
- export const ELEMENT_STATES = Object.freeze(["match", "absent", "unexpected", "unknown"]);
81
-
82
79
  /**
83
80
  * A scored element.
84
81
  *
@@ -57,6 +57,7 @@
57
57
  */
58
58
 
59
59
  import { originViolations } from "./provenance-record.mjs";
60
+ import { describe, isPlainObject } from "../values.mjs";
60
61
 
61
62
  /** The shape of any `origin.on` producer. Re-exported for a row owner's own docs. */
62
63
  export { clockViolations as clockValidation } from "./clock.mjs";
@@ -85,17 +86,6 @@ export const GOVERNANCE_ROW_KEYS = Object.freeze([
85
86
  * @property {string[]} [fitnessBindings] Fitness ids this row is bound to.
86
87
  */
87
88
 
88
- /** @type {(value: unknown) => value is Record<string, unknown>} */
89
- const isPlainObject = (value) =>
90
- value !== null && typeof value === "object" && !Array.isArray(value);
91
-
92
- /** A value's type, for an error message that shows what was actually there. */
93
- function describe(value) {
94
- if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
95
- if (value === null) return "null";
96
- return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
97
- }
98
-
99
89
  /**
100
90
  * Everything wrong with a row's `rationale`, `decisionRef`, or
101
91
  * `fitnessBindings` — the three string-shaped governance keys. `origin` has