@ecoma-io/archkeep 0.23.0 → 0.24.1
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 +1 -1
- package/src/analysis/csharp.mjs +3 -1
- package/src/analysis/dotnet/csproj.mjs +5 -1
- package/src/analysis/dotnet/namespaces.mjs +1 -0
- package/src/analysis/go.mjs +6 -0
- package/src/analysis/java.mjs +2 -0
- package/src/analysis/jvm/gradle.mjs +3 -1
- package/src/analysis/jvm/maven.mjs +6 -1
- package/src/analysis/jvm/packages.mjs +1 -0
- package/src/analysis/jvm/resolve.mjs +4 -2
- package/src/analysis/kotlin.mjs +2 -0
- package/src/analysis/markdown.mjs +341 -0
- package/src/analysis/python.mjs +8 -0
- package/src/analysis/rust.mjs +5 -1
- package/src/analysis/source-util.mjs +6 -5
- package/src/analysis/typescript.mjs +148 -0
- package/src/architecture-intent/model.mjs +13 -8
- package/src/architecture-intent/selectors.mjs +2 -1
- package/src/commands/change-intent.mjs +10 -9
- package/src/commands/change.mjs +17 -1
- package/src/commands/check.mjs +177 -17
- package/src/commands/completeness.mjs +7 -6
- package/src/commands/context-command.mjs +12 -20
- package/src/commands/coverage-acceptance.mjs +46 -0
- package/src/commands/coverage-verdict.mjs +12 -2
- package/src/commands/custom-rules.mjs +1 -0
- package/src/commands/delta-classify.mjs +3 -0
- package/src/commands/delta-snapshot.mjs +3 -6
- package/src/commands/delta.mjs +35 -21
- package/src/commands/drift.mjs +1 -1
- package/src/commands/evaluation-primitives.mjs +4 -4
- package/src/commands/evolution.mjs +2 -0
- package/src/commands/explain.mjs +19 -20
- package/src/commands/graph.mjs +44 -15
- package/src/commands/health.mjs +4 -0
- package/src/commands/history.mjs +2 -0
- package/src/commands/plan-context-command.mjs +9 -2
- package/src/commands/policy.mjs +8 -5
- package/src/commands/provenance.mjs +8 -2
- package/src/commands/scenario-evaluation.mjs +1 -1
- package/src/commands/trajectory.mjs +2 -1
- package/src/config.mjs +170 -2
- package/src/custom-rules/host.mjs +3 -3
- package/src/errors.mjs +23 -1
- package/src/eslint-config.mjs +3 -5
- package/src/fixtures/evolution-lifecycle/workspace.mjs +15 -4
- package/src/go-work.mjs +1 -1
- package/src/governance/adr-registry.mjs +6 -2
- package/src/governance/debt-ledger.mjs +1 -1
- package/src/governance/decision-fitness.mjs +2 -0
- package/src/governance/decision-graph.mjs +1 -0
- package/src/governance/discovery-proposal.mjs +8 -2
- package/src/governance/evolution-event.mjs +42 -0
- package/src/governance/evolution-store.mjs +3 -2
- package/src/governance/fitness-registry.mjs +2 -3
- package/src/governance/preset-fingerprints.json +14 -14
- package/src/governance/profile-registry.mjs +22 -3
- package/src/governance/provenance-record.mjs +4 -1
- package/src/governance/reconcile-score.mjs +4 -0
- package/src/governance/row-schema.mjs +1 -3
- package/src/governance/verdict.mjs +1 -0
- package/src/governance/waiver.mjs +1 -0
- package/src/intent/intent-manifest.json +6 -6
- package/src/intent/mask-non-code.mjs +1 -0
- package/src/lsp/diagnostics.mjs +3 -2
- package/src/lsp/protocol.mjs +2 -1
- package/src/lsp/server.mjs +3 -0
- package/src/lsp/workspace-index.mjs +3 -1
- package/src/providers/moon.mjs +5 -5
- package/src/providers/native/differential.fixtures.mjs +29 -11
- package/src/providers/native/index.mjs +2 -1
- package/src/providers/native/model.mjs +4 -0
- package/src/report/envelope-shape.mjs +2 -0
- package/src/report/sarif.mjs +21 -8
- package/src/report/snapshot-text.mjs +3 -3
- package/src/report/text.mjs +10 -2
- package/src/rules/index.mjs +30 -0
- package/src/rules/match.mjs +7 -5
- package/src/rules/specifiers.mjs +2 -0
- package/src/rules/tags.mjs +3 -2
- package/src/rules/topology.mjs +6 -1
- package/src/verdict.mjs +33 -2
- package/src/workspace.mjs +1 -0
package/src/errors.mjs
CHANGED
|
@@ -23,7 +23,10 @@
|
|
|
23
23
|
* (`../AGENTS.md`, check's four exit codes).
|
|
24
24
|
*
|
|
25
25
|
* One class, nothing else exported: a second class needs a catch site that
|
|
26
|
-
* treats two of these mistakes differently, and none does.
|
|
26
|
+
* treats two of these mistakes differently, and none does. Beside it sits the
|
|
27
|
+
* one error-SHAPE predicate the engine shares (`isEnoent` below) — a test of
|
|
28
|
+
* what a caught value looks like, not a decision about what one means, which
|
|
29
|
+
* is why it lives with the error primitives rather than at any catch site.
|
|
27
30
|
*/
|
|
28
31
|
export class UsageError extends Error {
|
|
29
32
|
/**
|
|
@@ -34,3 +37,22 @@ export class UsageError extends Error {
|
|
|
34
37
|
this.name = "UsageError";
|
|
35
38
|
}
|
|
36
39
|
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Is this caught value the filesystem's "no such file or directory"?
|
|
43
|
+
*
|
|
44
|
+
* Node's `fs` throws the raw error with `code: "ENOENT"` set directly;
|
|
45
|
+
* `./process.mjs`'s `runProcess` wraps its child's failure and carries the
|
|
46
|
+
* original on `cause` — so the shape arrives both ways, and every site that
|
|
47
|
+
* distinguishes "absent" from "could not read" was spelling the test by hand
|
|
48
|
+
* (`#652`). The two legs together are the one definition; a catch site adds
|
|
49
|
+
* its own meaning on top (absent store, absent registry, install Moon), which
|
|
50
|
+
* is exactly the logic this predicate does not own.
|
|
51
|
+
*
|
|
52
|
+
* @param {unknown} error The caught value, of any shape.
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
export function isEnoent(error) {
|
|
56
|
+
const thrown = /** @type {{code?: unknown, cause?: {code?: unknown}}|null|undefined} */ (error);
|
|
57
|
+
return thrown?.code === "ENOENT" || thrown?.cause?.code === "ENOENT";
|
|
58
|
+
}
|
package/src/eslint-config.mjs
CHANGED
|
@@ -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;
|
|
@@ -238,6 +235,7 @@ function parseRuleValue(value, index) {
|
|
|
238
235
|
* severity-only fallback has been applied.
|
|
239
236
|
*/
|
|
240
237
|
export function extractBoundaryRule(flatConfig, readerOptions = {}) {
|
|
238
|
+
// used by its own test
|
|
241
239
|
const pathScopedMode = readerOptions.pathScoped ?? "refuse";
|
|
242
240
|
if (pathScopedMode !== "refuse" && pathScopedMode !== "bind-tree-wide") {
|
|
243
241
|
throw new Error(
|
|
@@ -36,6 +36,7 @@ const IDENTITY = ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpg
|
|
|
36
36
|
* than blocking the worker thread forever.
|
|
37
37
|
*/
|
|
38
38
|
export function git(cwd, ...args) {
|
|
39
|
+
// used by its own test
|
|
39
40
|
return execFileSync("git", args, {
|
|
40
41
|
cwd,
|
|
41
42
|
env: environmentForTree(),
|
|
@@ -47,12 +48,14 @@ export function git(cwd, ...args) {
|
|
|
47
48
|
|
|
48
49
|
/** Writes `text` to `root/relativePath`, creating parent directories. */
|
|
49
50
|
export function writeIn(root, relativePath, text) {
|
|
51
|
+
// used by its own test
|
|
50
52
|
mkdirSync(join(root, relativePath, ".."), { recursive: true });
|
|
51
53
|
writeFileSync(join(root, relativePath), text);
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
/** Stages every change and commits with the fixture identity; returns the SHA. */
|
|
55
57
|
export function commit(root, message) {
|
|
58
|
+
// used by its own test
|
|
56
59
|
git(root, ...IDENTITY, "add", "-A");
|
|
57
60
|
git(root, ...IDENTITY, "commit", "-q", "-m", message);
|
|
58
61
|
return git(root, "rev-parse", "HEAD").trim();
|
|
@@ -109,12 +112,12 @@ const OPTIONS = `export const moduleBoundaryOptions = {
|
|
|
109
112
|
};
|
|
110
113
|
`;
|
|
111
114
|
|
|
112
|
-
export const ALPHA_CLEAN = `package alpha
|
|
115
|
+
export const ALPHA_CLEAN = `package alpha // used by its own test
|
|
113
116
|
|
|
114
117
|
func Name() string { return "alpha" }
|
|
115
118
|
`;
|
|
116
119
|
|
|
117
|
-
export const ALPHA_REACHING = `package alpha
|
|
120
|
+
export const ALPHA_REACHING = `package alpha // used by its own test
|
|
118
121
|
|
|
119
122
|
import (
|
|
120
123
|
"example.com/beta"
|
|
@@ -123,7 +126,7 @@ import (
|
|
|
123
126
|
func Name() string { return "alpha" + beta.Suffix() }
|
|
124
127
|
`;
|
|
125
128
|
|
|
126
|
-
export const BETA = `package beta
|
|
129
|
+
export const BETA = `package beta // used by its own test
|
|
127
130
|
|
|
128
131
|
func Suffix() string { return "-beta" }
|
|
129
132
|
`;
|
|
@@ -136,6 +139,7 @@ func Suffix() string { return "-beta" }
|
|
|
136
139
|
* @param {{rows?: string, fitness?: string}} [law]
|
|
137
140
|
*/
|
|
138
141
|
export function writeLaw(root, { rows = "", fitness } = {}) {
|
|
142
|
+
// used by its own test
|
|
139
143
|
writeIn(
|
|
140
144
|
root,
|
|
141
145
|
"module-boundaries.config.mjs",
|
|
@@ -149,7 +153,7 @@ export function writeLaw(root, { rows = "", fitness } = {}) {
|
|
|
149
153
|
* evolution CLI integration fixtures use, so an allowed alpha→beta edge never
|
|
150
154
|
* trips a boundary rule.
|
|
151
155
|
*/
|
|
152
|
-
export const ALLOW_A_TO_B = ` { sourceTag: "layer:a", onlyDependOnLibsWithTags: ["layer:b"] },`;
|
|
156
|
+
export const ALLOW_A_TO_B = ` { sourceTag: "layer:a", onlyDependOnLibsWithTags: ["layer:b"] },`; // used by its own test
|
|
153
157
|
|
|
154
158
|
/**
|
|
155
159
|
* Writes `architecture-intent.json` at `root`. `sections` carries the top-level
|
|
@@ -157,6 +161,7 @@ export const ALLOW_A_TO_B = ` { sourceTag: "layer:a", onlyDependOnLibsWithTags:
|
|
|
157
161
|
* `dependencies`, …); `version` defaults to "1".
|
|
158
162
|
*/
|
|
159
163
|
export function writeIntent(root, sections) {
|
|
164
|
+
// used by its own test
|
|
160
165
|
writeIn(root, "architecture-intent.json", `${JSON.stringify(sections, null, 2)}\n`);
|
|
161
166
|
}
|
|
162
167
|
|
|
@@ -165,6 +170,7 @@ export function writeIntent(root, sections) {
|
|
|
165
170
|
* `record` is the frontmatter map (`{id, status, supersedes?, bindings?}`).
|
|
166
171
|
*/
|
|
167
172
|
export function writeAdr(root, filename, record) {
|
|
173
|
+
// used by its own test
|
|
168
174
|
const lines = ["---", `id: ${record.id}`, `status: ${record.status}`];
|
|
169
175
|
if (record.supersedes?.length) {
|
|
170
176
|
lines.push("supersedes:");
|
|
@@ -184,6 +190,7 @@ export function writeAdr(root, filename, record) {
|
|
|
184
190
|
* (`../cli.mjs`), never a shell-out to a binary named `archkeep`.
|
|
185
191
|
*/
|
|
186
192
|
export async function runEvolution(cwd, argv) {
|
|
193
|
+
// used by its own test
|
|
187
194
|
const out = [];
|
|
188
195
|
const err = [];
|
|
189
196
|
const exitCode = await runCli(argv, {
|
|
@@ -201,6 +208,7 @@ export async function runEvolution(cwd, argv) {
|
|
|
201
208
|
* @param {{head?: string, eventOut?: string, format?: string}} [options]
|
|
202
209
|
*/
|
|
203
210
|
export function evolutionArgs(base, { head, eventOut, format = "json" } = {}) {
|
|
211
|
+
// used by its own test
|
|
204
212
|
const args = ["evolution", "--base", base];
|
|
205
213
|
if (head) args.push("--head", head);
|
|
206
214
|
if (eventOut) args.push("--event-out", eventOut);
|
|
@@ -212,6 +220,7 @@ export function evolutionArgs(base, { head, eventOut, format = "json" } = {}) {
|
|
|
212
220
|
* Parses the `--format json` envelope out of a successful evolution run.
|
|
213
221
|
*/
|
|
214
222
|
export function parseEnvelope(run) {
|
|
223
|
+
// used by its own test
|
|
215
224
|
if (run.exitCode !== EXIT.ok) throw new Error(`evolution exited ${run.exitCode}: ${run.err}`);
|
|
216
225
|
return JSON.parse(run.out);
|
|
217
226
|
}
|
|
@@ -226,6 +235,7 @@ export function readEvents(dir) {
|
|
|
226
235
|
|
|
227
236
|
/** The event store's file names in `dir`, in filename order. */
|
|
228
237
|
export function eventFiles(dir) {
|
|
238
|
+
// used by its own test
|
|
229
239
|
return readdirSync(dir)
|
|
230
240
|
.filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp"))
|
|
231
241
|
.sort();
|
|
@@ -233,5 +243,6 @@ export function eventFiles(dir) {
|
|
|
233
243
|
|
|
234
244
|
/** Removes a throwaway workspace. */
|
|
235
245
|
export function dispose(root) {
|
|
246
|
+
// used by its own test
|
|
236
247
|
rmSync(root, { recursive: true, force: true });
|
|
237
248
|
}
|
package/src/go-work.mjs
CHANGED
|
@@ -113,7 +113,7 @@ const IDENTIFIER_BOUNDARY = new Set([" ", "\t", "\r", '"', "`", "(", ")"]);
|
|
|
113
113
|
* @throws {Error} on an unterminated string — a string left open would
|
|
114
114
|
* otherwise swallow the rest of the line silently.
|
|
115
115
|
*/
|
|
116
|
-
|
|
116
|
+
function tokenizeGoWorkLine(line, lineNumber) {
|
|
117
117
|
const tokens = [];
|
|
118
118
|
let at = 0;
|
|
119
119
|
while (at < line.length) {
|
|
@@ -91,6 +91,7 @@ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "
|
|
|
91
91
|
import { join } from "node:path";
|
|
92
92
|
|
|
93
93
|
import { containmentViolation } from "../containment.mjs";
|
|
94
|
+
import { isEnoent } from "../errors.mjs";
|
|
94
95
|
import { describe } from "../values.mjs";
|
|
95
96
|
|
|
96
97
|
/** The directory, relative to a workspace root, where ADR files live. */
|
|
@@ -123,7 +124,7 @@ export function hasAuthority(status) {
|
|
|
123
124
|
* format outgrows 999 records without breaking; the slug is dash-separated
|
|
124
125
|
* lowercase words.
|
|
125
126
|
*/
|
|
126
|
-
|
|
127
|
+
const ADR_FILE_PATTERN = /^(\d{3,})-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$/u;
|
|
127
128
|
|
|
128
129
|
/** An ADR id — `NNN-slug` — must match the filename it lives in. */
|
|
129
130
|
export const ADR_ID_PATTERN = /^\d{3,}-[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
@@ -235,6 +236,7 @@ function parseProseFields(body) {
|
|
|
235
236
|
* that already appears earlier in the same block.
|
|
236
237
|
*/
|
|
237
238
|
export function parseFrontmatterFields(text, at) {
|
|
239
|
+
// used by its own test
|
|
238
240
|
/** @type {Record<string, string|string[]|undefined>} */
|
|
239
241
|
const fields = {};
|
|
240
242
|
let currentList = null;
|
|
@@ -306,6 +308,7 @@ function toList(value) {
|
|
|
306
308
|
* @throws {Error} naming every violation at once.
|
|
307
309
|
*/
|
|
308
310
|
export function validateRecord({ id, frontmatter, body = "" }) {
|
|
311
|
+
// used by its own test
|
|
309
312
|
const fields = frontmatter === null ? {} : parseFrontmatterFields(frontmatter, id);
|
|
310
313
|
const violations = [];
|
|
311
314
|
|
|
@@ -399,6 +402,7 @@ export function validateRecord({ id, frontmatter, body = "" }) {
|
|
|
399
402
|
* @throws {Error} naming every lineage violation at once.
|
|
400
403
|
*/
|
|
401
404
|
export function validateLineage(records) {
|
|
405
|
+
// used by its own test
|
|
402
406
|
const byId = new Map(records.map((record) => [record.id, record]));
|
|
403
407
|
/** @type {Map<string, string[]>} */
|
|
404
408
|
const supersededBy = new Map(records.map((record) => [record.id, []]));
|
|
@@ -538,7 +542,7 @@ export function loadAdrRegistry(root, io = {}) {
|
|
|
538
542
|
// deliberately silent, for the reason this module's header states.
|
|
539
543
|
names = readDir(dir);
|
|
540
544
|
} catch (cause) {
|
|
541
|
-
if (cause
|
|
545
|
+
if (isEnoent(cause)) return { records: [], byId: new Map() };
|
|
542
546
|
throw new Error(`archkeep: cannot read ${ADR_DIR}: ${cause?.message ?? cause}`, { cause });
|
|
543
547
|
}
|
|
544
548
|
names.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
@@ -143,7 +143,7 @@ function owningProjectForPath(path, byName) {
|
|
|
143
143
|
* @param {string} source The entry's `source` (its keying field).
|
|
144
144
|
* @returns {string} The stable hex id.
|
|
145
145
|
*/
|
|
146
|
-
|
|
146
|
+
function entryId(kind, source) {
|
|
147
147
|
const semanticKind = kind === "expired-waiver" ? "waiver" : kind;
|
|
148
148
|
return createHash("sha256")
|
|
149
149
|
.update(canonicalizeJson({ kind: semanticKind, source }))
|
|
@@ -61,6 +61,7 @@ import { hasAuthority } from "./adr-registry.mjs";
|
|
|
61
61
|
|
|
62
62
|
/** The closed set of per-decision fitness levels `computeDecisionFitness` emits. */
|
|
63
63
|
export const DECISION_FITNESS_LEVELS = Object.freeze([
|
|
64
|
+
// used by its own test
|
|
64
65
|
"enforced",
|
|
65
66
|
"partially-enforced",
|
|
66
67
|
"violated",
|
|
@@ -70,6 +71,7 @@ export const DECISION_FITNESS_LEVELS = Object.freeze([
|
|
|
70
71
|
|
|
71
72
|
/** Whether a level names a red (never-healthy) direction. */
|
|
72
73
|
export function isRedDirection(level) {
|
|
74
|
+
// used by its own test
|
|
73
75
|
return level === "violated" || level === "unverifiable";
|
|
74
76
|
}
|
|
75
77
|
|
|
@@ -382,6 +382,7 @@ export function forwardDecision(decisionId, ctx) {
|
|
|
382
382
|
* @returns {GraphWalk}
|
|
383
383
|
*/
|
|
384
384
|
export function reverseRow(rowRef, ctx) {
|
|
385
|
+
// used by its own test
|
|
385
386
|
const g = newWalk();
|
|
386
387
|
|
|
387
388
|
const row = ctx.rows.find((candidate) => candidate.id === rowRef);
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
* The uncertainty marker vocabulary — three values, the bound the "bounded
|
|
62
62
|
* uncertainty markers" test asserts.
|
|
63
63
|
*/
|
|
64
|
-
export const CONFIDENCE = Object.freeze(["high", "medium", "low"]);
|
|
64
|
+
export const CONFIDENCE = Object.freeze(["high", "medium", "low"]); // used by its own test
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
67
|
* The component model: every project's root's first path segment (`""` at the
|
|
@@ -73,6 +73,7 @@ export const CONFIDENCE = Object.freeze(["high", "medium", "low"]);
|
|
|
73
73
|
* @returns {Map<string, {name: string, root: string, tags: string[]}[]>}
|
|
74
74
|
*/
|
|
75
75
|
export function componentsByDirectory(projects) {
|
|
76
|
+
// used by its own test
|
|
76
77
|
const buckets = new Map();
|
|
77
78
|
for (const project of projects) {
|
|
78
79
|
const component = project.root === "" ? "" : project.root.split("/")[0];
|
|
@@ -101,6 +102,7 @@ export function componentsByDirectory(projects) {
|
|
|
101
102
|
* @returns {{tag: string, component: string, members: string[]}[]} Sorted.
|
|
102
103
|
*/
|
|
103
104
|
export function dominantTags(components) {
|
|
105
|
+
// used by its own test
|
|
104
106
|
const tags = [];
|
|
105
107
|
for (const [component, members] of components) {
|
|
106
108
|
if (members.length < 2) continue;
|
|
@@ -136,6 +138,7 @@ export function dominantTags(components) {
|
|
|
136
138
|
* @returns {{axis: string, values: string[]}[]} Sorted by axis.
|
|
137
139
|
*/
|
|
138
140
|
export function tagAxes(projects) {
|
|
141
|
+
// used by its own test
|
|
139
142
|
const byAxis = new Map();
|
|
140
143
|
for (const project of projects) {
|
|
141
144
|
for (const tag of project.tags) {
|
|
@@ -169,7 +172,7 @@ export function tagAxes(projects) {
|
|
|
169
172
|
* @param {string} target
|
|
170
173
|
* @returns {boolean}
|
|
171
174
|
*/
|
|
172
|
-
|
|
175
|
+
function sameComponent(components, source, target) {
|
|
173
176
|
for (const members of components.values()) {
|
|
174
177
|
if (members.some((m) => m.name === source) && members.some((m) => m.name === target)) {
|
|
175
178
|
return true;
|
|
@@ -198,6 +201,7 @@ export function sameComponent(components, source, target) {
|
|
|
198
201
|
* component?: string, evidence: object[], confidence: string}[]} Sorted.
|
|
199
202
|
*/
|
|
200
203
|
export function boundaryAssertions({ projects, edges }) {
|
|
204
|
+
// used by its own test
|
|
201
205
|
const components = componentsByDirectory(projects);
|
|
202
206
|
const projectNames = new Set(projects.map((p) => p.name));
|
|
203
207
|
/** @type {{kind: "edge"|"component", source: string|undefined, target: string|undefined,
|
|
@@ -256,6 +260,7 @@ export function boundaryAssertions({ projects, edges }) {
|
|
|
256
260
|
* evidence: object[], confidence: string}[]} Sorted.
|
|
257
261
|
*/
|
|
258
262
|
export function tagVocabulary(projects) {
|
|
263
|
+
// used by its own test
|
|
259
264
|
const components = componentsByDirectory(projects);
|
|
260
265
|
/** @type {{kind: "observed"|"suggested", tag: string|undefined, axis: string|undefined,
|
|
261
266
|
* component: string|undefined, members: string[]|undefined, values: string[]|undefined,
|
|
@@ -308,6 +313,7 @@ export function tagVocabulary(projects) {
|
|
|
308
313
|
* component?: string, evidence: object[], confidence: string}[]} Sorted.
|
|
309
314
|
*/
|
|
310
315
|
export function candidateRules(assertions) {
|
|
316
|
+
// used by its own test
|
|
311
317
|
/** @type {{kind: "noDependency"|"boundary", source: string|undefined, target: string|undefined,
|
|
312
318
|
* component: string|undefined, evidence: object[], confidence: "medium"}[]} */
|
|
313
319
|
const rules = [];
|
|
@@ -80,6 +80,48 @@ export function eventId(event) {
|
|
|
80
80
|
return createHash("sha256").update(eventDedupeKey(event)).digest("hex");
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* The refusal law every command that writes an evolution event holds: the
|
|
85
|
+
* write happens only from a reproducible identity — a committed, clean head
|
|
86
|
+
* and a clean base. A commitless head has no revision to name (the event
|
|
87
|
+
* would serialize its `head` as `{}`, and every run over that workspace
|
|
88
|
+
* would collide on one event id); a dirty tree names a commit its evidence
|
|
89
|
+
* does not back, so two distinct uncommitted states collapse onto one event
|
|
90
|
+
* id — a later transition is silently lost or aliased. That is the silent
|
|
91
|
+
* direction this repository refuses, so the write is refused loudly instead.
|
|
92
|
+
* The same run without `--event-out` stays a byte-identical in-memory run —
|
|
93
|
+
* the gate is the event write, never the verdict.
|
|
94
|
+
*
|
|
95
|
+
* The messages are the `delta` command's original wording, parameterized by
|
|
96
|
+
* the writing command's label. Consumers match on these strings (a refusal
|
|
97
|
+
* is part of a run's observable contract), so the wording is frozen here —
|
|
98
|
+
* one home, one copy, no per-command drift.
|
|
99
|
+
*
|
|
100
|
+
* @param {{label: "delta"|"change", headCommit: string|undefined,
|
|
101
|
+
* baseDirty: boolean, headDirty: boolean}} input `label` names the writing
|
|
102
|
+
* command in the refusal message; `headCommit` is the head revision the
|
|
103
|
+
* event would carry (`undefined` when provenance could not resolve one);
|
|
104
|
+
* `baseDirty`/`headDirty` are the two sides' provenance dirty bits.
|
|
105
|
+
* @returns {void} Throws on every state that cannot produce a reproducible
|
|
106
|
+
* event identity.
|
|
107
|
+
*/
|
|
108
|
+
export function assertReproducibleEventIdentity({ label, headCommit, baseDirty, headDirty }) {
|
|
109
|
+
if (typeof headCommit !== "string") {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`archkeep: refusing to write a ${label} event without a committed head — a commitless ` +
|
|
112
|
+
"head has no reproducible event identity, and every distinct head state would " +
|
|
113
|
+
"collide on one event id. Commit the head, or capture without --event-out.",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (baseDirty === true || headDirty === true) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`archkeep: refusing to write a ${label} event from a dirty working tree — the event ` +
|
|
119
|
+
"would name a commit whose evidence is uncommitted, and distinct uncommitted " +
|
|
120
|
+
"states would collide on one event id. Commit both sides first.",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
83
125
|
/**
|
|
84
126
|
* The digest of a normalized change-intent's DECLARATIVE parts only:
|
|
85
127
|
* `{version, base, projects, edges, constraints}`. The prose `summary` is
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
import { join, resolve } from "node:path";
|
|
52
52
|
|
|
53
53
|
import { containmentViolation } from "../containment.mjs";
|
|
54
|
+
import { isEnoent } from "../errors.mjs";
|
|
54
55
|
import {
|
|
55
56
|
eventDedupeKey,
|
|
56
57
|
eventId,
|
|
@@ -222,7 +223,7 @@ export function writeEvent(dir, event, io = {}) {
|
|
|
222
223
|
try {
|
|
223
224
|
names = readDir(dirAbs);
|
|
224
225
|
} catch (cause) {
|
|
225
|
-
if (cause
|
|
226
|
+
if (isEnoent(cause)) {
|
|
226
227
|
// An absent optional store is an empty store: the caller's first event.
|
|
227
228
|
makeDir(dirAbs, { recursive: true });
|
|
228
229
|
names = readDir(dirAbs);
|
|
@@ -325,7 +326,7 @@ export function readEvents(dir, io = {}) {
|
|
|
325
326
|
try {
|
|
326
327
|
names = readDir(dirAbs);
|
|
327
328
|
} catch (cause) {
|
|
328
|
-
if (cause
|
|
329
|
+
if (isEnoent(cause)) return [];
|
|
329
330
|
throw new Error(
|
|
330
331
|
`archkeep: cannot read the event store '${dirAbs}': ${cause?.message ?? cause}`,
|
|
331
332
|
{ cause },
|
|
@@ -63,11 +63,9 @@ import {
|
|
|
63
63
|
tagConformance,
|
|
64
64
|
} from "./fitness-rules.mjs";
|
|
65
65
|
|
|
66
|
-
/** The one `fitness` list key in the boundary config. */
|
|
67
|
-
export const FITNESS_KEY = "fitness";
|
|
68
|
-
|
|
69
66
|
/** The condition types the registry can evaluate. */
|
|
70
67
|
export const CONDITION_TYPES = Object.freeze([
|
|
68
|
+
// used by its own test
|
|
71
69
|
"cycle-free",
|
|
72
70
|
"layer-dependency",
|
|
73
71
|
"tag-conformance",
|
|
@@ -330,6 +328,7 @@ function tagAxisIsolationViolations(condition, at) {
|
|
|
330
328
|
* @returns {object} A verdict record from `fitnessVerdict`.
|
|
331
329
|
*/
|
|
332
330
|
export function judgeFitnessRow(row, graph, analysis, intent, suppressions) {
|
|
331
|
+
// used by its own test
|
|
333
332
|
const names = resolveMembers(row.match, graph.nodes);
|
|
334
333
|
if (names.length === 0) {
|
|
335
334
|
return fitnessVerdict({
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
|
-
"clean-architecture#clean-architecture": "
|
|
3
|
-
"clean-architecture#clean-architecture-pure-core": "
|
|
4
|
-
"ddd-bounded-contexts#ddd-bounded-contexts": "
|
|
5
|
-
"ddd-bounded-contexts#ddd-bounded-contexts-isolated": "
|
|
6
|
-
"ddd-bounded-contexts#ddd-bounded-contexts-partitioned": "
|
|
7
|
-
"hexagonal#hexagonal": "
|
|
8
|
-
"hexagonal#hexagonal-pure-domain": "
|
|
9
|
-
"layered#layered-relaxed": "
|
|
10
|
-
"layered#layered-strict": "
|
|
11
|
-
"modular-monolith#modular-monolith": "
|
|
12
|
-
"modular-monolith#modular-monolith-sealed-kernel": "
|
|
13
|
-
"modular-monolith#modular-monolith-sealed-modules": "
|
|
14
|
-
"vertical-slice#vertical-slice": "
|
|
15
|
-
"vertical-slice#vertical-slice-sealed-kernel": "
|
|
2
|
+
"clean-architecture#clean-architecture": "5f32ab7e98b5e49ed832bbb4dd97d6757abdf607ca85dbe70a3fc9515493fcb4",
|
|
3
|
+
"clean-architecture#clean-architecture-pure-core": "9728201a3f57fc7db38747e58cd277fdff54277a34d3997b6ae67827dc547404",
|
|
4
|
+
"ddd-bounded-contexts#ddd-bounded-contexts": "6d9f733c915e3ab33932d5150d0651a4603a7869625a7ec55afc7bb3a0328871",
|
|
5
|
+
"ddd-bounded-contexts#ddd-bounded-contexts-isolated": "6dd7e4360b8d8fb0359148af1360bc8986526b2be51c6b7a647f7fb414e5a0a3",
|
|
6
|
+
"ddd-bounded-contexts#ddd-bounded-contexts-partitioned": "93b15523dda77be5aca06806d0235eefa32a504e411d4fcf6962a7c1bec9e719",
|
|
7
|
+
"hexagonal#hexagonal": "86321ee12d3e304567c988a5fb04ef17d040763c45f021a9b1f64c3852ea7c12",
|
|
8
|
+
"hexagonal#hexagonal-pure-domain": "2aa8efe7d71a4c731aee61c46961d6bba8d239af68909a71595488374c5de80a",
|
|
9
|
+
"layered#layered-relaxed": "ffc0f268d37b88c97eb8c783283adbf9779850750b3e21c780c7851d936fb6b4",
|
|
10
|
+
"layered#layered-strict": "4c90afb7cfe1e273fedce2a85e2403a1f354a4a038f1c872ad103f1c2f1d7df1",
|
|
11
|
+
"modular-monolith#modular-monolith": "634ef3e706d06780bfd72a57c1a4a3c0be2d4163b0728fc734f4bd4e5da8cbe1",
|
|
12
|
+
"modular-monolith#modular-monolith-sealed-kernel": "372cabc2d618aea8a0aa8b284e8be93a062dd5ebe22f64af52bc1fa785826a97",
|
|
13
|
+
"modular-monolith#modular-monolith-sealed-modules": "0ff0b39328d50fb3622dc84fb4d7b26fd567a5110437536aaa586e29ad4b8c92",
|
|
14
|
+
"vertical-slice#vertical-slice": "4df057e4ec1f92dae8aada8203d18869c6e283764f66a9f96f1c425304436f34",
|
|
15
|
+
"vertical-slice#vertical-slice-sealed-kernel": "1d1fbaf268a7afc851e6e4a126bd586dd0ca9547cf9d654e44dcf0dd99537f78"
|
|
16
16
|
}
|
|
@@ -74,7 +74,7 @@ import { describe, isPlainObject } from "../values.mjs";
|
|
|
74
74
|
const REGISTRY_KEYS = ["profiles", "version", "$schema"];
|
|
75
75
|
|
|
76
76
|
/** A version a reader that predates it must refuse, per `docs/reference/profiles.md`. */
|
|
77
|
-
|
|
77
|
+
const PROFILE_REGISTRY_SCHEMA_VERSION = 1;
|
|
78
78
|
|
|
79
79
|
/** The registry's schema version: stated, or schema 1 when absent. */
|
|
80
80
|
function registrySchemaVersion(raw) {
|
|
@@ -112,6 +112,7 @@ const NAME_PATTERN = /^[a-zA-Z0-9_-]+$/u;
|
|
|
112
112
|
|
|
113
113
|
/** A profile's declared block, kept ONLY for this command's own data. */
|
|
114
114
|
export function listNames(registry) {
|
|
115
|
+
// used by its own test
|
|
115
116
|
return registry.profiles.map((profile) => profile.name);
|
|
116
117
|
}
|
|
117
118
|
|
|
@@ -126,6 +127,7 @@ export function listNames(registry) {
|
|
|
126
127
|
* @returns {string[]}
|
|
127
128
|
*/
|
|
128
129
|
export function profileRegistryViolations(raw) {
|
|
130
|
+
// used by its own test
|
|
129
131
|
if (!isPlainObject(raw)) {
|
|
130
132
|
return [`profiles: expected a JSON object, got ${describe(raw)}`];
|
|
131
133
|
}
|
|
@@ -201,6 +203,7 @@ export function profileRegistryViolations(raw) {
|
|
|
201
203
|
* @returns {string[]}
|
|
202
204
|
*/
|
|
203
205
|
export function profileReferenceViolations(profiles) {
|
|
206
|
+
// used by its own test
|
|
204
207
|
const violations = [];
|
|
205
208
|
const byName = new Map(profiles.map((profile) => [profile.name, profile]));
|
|
206
209
|
for (const profile of profiles) {
|
|
@@ -250,6 +253,7 @@ export function profileReferenceViolations(profiles) {
|
|
|
250
253
|
* silently resolved as "no profile".
|
|
251
254
|
*/
|
|
252
255
|
export function resolveProfile(profiles, name, seen = new Set()) {
|
|
256
|
+
// used by its own test
|
|
253
257
|
const profile = profiles.find((candidate) => candidate.name === name);
|
|
254
258
|
if (profile === undefined) {
|
|
255
259
|
throw new Error(
|
|
@@ -302,6 +306,7 @@ export function resolveProfile(profiles, name, seen = new Set()) {
|
|
|
302
306
|
* profile-registry or reference-graph defect.
|
|
303
307
|
*/
|
|
304
308
|
export function loadProfileRegistry(path, { readFile = defaultProfileIo.readFile } = {}) {
|
|
309
|
+
// used by its own test
|
|
305
310
|
const text = readFile(path);
|
|
306
311
|
if (text === null) {
|
|
307
312
|
throw new Error(`archkeep: cannot read profiles file ${path}`);
|
|
@@ -336,13 +341,27 @@ export function loadProfileRegistry(path, { readFile = defaultProfileIo.readFile
|
|
|
336
341
|
* @param {string} profileName The profile to resolve.
|
|
337
342
|
* @param {string} sourceLabel What failed, named in the thrown message.
|
|
338
343
|
* @param {{readFile?: (path: string) => string|null}} [io]
|
|
339
|
-
* @returns {{depConstraints: object[], options: object, suppressions: object[], fitness?: object[]
|
|
344
|
+
* @returns {{depConstraints: object[], options: object, suppressions: object[], fitness?: object[],
|
|
345
|
+
* profile: string}} `profile` is the selection the policy was resolved by,
|
|
346
|
+
* carried on the policy itself so the law's identity travels with it —
|
|
347
|
+
* `../commands/graph.mjs`'s `computePolicyFingerprint` reads it, and the
|
|
348
|
+
* fingerprint is the one policy fact that travels between captures
|
|
349
|
+
* (`delta`, `diff`, `history`).
|
|
340
350
|
* @throws {Error} when the registry or the named profile is defective.
|
|
341
351
|
*/
|
|
342
352
|
export function profilePolicy(registryPath, profileName, sourceLabel, io = {}) {
|
|
343
353
|
const registry = loadProfileRegistry(registryPath, io);
|
|
344
354
|
const effective = resolveProfile(registry.profiles, profileName);
|
|
345
|
-
return
|
|
355
|
+
return {
|
|
356
|
+
...policyFrom(effective, `${sourceLabel} (profile "${profileName}")`),
|
|
357
|
+
// The selection rides on the policy it produced: two profiles whose
|
|
358
|
+
// blocks converge resolve to the same fields, so without this key a
|
|
359
|
+
// switch between them resolves to the same fingerprint and a `delta`
|
|
360
|
+
// across the switch classifies as no law change. The NAME, never
|
|
361
|
+
// `registryPath` — a path is machine-local, and a fingerprint that
|
|
362
|
+
// differs between a laptop and CI reports a change nobody made.
|
|
363
|
+
profile: profileName,
|
|
364
|
+
};
|
|
346
365
|
}
|
|
347
366
|
|
|
348
367
|
/**
|
|
@@ -52,7 +52,7 @@ import { clockViolations } from "./clock.mjs";
|
|
|
52
52
|
import { describe, isPlainObject } from "../values.mjs";
|
|
53
53
|
|
|
54
54
|
/** The only keys a validated `origin` may carry. */
|
|
55
|
-
export const ORIGIN_KEYS = Object.freeze(["by", "tool", "on"]);
|
|
55
|
+
export const ORIGIN_KEYS = Object.freeze(["by", "tool", "on"]); // used by its own test
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
58
|
* @typedef {object} OriginRecord
|
|
@@ -135,6 +135,7 @@ export function originViolations(raw, io = {}) {
|
|
|
135
135
|
* @throws {Error} naming every violation at once, prefixed by `at`.
|
|
136
136
|
*/
|
|
137
137
|
export function validateOrigin(raw, io = {}, at = "origin") {
|
|
138
|
+
// used by its own test
|
|
138
139
|
const violations = originViolations(raw, io).map((message) =>
|
|
139
140
|
message.startsWith("origin.")
|
|
140
141
|
? `${at}.${message.slice("origin.".length)}`
|
|
@@ -181,6 +182,7 @@ export function recordOrigin({ by, tool, clock }) {
|
|
|
181
182
|
* change, supersession, and bindings change is recorded as one of these.
|
|
182
183
|
*/
|
|
183
184
|
export const DECISION_LIFECYCLE_KINDS = Object.freeze([
|
|
185
|
+
// used by its own test
|
|
184
186
|
"status-transition",
|
|
185
187
|
"supersession",
|
|
186
188
|
"bindings-change",
|
|
@@ -234,6 +236,7 @@ export const DECISION_LIFECYCLE_KINDS = Object.freeze([
|
|
|
234
236
|
* the registry's `ADR_STATUSES`, a no-op event, or an invalid origin/clock.
|
|
235
237
|
*/
|
|
236
238
|
export function recordDecisionLifecycle({
|
|
239
|
+
// used by its own test
|
|
237
240
|
kind,
|
|
238
241
|
decisionId,
|
|
239
242
|
from = null,
|
|
@@ -70,6 +70,7 @@ function boundaryKey(from, to) {
|
|
|
70
70
|
|
|
71
71
|
/** The severity a state earns — the sort key a ranked proposal list uses. */
|
|
72
72
|
export const SEVERITY_ORDER = Object.freeze({
|
|
73
|
+
// used by its own test
|
|
73
74
|
unexpected: 4,
|
|
74
75
|
absent: 3,
|
|
75
76
|
match: 0,
|
|
@@ -133,6 +134,7 @@ function intentKeys(intent) {
|
|
|
133
134
|
* @returns {{project: ScoredElement, tags: ScoredElement[]}}
|
|
134
135
|
*/
|
|
135
136
|
export function scoreProject(project, keys, requiredTagsByProject) {
|
|
137
|
+
// used by its own test
|
|
136
138
|
const tags = project.data?.tags ?? project.tags ?? [];
|
|
137
139
|
const requiredTags = requiredTagsByProject.get(project.name) ?? [];
|
|
138
140
|
const element = { plane: "project", name: project.name };
|
|
@@ -209,6 +211,7 @@ export function scoreProject(project, keys, requiredTagsByProject) {
|
|
|
209
211
|
* @returns {ScoredElement}
|
|
210
212
|
*/
|
|
211
213
|
export function scoreEdge(edge, keys, intentForbiddenPairs, tagForbiddenPairs) {
|
|
214
|
+
// used by its own test
|
|
212
215
|
const key = `${edge.source} → ${edge.target}`;
|
|
213
216
|
const element = { plane: "edge", name: key, intentRow: null };
|
|
214
217
|
|
|
@@ -279,6 +282,7 @@ export function scoreEdge(edge, keys, intentForbiddenPairs, tagForbiddenPairs) {
|
|
|
279
282
|
* @returns {ScoredElement[]}
|
|
280
283
|
*/
|
|
281
284
|
export function scoreIntentRows(intent, judgeVerdict, observed, tagsByProject) {
|
|
285
|
+
// used by its own test
|
|
282
286
|
const rows = [];
|
|
283
287
|
const observedNames = new Set(observed.projects.map((p) => p.name));
|
|
284
288
|
const observedEdgeKeys = new Set(observed.edges.map((e) => `${e.source} → ${e.target}`));
|
|
@@ -59,9 +59,6 @@
|
|
|
59
59
|
import { originViolations } from "./provenance-record.mjs";
|
|
60
60
|
import { describe, isPlainObject } from "../values.mjs";
|
|
61
61
|
|
|
62
|
-
/** The shape of any `origin.on` producer. Re-exported for a row owner's own docs. */
|
|
63
|
-
export { clockViolations as clockValidation } from "./clock.mjs";
|
|
64
|
-
|
|
65
62
|
/** The four governance keys a row may carry, in the order reports list them. */
|
|
66
63
|
export const GOVERNANCE_ROW_KEYS = Object.freeze([
|
|
67
64
|
"origin",
|
|
@@ -97,6 +94,7 @@ export const GOVERNANCE_ROW_KEYS = Object.freeze([
|
|
|
97
94
|
* @returns {string[]}
|
|
98
95
|
*/
|
|
99
96
|
export function governanceBlockViolations(raw, at) {
|
|
97
|
+
// used by its own test
|
|
100
98
|
if (!isPlainObject(raw)) return [];
|
|
101
99
|
const violations = [];
|
|
102
100
|
|
|
@@ -48,6 +48,7 @@ export const VERDICTS = Object.freeze(["pass", "fail", "unknown", "not_applicabl
|
|
|
48
48
|
|
|
49
49
|
/** The single mapping from an envelope status to a verdict. */
|
|
50
50
|
export const VERDICT_FOR_STATUS = Object.freeze({
|
|
51
|
+
// used by its own test
|
|
51
52
|
ok: "pass",
|
|
52
53
|
findings: "fail",
|
|
53
54
|
"no-verdict": "unknown",
|