@ecoma-io/archkeep 0.13.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/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- package/src/workspace.mjs +580 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named boundary-law profiles: rule blocks a workspace can give a name to,
|
|
3
|
+
* stack on a base, and then select by name instead of by file.
|
|
4
|
+
*
|
|
5
|
+
* A profile is data, not a new dialect. Each profile body is a policy block of
|
|
6
|
+
* the exact four keys every boundaryConfig dialect already shares —
|
|
7
|
+
* `depConstraints`, `moduleBoundaryOptions`, `boundarySuppressions`, and
|
|
8
|
+
* `fitness` (`../../config.mjs`'s `findBoundaryConfigViolations`, documented in
|
|
9
|
+
* `../../../../docs/concepts/policies.md`) — validated by that same validator,
|
|
10
|
+
* so a profile cannot state a constraint the file dialects cannot. The
|
|
11
|
+
* resolution result is fed through `../../config.mjs`'s `policyFrom` tail, so
|
|
12
|
+
* there is exactly ONE enforcement path: a profile is a way to name and reuse a
|
|
13
|
+
* policy, never a second kind of policy that could disagree with a file about
|
|
14
|
+
* the same row.
|
|
15
|
+
*
|
|
16
|
+
* The profile list itself is JSON, named by the `profiles` plugin option
|
|
17
|
+
* (`../../options.mjs`). A profile has a `name`, an optional `base` (the name
|
|
18
|
+
* of another profile whose effective block it inherits), and a `block` of the
|
|
19
|
+
* four keys. Precedence is deterministic and documented in
|
|
20
|
+
* `../../../../docs/concepts/profiles.md`: a child profile's `depConstraints`
|
|
21
|
+
* rows are appended AFTER its base's rows (the composition semantics of
|
|
22
|
+
* `@nx/enforce-module-boundaries`, where a dependency must satisfy EVERY row
|
|
23
|
+
* whose `sourceTag` its source project carries, so axes compose rather than
|
|
24
|
+
* replace); its `moduleBoundaryOptions` keys overwrite the base's key by key;
|
|
25
|
+
* its `boundarySuppressions` rows — and its `fitness` rows — are appended
|
|
26
|
+
* after the base's. `base` may
|
|
27
|
+
* chain by name — `b` on `a`, `c` on `b` — resolved depth-first, earlier
|
|
28
|
+
* profiles first, so a chain reads in the order it was written.
|
|
29
|
+
*
|
|
30
|
+
* ## What is loud, and why
|
|
31
|
+
*
|
|
32
|
+
* A profile registry that silently compensated for its own defects would
|
|
33
|
+
* repeat the one failure this tool exists to end. Three conditions are thrown,
|
|
34
|
+
* all by name:
|
|
35
|
+
*
|
|
36
|
+
* - A profile whose reference is **unknown** — `base` names a profile that
|
|
37
|
+
* does not exist. Read as "no base", the profile would shed the rows it was
|
|
38
|
+
* meant to inherit: the stack stops enforcing its base block, and nothing
|
|
39
|
+
* says so.
|
|
40
|
+
* - A **cycle** in a base chain — `a` on `b` on `a`. A cycle has no
|
|
41
|
+
* deterministic resolution; stopping it loudly is the only correct answer.
|
|
42
|
+
* - A profile with **no name**, or no `block` — the first states a rule nobody
|
|
43
|
+
* can refer to, the second parses as an empty policy (which `policyFrom`
|
|
44
|
+
* would refuse for its own reasons, but the registry names it as a profile
|
|
45
|
+
* defect so the reader is looking at the right file).
|
|
46
|
+
*
|
|
47
|
+
* Missing `name` is an exit-3 class (`../../../cli.mjs`, `EXIT.error`): the
|
|
48
|
+
* registry cannot be read, so no command that depends on it can reach a
|
|
49
|
+
* verdict.
|
|
50
|
+
*
|
|
51
|
+
* ## Loading
|
|
52
|
+
*
|
|
53
|
+
* `loadProfileRegistry` reads the file named by the `profiles` option. It does
|
|
54
|
+
* not `import()` the way a `.mjs` boundary config does: a registry whose
|
|
55
|
+
* entries are data earns the same strict reading as a `.json` boundary law —
|
|
56
|
+
* plain `JSON.parse`, never JSONC (`../../config.mjs`'s header argues that
|
|
57
|
+
* dialect). The injectable `readFile` seam is the same one
|
|
58
|
+
* `../../options.mjs`'s readers take, and everything except the read itself is
|
|
59
|
+
* pure.
|
|
60
|
+
*/
|
|
61
|
+
import { readFileSync } from "node:fs";
|
|
62
|
+
|
|
63
|
+
import { policyFrom } from "../config.mjs";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The top-level keys a profiles file may carry. `version` is checked AFTER
|
|
67
|
+
* defaulting: a file that does not state one is schema 1 by definition, so a
|
|
68
|
+
* later reader can still tell a v1 registry from a hypothetical v2 — the
|
|
69
|
+
* default is applied here, at the one place the version is read. `profiles`
|
|
70
|
+
* is the list. `$schema` is tolerated for editor validation, the same
|
|
71
|
+
* carve-out the `.json` boundary dialect makes.
|
|
72
|
+
*/
|
|
73
|
+
const REGISTRY_KEYS = ["profiles", "version", "$schema"];
|
|
74
|
+
|
|
75
|
+
/** A version a reader that predates it must refuse, per `docs/reference/profiles.md`. */
|
|
76
|
+
export const PROFILE_REGISTRY_SCHEMA_VERSION = 1;
|
|
77
|
+
|
|
78
|
+
/** The registry's schema version: stated, or schema 1 when absent. */
|
|
79
|
+
function registrySchemaVersion(raw) {
|
|
80
|
+
return raw.version === undefined
|
|
81
|
+
? PROFILE_REGISTRY_SCHEMA_VERSION
|
|
82
|
+
: /** @type {unknown} */ (raw.version);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The four keys a profile's `block` may carry — the boundary laws `policyFrom`
|
|
87
|
+
* reads, minus `customRules`, deliberately: a profile is a shareable law pack,
|
|
88
|
+
* and a custom-rule row names a wasm artifact by workspace-relative path and
|
|
89
|
+
* hash, which does not travel with a registry. A block naming it is refused by
|
|
90
|
+
* name below, loudly, until profile-carried rules are designed on purpose.
|
|
91
|
+
*/
|
|
92
|
+
const BLOCK_KEYS = ["depConstraints", "moduleBoundaryOptions", "boundarySuppressions", "fitness"];
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A profile `name`, matched exactly by `resolveProfile` and typed by an
|
|
96
|
+
* operator at `--config`/`boundaryConfig`. Restricted to the same safe set
|
|
97
|
+
* `../architecture-intent/model.mjs`'s boundary names and
|
|
98
|
+
* `./fitness-registry.mjs`'s fitness names already use, and for the identical
|
|
99
|
+
* reason: a name is a selector, not a label, so two names that a human reads
|
|
100
|
+
* as identical must not be able to exist as two different values. Unicode
|
|
101
|
+
* (a zero-width character, a homoglyph from another script, two normalisation
|
|
102
|
+
* forms of the same visible glyph) can make two byte-distinct strings render
|
|
103
|
+
* identically — the duplicate check below compares the raw string, which
|
|
104
|
+
* would accept both as "unique" and then resolve whichever one an operator's
|
|
105
|
+
* literal `--config` argument happened to byte-match, silently enforcing the
|
|
106
|
+
* OTHER one's block. Refusing every character outside this set closes that
|
|
107
|
+
* off at the source: two names that display the same are now always the same
|
|
108
|
+
* string, so the existing duplicate check is sufficient again.
|
|
109
|
+
*/
|
|
110
|
+
const NAME_PATTERN = /^[a-zA-Z0-9_-]+$/u;
|
|
111
|
+
|
|
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
|
+
/** A profile's declared block, kept ONLY for this command's own data. */
|
|
123
|
+
export function listNames(registry) {
|
|
124
|
+
return registry.profiles.map((profile) => profile.name);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Everything wrong with a registry, as messages; empty when it is well-formed.
|
|
129
|
+
* Pure, so a test drives it without a file on disk.
|
|
130
|
+
*
|
|
131
|
+
* Every condition is a silent-direction guard: a registry the reader cannot
|
|
132
|
+
* trust must not be read as smaller than it is.
|
|
133
|
+
*
|
|
134
|
+
* @param {unknown} raw The parsed JSON value.
|
|
135
|
+
* @returns {string[]}
|
|
136
|
+
*/
|
|
137
|
+
export function profileRegistryViolations(raw) {
|
|
138
|
+
if (!isPlainObject(raw)) {
|
|
139
|
+
return [`profiles: expected a JSON object, got ${describe(raw)}`];
|
|
140
|
+
}
|
|
141
|
+
const violations = [];
|
|
142
|
+
for (const key of Object.keys(raw)) {
|
|
143
|
+
if (!REGISTRY_KEYS.includes(key)) {
|
|
144
|
+
violations.push(
|
|
145
|
+
`${key}: not a recognised profiles-file key — expected one of ${REGISTRY_KEYS.join(", ")}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (registrySchemaVersion(raw) !== PROFILE_REGISTRY_SCHEMA_VERSION) {
|
|
150
|
+
violations.push(
|
|
151
|
+
`version: expected ${PROFILE_REGISTRY_SCHEMA_VERSION}, got ${describe(registrySchemaVersion(raw))} — ` +
|
|
152
|
+
`a registry this reader does not understand must refuse rather than guess`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (!Array.isArray(raw.profiles)) {
|
|
156
|
+
violations.push(`profiles: must be an array of profiles, got ${describe(raw.profiles)}`);
|
|
157
|
+
return violations;
|
|
158
|
+
}
|
|
159
|
+
const names = new Set();
|
|
160
|
+
raw.profiles.forEach((profile, index) => {
|
|
161
|
+
const at = `profiles[${index}]`;
|
|
162
|
+
if (!isPlainObject(profile)) {
|
|
163
|
+
violations.push(`${at}: must be an object, got ${describe(profile)}`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const extra = Object.keys(profile).filter((key) => !["name", "base", "block"].includes(key));
|
|
167
|
+
for (const key of extra) {
|
|
168
|
+
violations.push(
|
|
169
|
+
`${at}.${key}: not a profile field — a profile may carry only name, base, block`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if (typeof profile.name !== "string" || !NAME_PATTERN.test(profile.name)) {
|
|
173
|
+
violations.push(
|
|
174
|
+
`${at}.name: must be a non-empty string of letters, digits, "-" or "_", got ${describe(profile.name)}`,
|
|
175
|
+
);
|
|
176
|
+
} else if (names.has(profile.name)) {
|
|
177
|
+
violations.push(
|
|
178
|
+
`${at}.name: "${profile.name}" is declared more than once — every profile name must be unique`,
|
|
179
|
+
);
|
|
180
|
+
} else {
|
|
181
|
+
names.add(profile.name);
|
|
182
|
+
}
|
|
183
|
+
if (profile.base !== undefined && profile.base !== null) {
|
|
184
|
+
if (typeof profile.base !== "string" || profile.base.trim() === "") {
|
|
185
|
+
violations.push(
|
|
186
|
+
`${at}.base: must be a non-empty string naming another profile, got ${describe(profile.base)}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!isPlainObject(profile.block)) {
|
|
191
|
+
violations.push(`${at}.block: must be a policy block object, got ${describe(profile.block)}`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const blockExtra = Object.keys(profile.block).filter((key) => !BLOCK_KEYS.includes(key));
|
|
195
|
+
for (const key of blockExtra) {
|
|
196
|
+
violations.push(
|
|
197
|
+
`${at}.block.${key}: not a policy block field — expected one of ${BLOCK_KEYS.join(", ")}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
return violations;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Everything wrong with a profile's reference graph — unknown `base` and
|
|
206
|
+
* `base` cycles. Separated from `profileRegistryViolations` so a test can pin
|
|
207
|
+
* each loud condition by name.
|
|
208
|
+
*
|
|
209
|
+
* @param {object[]} profiles Validated profile objects.
|
|
210
|
+
* @returns {string[]}
|
|
211
|
+
*/
|
|
212
|
+
export function profileReferenceViolations(profiles) {
|
|
213
|
+
const violations = [];
|
|
214
|
+
const byName = new Map(profiles.map((profile) => [profile.name, profile]));
|
|
215
|
+
for (const profile of profiles) {
|
|
216
|
+
if (profile.base === undefined || profile.base === null) continue;
|
|
217
|
+
const stack = new Set([profile.name]);
|
|
218
|
+
let current = /** @type {string} */ (profile.base);
|
|
219
|
+
while (current !== undefined && current !== null) {
|
|
220
|
+
if (stack.has(current)) {
|
|
221
|
+
violations.push(
|
|
222
|
+
`profiles: "${profile.name}"'s base chain contains a cycle through "${current}" — ` +
|
|
223
|
+
`a cycle has no deterministic resolution`,
|
|
224
|
+
);
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
const next = byName.get(current);
|
|
228
|
+
if (next === undefined) {
|
|
229
|
+
violations.push(
|
|
230
|
+
`profiles: "${profile.name}" names base "${current}" but no profile with that name exists — ` +
|
|
231
|
+
`read as "no base", the stack would shed the rows it was meant to inherit`,
|
|
232
|
+
);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
stack.add(current);
|
|
236
|
+
current =
|
|
237
|
+
next.base === null || next.base === undefined
|
|
238
|
+
? undefined
|
|
239
|
+
: /** @type {string} */ (next.base);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return violations;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The effective policy block for a profile, its base chain resolved depth-first
|
|
247
|
+
* with the documented precedence: rows append, option keys overwrite.
|
|
248
|
+
*
|
|
249
|
+
* @param {object[]} profiles The registry's full profile list.
|
|
250
|
+
* @param {string} name The profile to resolve.
|
|
251
|
+
* @param {Set<string>} [seen] Inheritance stack (used internally by recursion).
|
|
252
|
+
* @returns {{depConstraints: object[], moduleBoundaryOptions: object, boundarySuppressions: object[],
|
|
253
|
+
* fitness?: object[]}} `fitness` is present only when the profile (or a base
|
|
254
|
+
* it inherits) declares one — the same absent-is-a-decision posture
|
|
255
|
+
* `policyFrom` keeps, so a profile-selected workspace reaches the fitness
|
|
256
|
+
* command's "declares no fitness functions" refusal rather than a
|
|
257
|
+
* config-loading failure.
|
|
258
|
+
* @throws {Error} when `name` does not exist or its base chain cycles — never
|
|
259
|
+
* silently resolved as "no profile".
|
|
260
|
+
*/
|
|
261
|
+
export function resolveProfile(profiles, name, seen = new Set()) {
|
|
262
|
+
const profile = profiles.find((candidate) => candidate.name === name);
|
|
263
|
+
if (profile === undefined) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`archkeep: profile "${name}" does not exist — a policy that selects an unknown profile ` +
|
|
266
|
+
`enforces nothing and says nothing`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
if (seen.has(name)) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`archkeep: profile "${name}"'s base chain contains a cycle through "${name}" — ` +
|
|
272
|
+
`a cycle has no deterministic resolution`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
seen.add(name);
|
|
276
|
+
const base =
|
|
277
|
+
profile.base === undefined || profile.base === null
|
|
278
|
+
? null
|
|
279
|
+
: resolveProfile(profiles, /** @type {string} */ (profile.base), seen);
|
|
280
|
+
const block = profile.block;
|
|
281
|
+
const options =
|
|
282
|
+
base === null
|
|
283
|
+
? { ...block.moduleBoundaryOptions }
|
|
284
|
+
: { ...base.moduleBoundaryOptions, ...block.moduleBoundaryOptions };
|
|
285
|
+
return {
|
|
286
|
+
depConstraints: [...(base?.depConstraints ?? []), ...(block.depConstraints ?? [])],
|
|
287
|
+
moduleBoundaryOptions: options,
|
|
288
|
+
boundarySuppressions: [
|
|
289
|
+
...(base?.boundarySuppressions ?? []),
|
|
290
|
+
...(block.boundarySuppressions ?? []),
|
|
291
|
+
],
|
|
292
|
+
// Child `fitness` rows append after base rows, the same composition
|
|
293
|
+
// semantics every other list-keyed block field uses.
|
|
294
|
+
...(base?.fitness !== undefined || block.fitness !== undefined
|
|
295
|
+
? { fitness: [...(base?.fitness ?? []), ...(block.fitness ?? [])] }
|
|
296
|
+
: {}),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Loads the profiles registry named by the `profiles` plugin option, validates
|
|
302
|
+
* it by name, and throws loudly on any defect.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} path Absolute path of the profiles file.
|
|
305
|
+
* @param {{readFile?: (path: string) => string|null}} [io] Injectable read,
|
|
306
|
+
* the same seam `../../options.mjs`'s readers take; answers `null` when the
|
|
307
|
+
* file is not there.
|
|
308
|
+
* @returns {{profiles: object[]}}
|
|
309
|
+
* @throws {Error} on a missing/unreadable/unparseable file, or on any
|
|
310
|
+
* profile-registry or reference-graph defect.
|
|
311
|
+
*/
|
|
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
|
+
) {
|
|
324
|
+
const text = readFile(path);
|
|
325
|
+
if (text === null) {
|
|
326
|
+
throw new Error(`archkeep: cannot read profiles file ${path}`);
|
|
327
|
+
}
|
|
328
|
+
let parsed;
|
|
329
|
+
try {
|
|
330
|
+
parsed = JSON.parse(text);
|
|
331
|
+
} catch (cause) {
|
|
332
|
+
throw new Error(`archkeep: cannot parse profiles file ${path}: ${cause?.message ?? cause}`, {
|
|
333
|
+
cause,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
const violations = [
|
|
337
|
+
...profileRegistryViolations(parsed),
|
|
338
|
+
...(parsed && Array.isArray(parsed.profiles)
|
|
339
|
+
? profileReferenceViolations(parsed.profiles)
|
|
340
|
+
: []),
|
|
341
|
+
];
|
|
342
|
+
if (violations.length > 0) {
|
|
343
|
+
throw new Error(`archkeep: ${path} is malformed:\n ${violations.join("\n ")}`);
|
|
344
|
+
}
|
|
345
|
+
/** @type {{profiles: object[]}} */ (parsed);
|
|
346
|
+
return parsed;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The full profile path `cli.mjs`'s `check` takes when a workspace selects a
|
|
351
|
+
* profile by name: load the registry, resolve the named profile to its
|
|
352
|
+
* effective block, and run that through the SAME `policyFrom` tail every
|
|
353
|
+
* boundary-config dialect uses — one enforcement path, named.
|
|
354
|
+
*
|
|
355
|
+
* @param {string} registryPath Absolute path of the profiles file.
|
|
356
|
+
* @param {string} profileName The profile to resolve.
|
|
357
|
+
* @param {string} sourceLabel What failed, named in the thrown message.
|
|
358
|
+
* @param {{readFile?: (path: string) => string|null}} [io]
|
|
359
|
+
* @returns {{depConstraints: object[], options: object, suppressions: object[], fitness?: object[]}}
|
|
360
|
+
* @throws {Error} when the registry or the named profile is defective.
|
|
361
|
+
*/
|
|
362
|
+
export function profilePolicy(registryPath, profileName, sourceLabel, io = {}) {
|
|
363
|
+
const registry = loadProfileRegistry(registryPath, io);
|
|
364
|
+
const effective = resolveProfile(registry.profiles, profileName);
|
|
365
|
+
return policyFrom(effective, `${sourceLabel} (profile "${profileName}")`);
|
|
366
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provenance record: WHO decided a governance row, with WHICH tool, and
|
|
3
|
+
* optionally WHEN — the auditable half of decision provenance.
|
|
4
|
+
*
|
|
5
|
+
* ## Shape
|
|
6
|
+
*
|
|
7
|
+
* ```json
|
|
8
|
+
* { "by": "jane@example.com", "tool": "archkeep:v1", "on": "2026-08-16" }
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* `by` and `tool` are required non-empty strings; `on` is optional and is only
|
|
12
|
+
* ever PRODUCED by the shared clock (`./clock.mjs`). This module has two
|
|
13
|
+
* surfaces, and the split is the determinism rule made concrete:
|
|
14
|
+
*
|
|
15
|
+
* - **Read** — `originViolations`/`validateOrigin` shape-check an `origin`
|
|
16
|
+
* already committed in a declaration file. An `on` present there is a static
|
|
17
|
+
* fact about committed bytes, byte-identical across every read regardless of
|
|
18
|
+
* wall clock, so no clock is needed to read it.
|
|
19
|
+
* - **Write** — `recordOrigin` is the ONLY producer of `on`. It refuses to run
|
|
20
|
+
* without a clock, loudly, and calls `clock.now()` exactly once for the
|
|
21
|
+
* record. A workspace that omits `on` entirely calls `recordOrigin` with the
|
|
22
|
+
* same clock and gets byte-identical bytes across runs; a workspace that
|
|
23
|
+
* records `on` hands a hermetic clock (a build id, a pinned value) so the
|
|
24
|
+
* claim is reproducible.
|
|
25
|
+
*
|
|
26
|
+
* ## Determinism — asserted both ways
|
|
27
|
+
*
|
|
28
|
+
* - Two runs over unchanged rows with the same clock are byte-identical.
|
|
29
|
+
* - Two runs with different clocks differ — the clock is the single
|
|
30
|
+
* non-determinism door, which is exactly why it is the only door.
|
|
31
|
+
*
|
|
32
|
+
* ## Null-prototype safety
|
|
33
|
+
*
|
|
34
|
+
* `origin` and every nested object are read through plain-object checks that
|
|
35
|
+
* reject arrays and non-plain objects, and enumeration uses `Object.keys`
|
|
36
|
+
* (own, enumerable, never the prototype chain) — a crafted `{"__proto__": …}`
|
|
37
|
+
* or a polluted prototype cannot smuggle keys into a validated origin. This
|
|
38
|
+
* module builds nothing from untrusted keys; it validates and, at write time,
|
|
39
|
+
* builds a fresh object with only the three permitted keys.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { clockViolations } from "./clock.mjs";
|
|
43
|
+
|
|
44
|
+
/** The only keys a validated `origin` may carry. */
|
|
45
|
+
export const ORIGIN_KEYS = Object.freeze(["by", "tool", "on"]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {object} OriginRecord
|
|
49
|
+
* @property {string} by Who decided the row — a non-empty string (a name, an
|
|
50
|
+
* email, a handle). Free form; no format is enforced.
|
|
51
|
+
* @property {string} tool Which tool or process recorded the decision — a
|
|
52
|
+
* non-empty string (`archkeep:v1`, `claude`, an ADR editor). Free form.
|
|
53
|
+
* @property {string} [on] When the decision was recorded — present ONLY when
|
|
54
|
+
* `recordOrigin` produced it through the shared clock.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** @type {(value: unknown) => value is Record<string, unknown>} */
|
|
58
|
+
const isPlainObject = (value) =>
|
|
59
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
60
|
+
|
|
61
|
+
/** A value's type, for an error message that shows what was actually there. */
|
|
62
|
+
function describe(value) {
|
|
63
|
+
if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
|
|
64
|
+
if (value === null) return "null";
|
|
65
|
+
return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Everything wrong with a raw `origin` record at READ time, as messages; empty
|
|
70
|
+
* when it is well-formed. Shape only — an `on` committed in a declaration file
|
|
71
|
+
* is a static fact, so no clock is needed to read it.
|
|
72
|
+
*
|
|
73
|
+
* An `io.clock` may be supplied by a caller in a WRITE context (a command that
|
|
74
|
+
* is about to emit `on`, or a test pinning determinism); it is then validated
|
|
75
|
+
* itself and every `on` must equal its answer — a raw `on` can never ride
|
|
76
|
+
* along beside a clock that would say something else.
|
|
77
|
+
*
|
|
78
|
+
* @param {unknown} raw
|
|
79
|
+
* @param {{clock?: import("./clock.mjs").Clock}} [io]
|
|
80
|
+
* @returns {string[]}
|
|
81
|
+
*/
|
|
82
|
+
export function originViolations(raw, io = {}) {
|
|
83
|
+
if (!isPlainObject(raw)) {
|
|
84
|
+
return [`origin must be an object, got ${describe(raw)}`];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const violations = [];
|
|
88
|
+
const allowed = ORIGIN_KEYS;
|
|
89
|
+
for (const key of Object.keys(raw)) {
|
|
90
|
+
if (!allowed.includes(key)) {
|
|
91
|
+
violations.push(
|
|
92
|
+
`origin.${key}: unknown key — an origin may carry only ${allowed.join(", ")}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (typeof raw.by !== "string" || raw.by.trim() === "") {
|
|
98
|
+
violations.push(
|
|
99
|
+
`origin.by: must be a non-empty string naming who decided, got ${describe(raw.by)}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (typeof raw.tool !== "string" || raw.tool.trim() === "") {
|
|
103
|
+
violations.push(
|
|
104
|
+
`origin.tool: must be a non-empty string naming the tool that recorded the decision, got ${describe(raw.tool)}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if ("on" in raw && (typeof raw.on !== "string" || raw.on.trim() === "")) {
|
|
108
|
+
violations.push(`origin.on: must be a non-empty string when present, got ${describe(raw.on)}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Write-context strictness: a caller that supplied a clock is about to emit
|
|
112
|
+
// `on`, and the clock is the single door.
|
|
113
|
+
if (io.clock !== undefined) {
|
|
114
|
+
violations.push(...clockViolations(io.clock));
|
|
115
|
+
if ("on" in raw && raw.on !== io.clock.now()) {
|
|
116
|
+
violations.push(
|
|
117
|
+
`origin.on: must equal the clock's answer (${JSON.stringify(io.clock.now())}), got ${describe(raw.on)} — the clock is the only producer of 'on'`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return violations;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Shape-validates an `origin` at read time and returns the row's own object
|
|
127
|
+
* when valid; throws one Error naming every violation otherwise.
|
|
128
|
+
*
|
|
129
|
+
* Never walks the prototype chain and never builds from untrusted keys, so a
|
|
130
|
+
* crafted `__proto__` cannot leak into a fresh object.
|
|
131
|
+
*
|
|
132
|
+
* @param {unknown} raw
|
|
133
|
+
* @param {{clock?: import("./clock.mjs").Clock}} [io]
|
|
134
|
+
* @param {string} at Dotted path of the field, for the message.
|
|
135
|
+
* @returns {OriginRecord}
|
|
136
|
+
* @throws {Error} naming every violation at once, prefixed by `at`.
|
|
137
|
+
*/
|
|
138
|
+
export function validateOrigin(raw, io = {}, at = "origin") {
|
|
139
|
+
const violations = originViolations(raw, io).map((message) =>
|
|
140
|
+
message.startsWith("origin.")
|
|
141
|
+
? `${at}.${message.slice("origin.".length)}`
|
|
142
|
+
: `${at}: ${message}`,
|
|
143
|
+
);
|
|
144
|
+
if (violations.length > 0) {
|
|
145
|
+
throw new Error(violations.join("; "));
|
|
146
|
+
}
|
|
147
|
+
return /** @type {OriginRecord} */ (raw);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The one way a governance record obtains `on`: through the shared clock, and
|
|
152
|
+
* nowhere else.
|
|
153
|
+
*
|
|
154
|
+
* @param {{by: string, tool: string, clock: import("./clock.mjs").Clock}} author
|
|
155
|
+
* The two required fields (shape checked like any read-time origin) plus the
|
|
156
|
+
* clock that supplies `on`. `clock` is required — an `on` produced without a
|
|
157
|
+
* clock is the non-determinism this module exists to exclude, so the absence
|
|
158
|
+
* is a loud Error, never a default.
|
|
159
|
+
* @returns {OriginRecord} `{by, tool, on: clock.now()}`, and ONLY those three
|
|
160
|
+
* keys — a fresh object, so nothing from untrusted input rides along.
|
|
161
|
+
* @throws {Error} on an invalid author, an unusable clock, or a
|
|
162
|
+
* non-string/empty clock answer.
|
|
163
|
+
*/
|
|
164
|
+
export function recordOrigin({ by, tool, clock }) {
|
|
165
|
+
const origin = { by, tool };
|
|
166
|
+
const shape = originViolations(origin);
|
|
167
|
+
if (shape.length > 0) {
|
|
168
|
+
throw new Error(shape.join("; "));
|
|
169
|
+
}
|
|
170
|
+
const clockProblems = clockViolations(clock);
|
|
171
|
+
if (clockProblems.length > 0) {
|
|
172
|
+
throw new Error(`origin.on: ${clockProblems.join("; ")}`);
|
|
173
|
+
}
|
|
174
|
+
// The clock is the single door, and it is called exactly once for this
|
|
175
|
+
// record, so two calls with the same clock are byte-identical.
|
|
176
|
+
return { by, tool, on: clock.now() };
|
|
177
|
+
}
|