@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,539 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ADR registry: `docs/adr/NNN-slug.md` files, parsed into one index an
|
|
3
|
+
* enforcer can resolve `decisionRef` against.
|
|
4
|
+
*
|
|
5
|
+
* An ADR (architecture decision record) is a Markdown file in the workspace's
|
|
6
|
+
* `docs/adr/` directory named `<NNN>-<slug>.md` — `NNN` a zero-padded number
|
|
7
|
+
* of at least three digits, `<slug>` a short dash-separated name. Each file
|
|
8
|
+
* declares, in frontmatter (the `---`-delimited block at the top, kept
|
|
9
|
+
* deliberately minimal), the fields an enforcer needs to make the record
|
|
10
|
+
* *enforceable*:
|
|
11
|
+
*
|
|
12
|
+
* - `id` — the record's own identity. Optional; when present it MUST equal the
|
|
13
|
+
* filename's id. The filesystem is the source of truth, so a file whose
|
|
14
|
+
* frontmatter id disagrees with its name is a loud error, never a drift the
|
|
15
|
+
* registry guesses at.
|
|
16
|
+
* - `status` — `proposed` (default), `accepted`, or `superseded`.
|
|
17
|
+
* - `supersedes` — optional list of ADR ids this record replaces, giving the
|
|
18
|
+
* supersession chain.
|
|
19
|
+
* - `bindings` — optional list of rule/fitness ids this ADR makes enforceable:
|
|
20
|
+
* the objects its decision binds. An ADR with no `bindings` is recorded but
|
|
21
|
+
* not yet enforceable; the moment a rule/fitness carries `decisionRef`
|
|
22
|
+
* naming it, the two sides of the binding exist.
|
|
23
|
+
*
|
|
24
|
+
* Frontmatter is a strict, minimal dialect — `key: value` lines, and list
|
|
25
|
+
* fields as `- item` continuation lines. It is never full YAML and never JSON
|
|
26
|
+
* (the same decision the intent model makes for `architecture-intent.json`: no
|
|
27
|
+
* parser leniency for this tool's own files).
|
|
28
|
+
*
|
|
29
|
+
* The invariant (`../../../../AGENTS.md`): an empty result must mean "no
|
|
30
|
+
* violation", and nothing else. For the registry that means four things:
|
|
31
|
+
*
|
|
32
|
+
* - **The registry is deterministic.** Files are read in byte-sorted filename
|
|
33
|
+
* order and every emitted list is sorted, so two runs over an unchanged
|
|
34
|
+
* `docs/adr/` produce byte-identical output.
|
|
35
|
+
* - **An unreadable registry is a loud failure, never an empty one.** A
|
|
36
|
+
* `docs/adr/` directory that exists but holds a file that will not parse, a
|
|
37
|
+
* duplicate id, a status outside the three, an unknown frontmatter key, or
|
|
38
|
+
* a `supersedes`/`bindings` entry that is not what the field requires —
|
|
39
|
+
* any of those throws, so a caller can never mistake "could not read the
|
|
40
|
+
* registry" for "no ADRs".
|
|
41
|
+
* - **A `decisionRef` that does not resolve is `unknown`, never `pass`.** The
|
|
42
|
+
* registry's `resolveDecisionRef` answers the two-name space — an ADR id
|
|
43
|
+
* (matching a file) or a rule/fitness id the workspace declares. Anything
|
|
44
|
+
* else is unknown, and the caller reports unknown (never clean).
|
|
45
|
+
* - **The registry trusts only git-tracked, in-workspace bytes.** A
|
|
46
|
+
* `docs/adr/` directory entry the tracked tree does not know about — a
|
|
47
|
+
* gitignored scratch file with an ADR-shaped name — is excluded exactly as
|
|
48
|
+
* if it were never there: no record, no id claimed, no error. So is a
|
|
49
|
+
* directory entry whose NAME is tracked but whose current bytes are not: a
|
|
50
|
+
* symlink (committed as one, or swapped in locally after the tracked-name
|
|
51
|
+
* check ran) whose target resolves outside the workspace root. Either way
|
|
52
|
+
* `resolveDecisionRef` answers `unknown` for the id, never `adr` — the same
|
|
53
|
+
* answer a name nobody ever wrote gets, so a planted file cannot make a
|
|
54
|
+
* `decisionRef` resolve against bytes this workspace never reviewed, with
|
|
55
|
+
* manual lookup the only thing that would otherwise have caught it.
|
|
56
|
+
* `../architecture-intent/model.mjs`'s `loadIntent` makes the identical call
|
|
57
|
+
* for `architecture-intent.json` (see its own header), and its `tracked`
|
|
58
|
+
* parameter is this module's `io.tracked` by another name. `docs/reference/adr.md`
|
|
59
|
+
* and `docs/usage/adr.md` already describe the registry as reading "the
|
|
60
|
+
* tracked `docs/adr/`" — this is what makes that sentence true.
|
|
61
|
+
*
|
|
62
|
+
* ## Remote lookup is opt-in and must never change local resolution
|
|
63
|
+
*
|
|
64
|
+
* A workspace may consult a remote catalog of decisions (an HTTP endpoint)
|
|
65
|
+
* that knows ADRs the local `docs/adr/` does not. Local knowledge always wins:
|
|
66
|
+
* a `decisionRef` the local registry already resolves stays resolved, and only
|
|
67
|
+
* an id the local tree does not know may be asked of the remote. A remote
|
|
68
|
+
* failure resolves nothing and throws nothing — an opt-in convenience must
|
|
69
|
+
* never make an enforceable rule unenforceable. `referenceTime()` stamps a
|
|
70
|
+
* fetch so a remote answer carries the moment it was taken.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
74
|
+
import { join } from "node:path";
|
|
75
|
+
|
|
76
|
+
import { containmentViolation } from "../containment.mjs";
|
|
77
|
+
|
|
78
|
+
/** The directory, relative to a workspace root, where ADR files live. */
|
|
79
|
+
export const ADR_DIR = "docs/adr";
|
|
80
|
+
|
|
81
|
+
/** The three statuses a record may carry. Any other value is a load error. */
|
|
82
|
+
export const ADR_STATUSES = Object.freeze(["proposed", "accepted", "superseded"]);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Matches a valid ADR filename. The number is at least three digits so the
|
|
86
|
+
* format outgrows 999 records without breaking; the slug is dash-separated
|
|
87
|
+
* lowercase words.
|
|
88
|
+
*/
|
|
89
|
+
export const ADR_FILE_PATTERN = /^(\d{3,})-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$/u;
|
|
90
|
+
|
|
91
|
+
/** An ADR id — `NNN-slug` — must match the filename it lives in. */
|
|
92
|
+
export const ADR_ID_PATTERN = /^\d{3,}-[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
93
|
+
|
|
94
|
+
/** The frontmatter keys a record file may carry. */
|
|
95
|
+
const FRONTMATTER_KEYS = Object.freeze(["id", "status", "supersedes", "bindings"]);
|
|
96
|
+
|
|
97
|
+
/** A value's type, for an error message that shows what was actually there. */
|
|
98
|
+
function describe(value) {
|
|
99
|
+
if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
|
|
100
|
+
if (value === null) return "null";
|
|
101
|
+
return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The `---`-delimited frontmatter block's text, or null when the file has none. */
|
|
105
|
+
function frontmatterBlock(text) {
|
|
106
|
+
if (!text.startsWith("---")) return null;
|
|
107
|
+
const end = text.indexOf("\n---", 3);
|
|
108
|
+
if (end === -1) {
|
|
109
|
+
throw new Error("frontmatter opened by '---' must close with a second '---' line");
|
|
110
|
+
}
|
|
111
|
+
return text.slice(3, end);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Strip a trailing `#` comment from a frontmatter value. */
|
|
115
|
+
function stripInlineComment(value) {
|
|
116
|
+
const hash = value.indexOf(" #");
|
|
117
|
+
return hash === -1 ? value : value.slice(0, hash).trim();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Parse the frontmatter block into a field map. The dialect is strict:
|
|
122
|
+
* `key: value` for scalars, `key:` followed by `- item` lines for lists, `#`
|
|
123
|
+
* for comments. Anything else is a loud parse error — a frontmatter line an
|
|
124
|
+
* enforcer cannot trust must never be silently dropped. That includes a key
|
|
125
|
+
* repeated within the same block: an unconditional `fields[key] = …` write
|
|
126
|
+
* would let the second occurrence silently discard the first — a scalar's
|
|
127
|
+
* earlier value, or, worse, an entire earlier `bindings`/`supersedes` list,
|
|
128
|
+
* since a second `key:` line resets `fields[key]` to a fresh empty array that
|
|
129
|
+
* the following `- item` lines then fill from nothing. So a repeated key
|
|
130
|
+
* throws instead of overwriting.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} text The block between the two `---` delimiters.
|
|
133
|
+
* @param {string} at The record's id, for the message.
|
|
134
|
+
* @returns {Record<string, string|string[]|undefined>}
|
|
135
|
+
* @throws {Error} on a line that is not part of the dialect, including a key
|
|
136
|
+
* that already appears earlier in the same block.
|
|
137
|
+
*/
|
|
138
|
+
export function parseFrontmatterFields(text, at) {
|
|
139
|
+
/** @type {Record<string, string|string[]|undefined>} */
|
|
140
|
+
const fields = {};
|
|
141
|
+
let currentList = null;
|
|
142
|
+
for (const line of text.split("\n")) {
|
|
143
|
+
const trimmed = line.trim();
|
|
144
|
+
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
145
|
+
|
|
146
|
+
const item = /^-\s+(.*)$/u.exec(trimmed);
|
|
147
|
+
if (item) {
|
|
148
|
+
if (currentList === null) {
|
|
149
|
+
throw new Error(`${at}: list item "${trimmed}" appears before any "key:" line`);
|
|
150
|
+
}
|
|
151
|
+
/** @type {string[]} */ (fields[currentList]).push(stripInlineComment(item[1]));
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const key = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/u.exec(trimmed);
|
|
156
|
+
if (key) {
|
|
157
|
+
if (Object.hasOwn(fields, key[1])) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`${at}: duplicate frontmatter key "${key[1]}" — the first occurrence would be silently overwritten`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const raw = key[2].trim();
|
|
163
|
+
if (raw === "") {
|
|
164
|
+
fields[key[1]] = [];
|
|
165
|
+
currentList = key[1];
|
|
166
|
+
} else {
|
|
167
|
+
fields[key[1]] = stripInlineComment(raw);
|
|
168
|
+
currentList = null;
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
throw new Error(`${at}: cannot parse frontmatter line "${trimmed}"`);
|
|
174
|
+
}
|
|
175
|
+
return fields;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The list a field holds: already an array (a `key:` list), or a
|
|
180
|
+
* comma-separated inline list written `key: a, b`. Both spellings stay legal.
|
|
181
|
+
*
|
|
182
|
+
* @param {string|string[]|undefined} value
|
|
183
|
+
* @returns {string[]}
|
|
184
|
+
*/
|
|
185
|
+
function toList(value) {
|
|
186
|
+
if (value === undefined) return [];
|
|
187
|
+
if (Array.isArray(value)) return value.map((entry) => entry.trim());
|
|
188
|
+
return value
|
|
189
|
+
.split(",")
|
|
190
|
+
.map((entry) => entry.trim())
|
|
191
|
+
.filter((entry) => entry !== "");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* One parsed record, every field validated. A record an enforcer cannot trust
|
|
196
|
+
* must never be read as an absent one (the invariant), so every malformed
|
|
197
|
+
* field throws here rather than degrading the record.
|
|
198
|
+
*
|
|
199
|
+
* @param {{id: string, frontmatter: string|null}} parsed The filename-derived
|
|
200
|
+
* id and the frontmatter block (null when the file has none).
|
|
201
|
+
* @returns {{id: string, status: string, supersedes: string[], bindings: string[]}}
|
|
202
|
+
* @throws {Error} naming every violation at once.
|
|
203
|
+
*/
|
|
204
|
+
export function validateRecord({ id, frontmatter }) {
|
|
205
|
+
const fields = frontmatter === null ? {} : parseFrontmatterFields(frontmatter, id);
|
|
206
|
+
const violations = [];
|
|
207
|
+
|
|
208
|
+
for (const key of Object.keys(fields)) {
|
|
209
|
+
if (!FRONTMATTER_KEYS.includes(key)) {
|
|
210
|
+
violations.push(
|
|
211
|
+
`${id}: unknown frontmatter key "${key}" — a record may carry only ${FRONTMATTER_KEYS.join(", ")}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (fields.id !== undefined && fields.id !== id) {
|
|
217
|
+
violations.push(
|
|
218
|
+
`${id}: frontmatter id "${fields.id}" disagrees with the filename's "${id}" — the ` +
|
|
219
|
+
`registry keys on filenames, so rename the file or fix the id`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (
|
|
224
|
+
fields.status !== undefined &&
|
|
225
|
+
!ADR_STATUSES.includes(/** @type {string} */ (fields.status))
|
|
226
|
+
) {
|
|
227
|
+
violations.push(`${id}: status "${fields.status}" is not one of ${ADR_STATUSES.join(", ")}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (const ref of toList(fields.supersedes)) {
|
|
231
|
+
if (!ADR_ID_PATTERN.test(ref)) {
|
|
232
|
+
violations.push(`${id}: supersedes entry ${describe(ref)} is not an ADR id`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const ref of toList(fields.bindings)) {
|
|
237
|
+
if (ref === "") {
|
|
238
|
+
violations.push(`${id}: bindings has an empty entry — a binding must name a rule/fitness id`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (violations.length > 0) {
|
|
243
|
+
throw new Error(`archkeep: malformed ADR registry:\n ${violations.join("\n ")}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
id,
|
|
248
|
+
status: typeof fields.status === "string" ? fields.status : "proposed",
|
|
249
|
+
supersedes: toList(fields.supersedes),
|
|
250
|
+
bindings: toList(fields.bindings),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Read and index every ADR file under `root/docs/adr/`. Deterministic:
|
|
256
|
+
* filenames are byte-sorted, and every list in the returned records is already
|
|
257
|
+
* in the order the source file stated (kept stable — the registry never
|
|
258
|
+
* reorders what a record declares).
|
|
259
|
+
*
|
|
260
|
+
* An absent `docs/adr/` is an empty registry — a workspace that has not
|
|
261
|
+
* adopted ADRs yet is not a failure, and has nothing to resolve. A directory
|
|
262
|
+
* that exists but holds an unreadable file, a malformed record, or a duplicate
|
|
263
|
+
* id throws; the caller maps that to exit 3, never to an empty list.
|
|
264
|
+
*
|
|
265
|
+
* @param {string} root Absolute workspace root.
|
|
266
|
+
* @param {{readdirSync?: (path: string) => string[], readFileSync?: (path: string, encoding: "utf8") => string,
|
|
267
|
+
* lstatSync?: (path: string) => {isSymbolicLink: () => boolean}, realpathSync?: (path: string) => string,
|
|
268
|
+
* tracked?: string[]}} [io]
|
|
269
|
+
* Injectable filesystem seams, defaulting to the sync `node:fs` calls this
|
|
270
|
+
* module uses so the CLI stays event-loop-simple. Tests inject an in-memory
|
|
271
|
+
* tree. `tracked` is the `git ls-files` list (`../workspace.mjs`'s
|
|
272
|
+
* `listTrackedFiles`); when provided, a directory entry whose `docs/adr/<name>`
|
|
273
|
+
* path is not in it is excluded before it is ever validated — see this
|
|
274
|
+
* module's header for why, and `../architecture-intent/model.mjs`'s
|
|
275
|
+
* `loadIntent` for the identical `tracked` contract this one mirrors. The
|
|
276
|
+
* `lstatSync`/`realpathSync` seams feed
|
|
277
|
+
* `../containment.mjs`'s `containmentViolation`, which resolves the deepest
|
|
278
|
+
* existing ancestor through every intermediate component — so a symlinked
|
|
279
|
+
* `docs/adr/` directory is excluded the same way an escaping entry file is.
|
|
280
|
+
* @returns {{records: object[], byId: Map<string, object>}}
|
|
281
|
+
* @throws {Error} on an unreadable registry.
|
|
282
|
+
*/
|
|
283
|
+
export function loadAdrRegistry(root, io = {}) {
|
|
284
|
+
const readDir = io.readdirSync ?? readdirSync;
|
|
285
|
+
const readFile = io.readFileSync ?? readFileSync;
|
|
286
|
+
const lstat = io.lstatSync ?? lstatSync;
|
|
287
|
+
const realpath = io.realpathSync ?? realpathSync;
|
|
288
|
+
const dir = join(root, ADR_DIR);
|
|
289
|
+
|
|
290
|
+
/** @type {string[]} */
|
|
291
|
+
let names;
|
|
292
|
+
try {
|
|
293
|
+
// Every entry, unfiltered: the ONLY filename verdict this loader makes is
|
|
294
|
+
// the `ADR_FILE_PATTERN` refusal below. An `.endsWith(".md")` pre-filter
|
|
295
|
+
// here used to drop `0002-cased.MD` and `0003-thing.markdown` before that
|
|
296
|
+
// throw could see them — an ADR-shaped record the author wrote, silently
|
|
297
|
+
// absent from the index, so `resolveDecisionRef` answered `unknown` for an
|
|
298
|
+
// id sitting in the tree and the reverse lookup answered "no ADR in
|
|
299
|
+
// docs/adr/ binds rule:X — it is not enforced by any recorded decision"
|
|
300
|
+
// about a binding the registry had simply refused to look at. Two
|
|
301
|
+
// filters deciding the same question is how one of them goes quiet
|
|
302
|
+
// (`../../../../AGENTS.md`). `README.md` reaches that same throw, by the
|
|
303
|
+
// same rule, and always did: this directory is the registry, so a file in
|
|
304
|
+
// it that is not a record is a thing to say out loud, not to drop. The
|
|
305
|
+
// `io.tracked` filter below is a different question — whether the bytes
|
|
306
|
+
// are the reviewed repository state — and stays the one exclusion that is
|
|
307
|
+
// deliberately silent, for the reason this module's header states.
|
|
308
|
+
names = readDir(dir);
|
|
309
|
+
} catch (cause) {
|
|
310
|
+
if (cause?.code === "ENOENT") return { records: [], byId: new Map() };
|
|
311
|
+
throw new Error(`archkeep: cannot read ${ADR_DIR}: ${cause?.message ?? cause}`, { cause });
|
|
312
|
+
}
|
|
313
|
+
names.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
314
|
+
|
|
315
|
+
if (io.tracked !== undefined) {
|
|
316
|
+
const tracked = new Set(io.tracked);
|
|
317
|
+
names = names.filter((name) => tracked.has(`${ADR_DIR}/${name}`));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const records = [];
|
|
321
|
+
const byId = new Map();
|
|
322
|
+
for (const name of names) {
|
|
323
|
+
const match = ADR_FILE_PATTERN.exec(name);
|
|
324
|
+
if (!match) {
|
|
325
|
+
throw new Error(
|
|
326
|
+
`archkeep: ${ADR_DIR}/${name} is not a valid ADR filename — an ADR file must be ` +
|
|
327
|
+
`named NNN-slug.md (zero-padded number of at least three digits, then a dash-separated slug)`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const id = `${match[1]}-${match[2]}`;
|
|
331
|
+
if (byId.has(id)) {
|
|
332
|
+
throw new Error(`archkeep: ${ADR_DIR} holds more than one file for id "${id}"`);
|
|
333
|
+
}
|
|
334
|
+
const filePath = join(dir, name);
|
|
335
|
+
// A tracked NAME says nothing about what currently sits on disk at that
|
|
336
|
+
// path: a symlink committed at mode 120000, one swapped in locally after
|
|
337
|
+
// the `tracked` filter above ran, or — the intermediate case — a
|
|
338
|
+
// symlinked `docs/adr/` itself, whose entries all pass the `tracked` filter
|
|
339
|
+
// as strings while `readdir` hands back the target's bytes. `../containment.mjs`'s
|
|
340
|
+
// `containmentViolation` walks the realpath of the deepest existing
|
|
341
|
+
// ancestor through every intermediate component, so it catches all three;
|
|
342
|
+
// the local `escapesWorkspace` it replaced only lstat'd the final file and
|
|
343
|
+
// let the intermediate case through, the same escape the write guard in
|
|
344
|
+
// `../../cli.mjs`'s `writeOutputReport` refuses (G-10). Excluded exactly
|
|
345
|
+
// like an untracked file — this module's header explains why silence here
|
|
346
|
+
// is the honest answer rather than a thrown error.
|
|
347
|
+
if (
|
|
348
|
+
// The real-fs guard `../architecture-intent/model.mjs`'s `loadIntent`
|
|
349
|
+
// uses for the identical reason: an injected in-memory reader a test
|
|
350
|
+
// drives is keyed by a fixture path that does not exist on disk, and
|
|
351
|
+
// probing a nonexistent root's ancestry would walk up to a real parent
|
|
352
|
+
// directory and misread it as an escape. Real roots only.
|
|
353
|
+
existsSync(root) &&
|
|
354
|
+
containmentViolation(root, filePath, { lstatSync: lstat, realpathSync: realpath }) !== null
|
|
355
|
+
) {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
let text;
|
|
359
|
+
try {
|
|
360
|
+
text = readFile(filePath, "utf8");
|
|
361
|
+
} catch (cause) {
|
|
362
|
+
throw new Error(`archkeep: cannot read ${ADR_DIR}/${name}: ${cause?.message ?? cause}`, {
|
|
363
|
+
cause,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
const record = validateRecord({ id, frontmatter: frontmatterBlock(text) });
|
|
367
|
+
byId.set(id, record);
|
|
368
|
+
records.push(record);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return { records, byId };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The set of every rule/fitness id the workspace's ADRs bind — the ids that
|
|
376
|
+
* appear in any record's `bindings`. A `decisionRef` naming one of these names
|
|
377
|
+
* a rule/fitness the ADR registry makes enforceable; `adrsBinding` answers
|
|
378
|
+
* which record(s) bind it.
|
|
379
|
+
*
|
|
380
|
+
* @param {object[]} records
|
|
381
|
+
* @returns {Set<string>}
|
|
382
|
+
*/
|
|
383
|
+
export function boundFitnessIds(records) {
|
|
384
|
+
const ids = new Set();
|
|
385
|
+
for (const record of records) {
|
|
386
|
+
for (const binding of record.bindings) ids.add(binding);
|
|
387
|
+
}
|
|
388
|
+
return ids;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The ADR records that bind a given rule/fitness id — the reverse lookup a
|
|
393
|
+
* binding-aware surface uses to show where a rule becomes enforceable.
|
|
394
|
+
*
|
|
395
|
+
* @param {object[]} records
|
|
396
|
+
* @param {string} fitnessId
|
|
397
|
+
* @returns {string[]} ADR ids, in registry order.
|
|
398
|
+
*/
|
|
399
|
+
export function adrsBinding(records, fitnessId) {
|
|
400
|
+
return records.filter((record) => record.bindings.includes(fitnessId)).map((record) => record.id);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Strips the `adr:` prefix this tool's own governance-row docs recommend as
|
|
405
|
+
* an alternate ADR-id spelling (`../governance/row-schema.mjs`'s
|
|
406
|
+
* `decisionRef` field docs and its "does not resolve" error text both show
|
|
407
|
+
* `"adr:0012"` beside the bare `0012-slug` form as an equally valid ADR id).
|
|
408
|
+
* The registry never keys a record on that spelling — `validateRecord`
|
|
409
|
+
* derives every `byId` key from the filename alone, and a frontmatter `id`
|
|
410
|
+
* carrying the prefix would already fail the "must equal the filename" check
|
|
411
|
+
* — so a lookup that does not strip it first can never match, no matter how
|
|
412
|
+
* real the record is: the one spelling this tool itself suggests would be the
|
|
413
|
+
* one spelling that silently fails to resolve. Any other ref — including one
|
|
414
|
+
* that merely differs in case, like `ADR:` — is returned unchanged: only the
|
|
415
|
+
* exact documented spelling is an alias, never a fuzzy match that would hide
|
|
416
|
+
* a genuine typo behind a "resolved" answer.
|
|
417
|
+
*
|
|
418
|
+
* @param {string} ref
|
|
419
|
+
* @returns {string}
|
|
420
|
+
*/
|
|
421
|
+
export function stripAdrPrefix(ref) {
|
|
422
|
+
return ref.startsWith("adr:") ? ref.slice(4) : ref;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The two-name-space resolution a `decisionRef` answers. Local knowledge
|
|
427
|
+
* always wins: an ADR id (matching a file in the registry, written bare or
|
|
428
|
+
* with the `adr:` prefix `stripAdrPrefix` strips) or a rule/fitness id the
|
|
429
|
+
* workspace declares in `knownFitness`. Anything else is unknown.
|
|
430
|
+
*
|
|
431
|
+
* The fitness half strips the documented `rule:`/`fitness:` prefixes
|
|
432
|
+
* (`../governance/row-schema.mjs`'s own decisionRef docs show both spellings)
|
|
433
|
+
* before the membership test: `knownFitness` holds the DECLARED names —
|
|
434
|
+
* a policy's `fitness` export names (`"hotspot"`), never prefixed strings —
|
|
435
|
+
* and a citation is the two spellings a row author can write. A ref that is
|
|
436
|
+
* neither an ADR id nor a declared name — including one that merely prefixes
|
|
437
|
+
* an undeclared name — is unknown, never a fuzzy match that would hide a
|
|
438
|
+
* typo behind a "resolved" answer (the same near-miss rule `stripAdrPrefix`
|
|
439
|
+
* documents for the ADR half).
|
|
440
|
+
*
|
|
441
|
+
* @param {Map<string, object>} byId The local registry index.
|
|
442
|
+
* @param {Set<string>} knownFitness Rule/fitness ids the workspace declares.
|
|
443
|
+
* @param {string} ref The decisionRef value.
|
|
444
|
+
* @returns {"adr"|"fitness"|"unknown"}
|
|
445
|
+
*/
|
|
446
|
+
export function resolveDecisionRef(byId, knownFitness, ref) {
|
|
447
|
+
if (byId.has(stripAdrPrefix(ref))) return "adr";
|
|
448
|
+
if (knownFitness.has(stripRuleFitnessPrefix(ref))) return "fitness";
|
|
449
|
+
return "unknown";
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Strips the `rule:`/`fitness:` prefix a governance-row `decisionRef` may
|
|
454
|
+
* carry (`../governance/row-schema.mjs` documents both spellings beside the
|
|
455
|
+
* bare name). Only the exact documented lowercase spellings are aliases —
|
|
456
|
+
* a differently-cased `RULE:x` is a near-miss like any other, never a fuzzy
|
|
457
|
+
* match — and a name already bare passes through unchanged.
|
|
458
|
+
*
|
|
459
|
+
* @param {string} ref
|
|
460
|
+
* @returns {string}
|
|
461
|
+
*/
|
|
462
|
+
export function stripRuleFitnessPrefix(ref) {
|
|
463
|
+
return ref.startsWith("rule:") || ref.startsWith("fitness:")
|
|
464
|
+
? ref.slice(ref.indexOf(":") + 1)
|
|
465
|
+
: ref;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The rule/fitness ids a policy DECLARES — the `fitness` export's `name`
|
|
470
|
+
* fields on the loaded boundary config (F04: a `decisionRef` claiming a
|
|
471
|
+
* fitness rule must be judged against the ids the executed policy actually
|
|
472
|
+
* declares, never against the ADRs' own `bindings` lists, which let a
|
|
473
|
+
* citation resolve itself). Absent when the policy declares none — and then
|
|
474
|
+
* no `fitness:`-shaped ref can ever resolve, which is the correct answer: a
|
|
475
|
+
* rule that cannot be measured is no more bound than one that does not exist.
|
|
476
|
+
*
|
|
477
|
+
* A `fitness` export that is not an array, or that holds a row that is not a
|
|
478
|
+
* plain object, is malformed — `findFitnessViolations`
|
|
479
|
+
* (`./fitness-registry.mjs`) is what reports that, by name. This function
|
|
480
|
+
* runs BEFORE that validation (`findBoundaryConfigViolations` builds its
|
|
481
|
+
* default `io.resolve` from this, ahead of the `findFitnessViolations` call),
|
|
482
|
+
* so it must never throw on a shape the validator has not yet had a chance to
|
|
483
|
+
* name: a `.map` on a non-array, or a `.name` read off a non-object row,
|
|
484
|
+
* would surface as a raw, unprefixed `TypeError` instead of the contracted
|
|
485
|
+
* `archkeep: <path> is malformed: fitness: …` message — loud, but naming
|
|
486
|
+
* nothing, which is its own silent-direction failure (`../../../../AGENTS.md`).
|
|
487
|
+
* Defensive here does not mean silent: an unusable `fitness` still yields no
|
|
488
|
+
* declared names, so a row citing one resolves to "unknown" exactly as it
|
|
489
|
+
* would once `findFitnessViolations` reports the malformed shape and the run
|
|
490
|
+
* exits non-zero.
|
|
491
|
+
*
|
|
492
|
+
* @param {unknown} config The loaded boundary config, or `null`/`undefined`
|
|
493
|
+
* when a caller has none, or any other shape a not-yet-validated `fitness`
|
|
494
|
+
* export may carry.
|
|
495
|
+
* @returns {Set<string>}
|
|
496
|
+
*/
|
|
497
|
+
export function declaredFitnessNames(config) {
|
|
498
|
+
const fitness = /** @type {{fitness?: unknown}} */ (config)?.fitness;
|
|
499
|
+
if (!Array.isArray(fitness)) return new Set();
|
|
500
|
+
const names = [];
|
|
501
|
+
for (const row of fitness) {
|
|
502
|
+
if (row !== null && typeof row === "object" && typeof row.name === "string") {
|
|
503
|
+
names.push(row.name);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return new Set(names);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* `resolveDecisionRef`, applied across a list of governance rows in one pass
|
|
511
|
+
* — the bulk form a report walks once per run rather than re-deriving the
|
|
512
|
+
* same two-name-space check per row owner. `check`'s violations, `context`'s
|
|
513
|
+
* matched constraints, and `drift`'s/`provenance`'s intent and config rows
|
|
514
|
+
* all share this one function, so a `decisionRef` is judged identically
|
|
515
|
+
* everywhere it is rendered. A row with no `decisionRef` — or an empty one,
|
|
516
|
+
* a shape `../governance/row-schema.mjs` already refuses at load time — is
|
|
517
|
+
* skipped: this answers "which CITATIONS are unverifiable", not "which rows
|
|
518
|
+
* are incomplete" (a different question `provenance`'s `hasOrigin` answers).
|
|
519
|
+
*
|
|
520
|
+
* @param {{kind: string, row: object}[]} rows Each row paired with the label
|
|
521
|
+
* its owner uses to identify it (`depConstraints[0]`, `forbidden[2]`, …).
|
|
522
|
+
* @param {Map<string, object>} byId The local ADR registry index.
|
|
523
|
+
* @param {Set<string>} knownFitness Rule/fitness ids the workspace declares.
|
|
524
|
+
* @returns {{kind: string, row: object, decisionRef: string}[]} Empty when
|
|
525
|
+
* every citation resolves — a list, not a bare boolean, so a caller can
|
|
526
|
+
* name which row is unverifiable rather than only that one is
|
|
527
|
+
* (`../../../../AGENTS.md`: an empty result must mean "no violation").
|
|
528
|
+
*/
|
|
529
|
+
export function unresolvedDecisionRefRows(rows, byId, knownFitness) {
|
|
530
|
+
const unresolved = [];
|
|
531
|
+
for (const { kind, row } of rows) {
|
|
532
|
+
const ref = row?.decisionRef;
|
|
533
|
+
if (typeof ref !== "string" || ref.trim() === "") continue;
|
|
534
|
+
if (resolveDecisionRef(byId, knownFitness, ref) === "unknown") {
|
|
535
|
+
unresolved.push({ kind, row, decisionRef: ref });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return unresolved;
|
|
539
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared reference-time clock for every governance capability that emits
|
|
3
|
+
* a timestamp or an age (waivers, debt, health — not the envelope itself,
|
|
4
|
+
* which stays byte-deterministic without one).
|
|
5
|
+
*
|
|
6
|
+
* The contract the evidence format depends on is the one the whole governance
|
|
7
|
+
* wave shares: **determinism and time are resolved by injection, never by
|
|
8
|
+
* absolute wall-clock asserts.** A feature that emits a timestamp takes its
|
|
9
|
+
* reference time from `referenceTime`, and a test drives the same code with a
|
|
10
|
+
* fixed time — so the evidence a run produces is reproducible byte-for-byte
|
|
11
|
+
* over an unchanged tree AND an unchanged injected clock, and a test never
|
|
12
|
+
* depends on the machine it runs on.
|
|
13
|
+
*
|
|
14
|
+
* `referenceTime` returns a stable ISO-8601 UTC instant. It is a function so
|
|
15
|
+
* the injectable default is trivial (delegate to `Date`) and the injectable
|
|
16
|
+
* override is trivial (a constant). Both are the same call shape, so a
|
|
17
|
+
* feature that takes an optional clock reads `clock ?? referenceTime` and
|
|
18
|
+
* works either way.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The reference time as a stable ISO-8601 UTC string.
|
|
23
|
+
*
|
|
24
|
+
* @returns {string} e.g. `"2026-08-16T10:00:00.000Z"`.
|
|
25
|
+
*/
|
|
26
|
+
export function referenceTime() {
|
|
27
|
+
return new Date().toISOString();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The shape a clock must have for a **record-`on`** producer. `now()`
|
|
32
|
+
* returns a non-empty string.
|
|
33
|
+
*
|
|
34
|
+
* @typedef {object} Clock
|
|
35
|
+
* @property {() => string} now
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Refuses a value that is not a usable record clock, naming what was wrong.
|
|
40
|
+
*
|
|
41
|
+
* Loud on purpose: a clock handed to a place that would emit `on` and silently
|
|
42
|
+
* ignored is exactly the silent direction — a record that claims a "when" it
|
|
43
|
+
* never obtained. A caller that passes no clock at all never reaches this
|
|
44
|
+
* check (the `on`-without-clock path is `provenance-record.mjs`'s own
|
|
45
|
+
* refusal).
|
|
46
|
+
*
|
|
47
|
+
* @param {unknown} clock
|
|
48
|
+
* @returns {string[]} Empty when `clock` is a usable clock.
|
|
49
|
+
*/
|
|
50
|
+
export function clockViolations(clock) {
|
|
51
|
+
if (clock === null || typeof clock !== "object") {
|
|
52
|
+
return [`clock must be an object with a now() function, got ${describe(clock)}`];
|
|
53
|
+
}
|
|
54
|
+
const now = /** @type {{now?: unknown}} */ (clock).now;
|
|
55
|
+
if (typeof now !== "function") {
|
|
56
|
+
return ["clock.now must be a function returning a non-empty string"];
|
|
57
|
+
}
|
|
58
|
+
const value = now();
|
|
59
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
60
|
+
return ["clock.now() must return a non-empty string"];
|
|
61
|
+
}
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A value's type, for an error message that shows what was actually there. */
|
|
66
|
+
function describe(value) {
|
|
67
|
+
if (value === null) return "null";
|
|
68
|
+
return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
|
|
69
|
+
}
|