@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.
- package/package.json +40 -13
- package/src/analysis/jvm/packages.mjs +0 -17
- package/src/analysis/manifest-util.mjs +14 -5
- package/src/architecture-intent/judge.mjs +1 -1
- package/src/architecture-intent/model.mjs +1 -11
- package/src/commands/README.md +16 -7
- package/src/commands/change-intent.mjs +2 -11
- package/src/commands/check.mjs +69 -5
- package/src/commands/completeness.mjs +0 -32
- package/src/commands/context-command.mjs +1 -1
- package/src/commands/context.mjs +46 -47
- package/src/commands/diff.mjs +1 -1
- package/src/commands/discover.mjs +7 -3
- package/src/commands/evaluation-primitives.mjs +6 -2
- package/src/commands/impact-reachability.mjs +104 -0
- package/src/commands/impact.mjs +9 -71
- package/src/commands/scenario-evaluation.mjs +1 -1
- package/src/config.mjs +1 -15
- package/src/custom-rules/evidence.mjs +1 -1
- package/src/custom-rules/host.mjs +2 -2
- package/src/custom-rules/values.mjs +8 -3
- package/src/errors.mjs +1 -1
- package/src/fixtures/evolution-lifecycle/workspace.mjs +0 -5
- package/src/governance/adr-registry.mjs +31 -16
- package/src/governance/decision-graph.mjs +1 -1
- package/src/governance/evolution-store.mjs +33 -16
- package/src/governance/fitness-registry.mjs +1 -11
- package/src/governance/profile-registry.mjs +20 -23
- package/src/governance/provenance-record.mjs +1 -11
- package/src/governance/reconcile-score.mjs +0 -3
- package/src/governance/row-schema.mjs +1 -11
- package/src/governance/verdict.mjs +168 -4
- package/src/intent/intent-manifest.json +12 -12
- package/src/lsp/diagnose.mjs +2 -2
- package/src/lsp/server.mjs +1 -1
- package/src/lsp/workspace-index.mjs +3 -3
- package/src/options.mjs +1 -1
- package/src/providers/model-gate.mjs +59 -0
- package/src/providers/moon.mjs +1 -1
- package/src/providers/native/model.mjs +2 -16
- package/src/report/README.md +13 -7
- package/src/report/evidence.mjs +11 -168
- package/src/report/json.mjs +10 -7
- package/src/report/sarif.mjs +29 -4
- package/src/report/text.mjs +39 -0
- package/src/rules/README.md +18 -9
- package/src/{commands → rules}/edge-constraints.mjs +18 -12
- package/src/values.mjs +49 -0
- package/src/verdict.mjs +25 -5
- 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
|
+
}
|
package/src/commands/impact.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
48
|
-
* `
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
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 "
|
|
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 `../
|
|
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
|
|
729
|
-
*
|
|
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
|
-
|
|
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 {{
|
|
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
|
|
501
|
-
* module uses so the CLI stays event-loop-simple.
|
|
502
|
-
*
|
|
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
|
|
516
|
-
const
|
|
517
|
-
const
|
|
518
|
-
const
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 ??
|
|
209
|
-
const readFile = io.readFileSync ??
|
|
210
|
-
const writeFile = io.writeFileSync ??
|
|
211
|
-
const rename = io.renameSync ??
|
|
212
|
-
const makeDir = io.mkdirSync ??
|
|
213
|
-
const lstat = io.lstatSync ??
|
|
214
|
-
const realpath = io.realpathSync ??
|
|
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 ??
|
|
320
|
-
const readFile = io.readFileSync ??
|
|
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
|