@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.
Files changed (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. 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
+ }