@ecoma-io/archkeep 0.16.1 → 0.18.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/README.md +1 -1
- package/cli.mjs +258 -20
- package/package.json +2 -2
- package/src/architecture-intent/judge.mjs +19 -6
- package/src/commands/adr.mjs +45 -4
- package/src/commands/change-intent.mjs +55 -8
- package/src/commands/change.mjs +332 -11
- package/src/commands/debt.mjs +26 -5
- package/src/commands/decisions.mjs +291 -0
- package/src/commands/delta-classify.mjs +257 -0
- package/src/commands/delta.mjs +269 -8
- package/src/commands/evolution.mjs +758 -5
- package/src/commands/explain.mjs +207 -1
- package/src/commands/history.mjs +81 -5
- package/src/commands/plan-context-command.mjs +163 -2
- package/src/commands/provenance-command.mjs +86 -17
- package/src/commands/provenance.mjs +60 -0
- package/src/commands/report.mjs +48 -1
- package/src/commands/trajectory.mjs +89 -3
- package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
- package/src/governance/adr-registry.mjs +252 -15
- package/src/governance/debt-ledger.mjs +261 -19
- package/src/governance/decision-fitness.mjs +213 -0
- package/src/governance/decision-graph.mjs +483 -0
- package/src/governance/decision-lineage.mjs +250 -0
- package/src/governance/evolution-event.mjs +470 -0
- package/src/governance/evolution-store.mjs +362 -0
- package/src/governance/provenance-record.mjs +150 -0
- package/src/providers/native/model.mjs +18 -4
- package/src/report/adr-text.mjs +109 -4
- package/src/report/change-text.mjs +21 -3
- package/src/report/debt-text.mjs +42 -6
- package/src/report/decisions-text.mjs +164 -0
- package/src/report/delta-text.mjs +36 -1
- package/src/report/evolution-text.mjs +231 -2
- package/src/report/explain-text.mjs +122 -1
- package/src/report/history-text.mjs +9 -3
- package/src/report/plan-context-text.mjs +94 -0
- package/src/report/provenance-text.mjs +67 -1
- package/src/report/report-text.mjs +53 -18
- package/src/report/snapshot-text.mjs +35 -1
- package/src/report/trajectory-text.mjs +30 -1
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The append-only evolution event store (design §3): one event per file,
|
|
3
|
+
* `<NNNN>-<id8>.json`, written atomically and idempotently. `docs/concepts/evolution.md`
|
|
4
|
+
* states the store semantics; this module implements them.
|
|
5
|
+
*
|
|
6
|
+
* Four properties, each with a mechanism:
|
|
7
|
+
*
|
|
8
|
+
* - **Append-only.** There is no update and no delete in this module, by
|
|
9
|
+
* construction — the only write path creates a new file. State change is a
|
|
10
|
+
* new event; rewriting history is impossible.
|
|
11
|
+
* - **Idempotent.** `writeEvent` scans the directory for an existing event
|
|
12
|
+
* with the same `dedupeKey` BEFORE writing. The key is the canonical
|
|
13
|
+
* `{base, head, declarationDigest}` tuple `evolution-event.mjs` defines — the
|
|
14
|
+
* same tuple the id hashes — so a rerun over the same transition finds the
|
|
15
|
+
* earlier event and returns `{duplicate: true}` writing nothing, the
|
|
16
|
+
* always-present `duplicate` sibling `commands/history.mjs`'s capture uses so
|
|
17
|
+
* the envelope shape never depends on directory state. A file that cannot be
|
|
18
|
+
* parsed during the scan throws: a corrupt store must never silently
|
|
19
|
+
* manufacture a duplicate of the record it could not read.
|
|
20
|
+
* - **Atomic.** The write goes to `<path>.json.tmp` opened with `{flag: "wx"}`
|
|
21
|
+
* (refusing rather than following a symlink already sitting there), then
|
|
22
|
+
* `rename` over the final name — the same mechanism as history's
|
|
23
|
+
* `writeSnapshotFile` (`../commands/history.mjs`). Reads filter `.json.tmp`
|
|
24
|
+
* out, so an interrupted write leaves a partial file the store will never
|
|
25
|
+
* read. The final-name rule from containment still applies: the `.tmp` name
|
|
26
|
+
* is not walked by the containment probe, and `wx` is what refuses a planted
|
|
27
|
+
* symlink at it.
|
|
28
|
+
* - **Contained.** A write is checked against the workspace root the same way
|
|
29
|
+
* `--output` is (`../containment.mjs`): `io.root` is REQUIRED, and a path
|
|
30
|
+
* whose intermediate components are workspace-controlled symlinks is refused
|
|
31
|
+
* loudly rather than silently landing outside the tree. A directory the
|
|
32
|
+
* caller names outside the root string is the caller's explicit choice and
|
|
33
|
+
* proceeds, exactly like `--output /tmp`.
|
|
34
|
+
*
|
|
35
|
+
* Sequence numbers are zero-based (`0000`, `0001`, …) and widen from a
|
|
36
|
+
* four-digit minimum rather than overflowing — the same mechanism as history's
|
|
37
|
+
* `nextSequence` (`../commands/history.mjs`), offset by one because a capture
|
|
38
|
+
* sequence is an ordinal while an event log is an index. `shortId` is history's
|
|
39
|
+
* `shortId` (first 8 hex chars of the id).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import {
|
|
43
|
+
mkdirSync as defaultMkdir,
|
|
44
|
+
readdirSync as defaultReaddir,
|
|
45
|
+
readFileSync as defaultReadFile,
|
|
46
|
+
renameSync as defaultRename,
|
|
47
|
+
writeFileSync as defaultWriteFile,
|
|
48
|
+
lstatSync as defaultLstat,
|
|
49
|
+
realpathSync as defaultRealpath,
|
|
50
|
+
} from "node:fs";
|
|
51
|
+
import { join, resolve } from "node:path";
|
|
52
|
+
|
|
53
|
+
import { containmentViolation } from "../containment.mjs";
|
|
54
|
+
import {
|
|
55
|
+
eventDedupeKey,
|
|
56
|
+
eventId,
|
|
57
|
+
EVOLUTION_EVENT_SCHEMA_VERSION,
|
|
58
|
+
EVENT_CLASSIFICATIONS,
|
|
59
|
+
EVENT_DISPOSITIONS,
|
|
60
|
+
} from "./evolution-event.mjs";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The zero-padded sequence number for the next event, taken from the highest
|
|
64
|
+
* existing event filename: `0000` for a fresh directory. An event log is
|
|
65
|
+
* zero-based — the first event is index 0 — unlike history's capture
|
|
66
|
+
* ordinals, which start at `0001`. The width widens from a four-digit minimum
|
|
67
|
+
* rather than overflowing, for the same reason history's `nextSequence`
|
|
68
|
+
* documents: a `10000` padded to four digits would byte-sort before `9999-…`
|
|
69
|
+
* and silently rewind the log, and the sequence regex would stop seeing the
|
|
70
|
+
* 5-digit name so repeated writes would clobber one file.
|
|
71
|
+
*
|
|
72
|
+
* @param {string[]} names Event filenames from the directory read.
|
|
73
|
+
* @returns {string} Zero-padded sequence, at least four digits.
|
|
74
|
+
*/
|
|
75
|
+
function nextSequence(names) {
|
|
76
|
+
// `max` starts at -1 so a fresh directory sequences from 0000.
|
|
77
|
+
let max = -1;
|
|
78
|
+
for (const name of names) {
|
|
79
|
+
const match = /^(\d+)-/.exec(name);
|
|
80
|
+
if (match) max = Math.max(max, Number.parseInt(match[1], 10));
|
|
81
|
+
}
|
|
82
|
+
const width = Math.max(4, String(max + 1).length);
|
|
83
|
+
return String(max + 1).padStart(width, "0");
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The short filename suffix for an event id — history's `shortId` (`../commands/history.mjs`):
|
|
87
|
+
* first 8 hex characters.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} id Full hex SHA-256 from `eventId`.
|
|
90
|
+
* @returns {string} First 8 hex characters.
|
|
91
|
+
*/
|
|
92
|
+
function shortId(id) {
|
|
93
|
+
return id.slice(0, 8);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Refuses an event whose identity does not match its content. `id` and
|
|
98
|
+
* `dedupeKey` are derived, never free-form: a record whose fields disagree
|
|
99
|
+
* with the canonical tuple would dedupe against the wrong key on rerun and
|
|
100
|
+
* silently manufacture duplicates — the failure shape this store exists to
|
|
101
|
+
* rule out.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} event The event to write.
|
|
104
|
+
* @throws {Error} naming the mismatch.
|
|
105
|
+
*/
|
|
106
|
+
function validateEventForWrite(event) {
|
|
107
|
+
if (typeof event !== "object" || event === null || Array.isArray(event)) {
|
|
108
|
+
throw new Error("archkeep: writeEvent requires an EvolutionEvent object");
|
|
109
|
+
}
|
|
110
|
+
const expectedId = eventId(event);
|
|
111
|
+
const expectedKey = eventDedupeKey(event);
|
|
112
|
+
if (typeof event.id !== "string") {
|
|
113
|
+
throw new Error("archkeep: refusing to write the evolution event: the record carries no 'id'");
|
|
114
|
+
}
|
|
115
|
+
if (event.id !== expectedId) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
"archkeep: refusing to write the evolution event: its 'id' does not match the canonical " +
|
|
118
|
+
"tuple {base, head, declarationDigest}",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (typeof event.dedupeKey !== "string") {
|
|
122
|
+
throw new Error(
|
|
123
|
+
"archkeep: refusing to write the evolution event: the record carries no 'dedupeKey'",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (event.dedupeKey !== expectedKey) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
"archkeep: refusing to write the evolution event: its 'dedupeKey' does not match the canonical " +
|
|
129
|
+
"tuple {base, head, declarationDigest}",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The three validations every stored event must pass: the schema version, the
|
|
136
|
+
* classification subset, and the disposition. Any other shape is a malformed
|
|
137
|
+
* store — thrown, never read as an event.
|
|
138
|
+
*
|
|
139
|
+
* @param {object} parsed The parsed record.
|
|
140
|
+
* @param {string} path The file it came from, for the error message.
|
|
141
|
+
* @throws {Error} naming the file and the violated check.
|
|
142
|
+
*/
|
|
143
|
+
function validateEventRecord(parsed, path) {
|
|
144
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`archkeep: malformed evolution event '${path}': the record is not a JSON object`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (parsed.schemaVersion !== EVOLUTION_EVENT_SCHEMA_VERSION) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`archkeep: malformed evolution event '${path}': schemaVersion ${JSON.stringify(
|
|
152
|
+
parsed.schemaVersion,
|
|
153
|
+
)} is not ${EVOLUTION_EVENT_SCHEMA_VERSION}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (
|
|
157
|
+
!Array.isArray(parsed.classifications) ||
|
|
158
|
+
parsed.classifications.some((entry) => !EVENT_CLASSIFICATIONS.includes(entry))
|
|
159
|
+
) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`archkeep: malformed evolution event '${path}': classifications must be a subset of ` +
|
|
162
|
+
`[${EVENT_CLASSIFICATIONS.join(", ")}]`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (!EVENT_DISPOSITIONS.includes(parsed.disposition)) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`archkeep: malformed evolution event '${path}': disposition ${JSON.stringify(
|
|
168
|
+
parsed.disposition,
|
|
169
|
+
)} is not one of [${EVENT_DISPOSITIONS.join(", ")}]`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Appends one event to the store at `dir`.
|
|
176
|
+
*
|
|
177
|
+
* Idempotency first: the directory is scanned for an event whose `dedupeKey`
|
|
178
|
+
* matches BEFORE anything is written; a match returns `{id, duplicate: true}`
|
|
179
|
+
* and writes nothing. Then the event is written atomically
|
|
180
|
+
* (`<path>.json.tmp` + rename, `{flag: "wx"}`) under a containment check
|
|
181
|
+
* against `io.root`. A missing directory is an empty store: it is created
|
|
182
|
+
* before the scan, so the first event in a fresh store lands at `0000`.
|
|
183
|
+
*
|
|
184
|
+
* The event's `id`/`dedupeKey` must match the canonical tuple (see
|
|
185
|
+
* `validateEventForWrite`) — a caller cannot persist a record whose identity
|
|
186
|
+
* lies about its content.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} dir Absolute or relative path to the event store directory.
|
|
189
|
+
* @param {object} event The EvolutionEvent to append.
|
|
190
|
+
* @param {{root?: string, readdirSync?: (path: string) => string[],
|
|
191
|
+
* readFileSync?: (path: string, encoding: "utf8") => string,
|
|
192
|
+
* writeFileSync?: (path: string, text: string, options: object) => void,
|
|
193
|
+
* renameSync?: (from: string, to: string) => void,
|
|
194
|
+
* mkdirSync?: (path: string, options: {recursive: boolean}) => void,
|
|
195
|
+
* lstatSync?: (path: string) => {isSymbolicLink: () => boolean},
|
|
196
|
+
* realpathSync?: (path: string) => string}} [io]
|
|
197
|
+
* Injectable filesystem seams, defaulting to the sync `node:fs` calls this
|
|
198
|
+
* module uses; `root` is the workspace root the containment check is made
|
|
199
|
+
* against and is REQUIRED for a write.
|
|
200
|
+
* @returns {{id: string, duplicate: boolean}} The event id, and whether this
|
|
201
|
+
* call wrote nothing because the event already existed.
|
|
202
|
+
* @throws {Error} on a mismatched event identity, an unreadable or malformed
|
|
203
|
+
* store, a containment violation, or a `wx` refusal.
|
|
204
|
+
*/
|
|
205
|
+
export function writeEvent(dir, event, io = {}) {
|
|
206
|
+
validateEventForWrite(event);
|
|
207
|
+
|
|
208
|
+
const readDir = io.readdirSync ?? defaultReaddir;
|
|
209
|
+
const readFile = io.readFileSync ?? defaultReadFile;
|
|
210
|
+
const writeFile = io.writeFileSync ?? defaultWriteFile;
|
|
211
|
+
const rename = io.renameSync ?? defaultRename;
|
|
212
|
+
const makeDir = io.mkdirSync ?? defaultMkdir;
|
|
213
|
+
const lstat = io.lstatSync ?? defaultLstat;
|
|
214
|
+
const realpath = io.realpathSync ?? defaultRealpath;
|
|
215
|
+
|
|
216
|
+
// Resolved once: the identical string feeds the containment check and the
|
|
217
|
+
// actual write (`../containment.mjs`, "One contract binds the WRITE call
|
|
218
|
+
// sites"). `resolve` also collapses any `..`, which containment refuses raw.
|
|
219
|
+
const dirAbs = resolve(dir);
|
|
220
|
+
|
|
221
|
+
let names;
|
|
222
|
+
try {
|
|
223
|
+
names = readDir(dirAbs);
|
|
224
|
+
} catch (cause) {
|
|
225
|
+
if (cause?.code === "ENOENT") {
|
|
226
|
+
// An absent optional store is an empty store: the caller's first event.
|
|
227
|
+
makeDir(dirAbs, { recursive: true });
|
|
228
|
+
names = readDir(dirAbs);
|
|
229
|
+
} else {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`archkeep: cannot read the event store '${dirAbs}': ${cause?.message ?? cause}`,
|
|
232
|
+
{ cause },
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const eventNames = names.filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp"));
|
|
238
|
+
eventNames.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
239
|
+
|
|
240
|
+
// The dedupe scan. A file that cannot be parsed, or that parses to a record
|
|
241
|
+
// without a string dedupeKey, throws: a store this module cannot read must
|
|
242
|
+
// not be silently appended to — the unreadable file may BE the duplicate.
|
|
243
|
+
for (const name of eventNames) {
|
|
244
|
+
const existingPath = join(dirAbs, name);
|
|
245
|
+
let text;
|
|
246
|
+
try {
|
|
247
|
+
text = readFile(existingPath, "utf8");
|
|
248
|
+
} catch (cause) {
|
|
249
|
+
throw new Error(
|
|
250
|
+
`archkeep: cannot read the evolution event '${existingPath}': ${cause?.message ?? cause}`,
|
|
251
|
+
{ cause },
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
let parsed;
|
|
255
|
+
try {
|
|
256
|
+
parsed = JSON.parse(text);
|
|
257
|
+
} catch (cause) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`archkeep: malformed evolution event '${existingPath}': ${cause?.message ?? cause}`,
|
|
260
|
+
{ cause },
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (typeof parsed?.dedupeKey !== "string") {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`archkeep: malformed evolution event '${existingPath}': the record carries no string 'dedupeKey'`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (parsed.dedupeKey === event.dedupeKey) {
|
|
269
|
+
return { id: event.id, duplicate: true };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const sequence = nextSequence(eventNames);
|
|
274
|
+
const name = `${sequence}-${shortId(event.id)}.json`;
|
|
275
|
+
const path = join(dirAbs, name);
|
|
276
|
+
|
|
277
|
+
if (io.root === undefined) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
"archkeep: writing an evolution event requires io.root — the workspace root — so the " +
|
|
280
|
+
"write can be proven to stay inside the workspace",
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
const violation = containmentViolation(io.root, path, {
|
|
284
|
+
forWrite: true,
|
|
285
|
+
lstatSync: lstat,
|
|
286
|
+
realpathSync: realpath,
|
|
287
|
+
});
|
|
288
|
+
if (violation !== null) {
|
|
289
|
+
throw new Error(`archkeep: refusing to write the evolution event '${path}': ${violation}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const tmp = `${path}.tmp`;
|
|
293
|
+
writeFile(tmp, `${JSON.stringify(event, null, 2)}\n`, { flag: "wx" });
|
|
294
|
+
rename(tmp, path);
|
|
295
|
+
|
|
296
|
+
return { id: event.id, duplicate: false };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Reads and validates every event in the store at `dir`, in filename order
|
|
301
|
+
* (the append order). A malformed file — JSON that does not parse, a
|
|
302
|
+
* non-object record, a wrong `schemaVersion`, a classification outside the
|
|
303
|
+
* vocabulary, a disposition outside the vocabulary — THROWS; the command layer
|
|
304
|
+
* maps that to exit 3, and "could not read the store" never reads as "no
|
|
305
|
+
* events recorded" (the invariant: an empty result is a claim, not a shrug).
|
|
306
|
+
* A missing directory is `[]` — an absent OPTIONAL store is not an error, and
|
|
307
|
+
* the caller states "no events recorded" itself when that matters.
|
|
308
|
+
*
|
|
309
|
+
* `.json.tmp` files are filtered out: an interrupted write leaves one behind,
|
|
310
|
+
* and it must never count as an event.
|
|
311
|
+
*
|
|
312
|
+
* @param {string} dir Path to the event store directory.
|
|
313
|
+
* @param {{readdirSync?: (path: string) => string[],
|
|
314
|
+
* readFileSync?: (path: string, encoding: "utf8") => string}} [io]
|
|
315
|
+
* @returns {object[]} The validated events, in append order.
|
|
316
|
+
* @throws {Error} on the first unreadable or malformed event file.
|
|
317
|
+
*/
|
|
318
|
+
export function readEvents(dir, io = {}) {
|
|
319
|
+
const readDir = io.readdirSync ?? defaultReaddir;
|
|
320
|
+
const readFile = io.readFileSync ?? defaultReadFile;
|
|
321
|
+
|
|
322
|
+
const dirAbs = resolve(dir);
|
|
323
|
+
|
|
324
|
+
let names;
|
|
325
|
+
try {
|
|
326
|
+
names = readDir(dirAbs);
|
|
327
|
+
} catch (cause) {
|
|
328
|
+
if (cause?.code === "ENOENT") return [];
|
|
329
|
+
throw new Error(
|
|
330
|
+
`archkeep: cannot read the event store '${dirAbs}': ${cause?.message ?? cause}`,
|
|
331
|
+
{ cause },
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const eventNames = names.filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp"));
|
|
336
|
+
eventNames.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
337
|
+
|
|
338
|
+
const events = [];
|
|
339
|
+
for (const name of eventNames) {
|
|
340
|
+
const path = join(dirAbs, name);
|
|
341
|
+
let text;
|
|
342
|
+
try {
|
|
343
|
+
text = readFile(path, "utf8");
|
|
344
|
+
} catch (cause) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`archkeep: cannot read the evolution event '${path}': ${cause?.message ?? cause}`,
|
|
347
|
+
{ cause },
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
let parsed;
|
|
351
|
+
try {
|
|
352
|
+
parsed = JSON.parse(text);
|
|
353
|
+
} catch (cause) {
|
|
354
|
+
throw new Error(`archkeep: malformed evolution event '${path}': ${cause?.message ?? cause}`, {
|
|
355
|
+
cause,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
validateEventRecord(parsed, path);
|
|
359
|
+
events.push(parsed);
|
|
360
|
+
}
|
|
361
|
+
return events;
|
|
362
|
+
}
|
|
@@ -37,8 +37,17 @@
|
|
|
37
37
|
* or a polluted prototype cannot smuggle keys into a validated origin. This
|
|
38
38
|
* module builds nothing from untrusted keys; it validates and, at write time,
|
|
39
39
|
* builds a fresh object with only the three permitted keys.
|
|
40
|
+
* ## The decision-lifecycle record
|
|
41
|
+
*
|
|
42
|
+
* The same discipline extends from a row to a DECISION (an ADR id — the
|
|
43
|
+
* stable handle later waves reference). `recordDecisionLifecycle` records one
|
|
44
|
+
* lifecycle event — a status transition, a supersession, or a bindings
|
|
45
|
+
* change — attributed by the same `origin` shape and the same clock door:
|
|
46
|
+
* `by`/`tool` are required, and `on` comes from `recordOrigin` and nowhere
|
|
47
|
+
* else. A record that records nothing (a no-op transition) is refused loudly.
|
|
40
48
|
*/
|
|
41
49
|
|
|
50
|
+
import { ADR_STATUSES } from "./adr-registry.mjs";
|
|
42
51
|
import { clockViolations } from "./clock.mjs";
|
|
43
52
|
|
|
44
53
|
/** The only keys a validated `origin` may carry. */
|
|
@@ -175,3 +184,144 @@ export function recordOrigin({ by, tool, clock }) {
|
|
|
175
184
|
// record, so two calls with the same clock are byte-identical.
|
|
176
185
|
return { by, tool, on: clock.now() };
|
|
177
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* The decision-lifecycle events one record can hold, each a single fact about
|
|
189
|
+
* ONE decision (an ADR id — `docs/adr/NNN-slug.md` — the stable handle the
|
|
190
|
+
* registry's `byId` map keys on). A decision's creation and every status
|
|
191
|
+
* change, supersession, and bindings change is recorded as one of these.
|
|
192
|
+
*/
|
|
193
|
+
export const DECISION_LIFECYCLE_KINDS = Object.freeze([
|
|
194
|
+
"status-transition",
|
|
195
|
+
"supersession",
|
|
196
|
+
"bindings-change",
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @typedef {object} DecisionLifecycleRecord
|
|
201
|
+
* @property {"status-transition"|"supersession"|"bindings-change"} kind
|
|
202
|
+
* One recorded lifecycle event on one decision.
|
|
203
|
+
* @property {string} decisionId The ADR id the event happened to — the stable
|
|
204
|
+
* handle the ADR registry keys on (`docs/adr/NNN-slug.md`).
|
|
205
|
+
* @property {string|null} [from] status-transition: the status the decision
|
|
206
|
+
* left, or null when the event is the decision's creation (its proposed
|
|
207
|
+
* entry).
|
|
208
|
+
* @property {string} [to] status-transition: the status the decision entered.
|
|
209
|
+
* @property {string[]} [superseded] supersession: the ADR id(s) this decision
|
|
210
|
+
* replaced — `decisionId` is the RECORDING record, the successor.
|
|
211
|
+
* @property {string[]} [added] bindings-change: constraint ids made
|
|
212
|
+
* enforceable.
|
|
213
|
+
* @property {string[]} [removed] bindings-change: constraint ids unbound.
|
|
214
|
+
* @property {OriginRecord} origin WHO recorded the event and with what tool —
|
|
215
|
+
* `on` produced by `recordOrigin`, the only door.
|
|
216
|
+
*/
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Records one decision-lifecycle event, attributed by the same `origin`
|
|
220
|
+
* discipline as a row: `by` and `tool` are required, and `on` comes from the
|
|
221
|
+
* injected clock through `recordOrigin` — the only producer of an `on`, and
|
|
222
|
+
* the refusal to run without one is its own, inherited here.
|
|
223
|
+
*
|
|
224
|
+
* The record carries ONLY the kind's own keys and the origin, built fresh, so
|
|
225
|
+
* nothing from untrusted input rides along. A no-op event — a status
|
|
226
|
+
* transition that changes nothing, a supersession naming no target, a
|
|
227
|
+
* bindings change that adds and removes nothing — is refused loudly: a record
|
|
228
|
+
* that records nothing would read as a transition that happened, the silent
|
|
229
|
+
* direction this module exists to exclude.
|
|
230
|
+
*
|
|
231
|
+
* Statuses are validated against `ADR_STATUSES` (`./adr-registry.mjs`), the
|
|
232
|
+
* single status vocabulary — a record can never attest a status the registry
|
|
233
|
+
* could not hold.
|
|
234
|
+
*
|
|
235
|
+
* @param {{kind: "status-transition"|"supersession"|"bindings-change",
|
|
236
|
+
* decisionId: string,
|
|
237
|
+
* from?: string|null, to?: string,
|
|
238
|
+
* superseded?: string[],
|
|
239
|
+
* added?: string[], removed?: string[],
|
|
240
|
+
* origin: {by: string, tool: string},
|
|
241
|
+
* clock: import("./clock.mjs").Clock}} event
|
|
242
|
+
* @returns {DecisionLifecycleRecord}
|
|
243
|
+
* @throws {Error} on an unknown kind, a missing decisionId, a status outside
|
|
244
|
+
* the registry's `ADR_STATUSES`, a no-op event, or an invalid origin/clock.
|
|
245
|
+
*/
|
|
246
|
+
export function recordDecisionLifecycle({
|
|
247
|
+
kind,
|
|
248
|
+
decisionId,
|
|
249
|
+
from = null,
|
|
250
|
+
to,
|
|
251
|
+
superseded,
|
|
252
|
+
added,
|
|
253
|
+
removed,
|
|
254
|
+
origin,
|
|
255
|
+
clock,
|
|
256
|
+
}) {
|
|
257
|
+
const violations = [];
|
|
258
|
+
if (!DECISION_LIFECYCLE_KINDS.includes(kind)) {
|
|
259
|
+
violations.push(
|
|
260
|
+
`kind: must be one of ${DECISION_LIFECYCLE_KINDS.join(", ")}, got ${describe(kind)}`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (typeof decisionId !== "string" || decisionId.trim() === "") {
|
|
264
|
+
violations.push(
|
|
265
|
+
`decisionId: must be a non-empty string naming the ADR, got ${describe(decisionId)}`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (kind === "status-transition") {
|
|
269
|
+
if (from !== null && !ADR_STATUSES.includes(from)) {
|
|
270
|
+
violations.push(
|
|
271
|
+
`from: must be null or one of ${ADR_STATUSES.join(", ")}, got ${describe(from)}`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if (!ADR_STATUSES.includes(to)) {
|
|
275
|
+
violations.push(`to: must be one of ${ADR_STATUSES.join(", ")}, got ${describe(to)}`);
|
|
276
|
+
}
|
|
277
|
+
if (from !== null && from === to) {
|
|
278
|
+
violations.push(
|
|
279
|
+
`to: equals from (${JSON.stringify(from)}) — a status transition that changes nothing is not a recordable event`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
} else if (kind === "supersession") {
|
|
283
|
+
if (
|
|
284
|
+
!Array.isArray(superseded) ||
|
|
285
|
+
superseded.length === 0 ||
|
|
286
|
+
superseded.some((ref) => typeof ref !== "string" || ref.trim() === "")
|
|
287
|
+
) {
|
|
288
|
+
violations.push("superseded: must be a non-empty array of ADR ids this decision replaced");
|
|
289
|
+
}
|
|
290
|
+
} else if (kind === "bindings-change") {
|
|
291
|
+
for (const [name, value] of [
|
|
292
|
+
["added", added],
|
|
293
|
+
["removed", removed],
|
|
294
|
+
]) {
|
|
295
|
+
if (value !== undefined && !Array.isArray(value)) {
|
|
296
|
+
violations.push(`${name}: must be an array of constraint ids, got ${describe(value)}`);
|
|
297
|
+
} else if (
|
|
298
|
+
Array.isArray(value) &&
|
|
299
|
+
value.some((id) => typeof id !== "string" || id.trim() === "")
|
|
300
|
+
) {
|
|
301
|
+
violations.push(`${name}: every entry must be a non-empty constraint id`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const addedList = Array.isArray(added) ? added : [];
|
|
305
|
+
const removedList = Array.isArray(removed) ? removed : [];
|
|
306
|
+
if (addedList.length === 0 && removedList.length === 0) {
|
|
307
|
+
violations.push(
|
|
308
|
+
"added/removed: a bindings change that adds nothing and removes nothing is not a recordable event",
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (violations.length > 0) {
|
|
313
|
+
throw new Error(`decisionLifecycle: ${violations.join("; ")}`);
|
|
314
|
+
}
|
|
315
|
+
const eventFields =
|
|
316
|
+
kind === "status-transition"
|
|
317
|
+
? { from, to }
|
|
318
|
+
: kind === "supersession"
|
|
319
|
+
? { superseded }
|
|
320
|
+
: { added: added ?? [], removed: removed ?? [] };
|
|
321
|
+
return {
|
|
322
|
+
kind,
|
|
323
|
+
decisionId,
|
|
324
|
+
...eventFields,
|
|
325
|
+
origin: recordOrigin({ by: origin?.by, tool: origin?.tool, clock }),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
@@ -246,6 +246,10 @@ const PROJECT_TYPES = ["app", "lib", "e2e"];
|
|
|
246
246
|
* An explicit `exclude` list REPLACES this default (the `tsc` convention for
|
|
247
247
|
* the same field): a workspace that names its own list takes over the whole
|
|
248
248
|
* decision, `exclude: []` included — that spelling is the documented opt-out.
|
|
249
|
+
* `excludeBeyondDefaults` extends the effective set — the defaults when
|
|
250
|
+
* `exclude` is absent, an explicit list when it is present — without restating
|
|
251
|
+
* it, so a workspace with `testdata/` or `golden/` directories does not copy
|
|
252
|
+
* these three patterns by hand (issue #389).
|
|
249
253
|
*
|
|
250
254
|
* @see DEFAULT_MANIFEST_NAMES
|
|
251
255
|
*/
|
|
@@ -375,7 +379,7 @@ function declaredProjectViolations(row, index) {
|
|
|
375
379
|
return violations;
|
|
376
380
|
}
|
|
377
381
|
|
|
378
|
-
const INFER_KEYS = ["manifests", "include", "exclude"];
|
|
382
|
+
const INFER_KEYS = ["manifests", "include", "exclude", "excludeBeyondDefaults"];
|
|
379
383
|
|
|
380
384
|
/** `projects.infer`'s problems, or `[]` when the key is absent — absent means "use the defaults", not "malformed". */
|
|
381
385
|
function inferViolations(value) {
|
|
@@ -385,6 +389,11 @@ function inferViolations(value) {
|
|
|
385
389
|
...stringListViolations(value.manifests, "projects.infer.manifests"),
|
|
386
390
|
...stringListViolations(value.include, "projects.infer.include", globComplexityError),
|
|
387
391
|
...stringListViolations(value.exclude, "projects.infer.exclude", globComplexityError),
|
|
392
|
+
...stringListViolations(
|
|
393
|
+
value.excludeBeyondDefaults,
|
|
394
|
+
"projects.infer.excludeBeyondDefaults",
|
|
395
|
+
globComplexityError,
|
|
396
|
+
),
|
|
388
397
|
];
|
|
389
398
|
// `[]` and "omit the key" both validate against `stringListViolations` above
|
|
390
399
|
// — a list is still a list at length zero — but they must not mean the same
|
|
@@ -758,9 +767,14 @@ export function normalizeNativeModel(raw) {
|
|
|
758
767
|
: {
|
|
759
768
|
manifests: rawInfer.manifests ?? DEFAULT_MANIFEST_NAMES,
|
|
760
769
|
include: rawInfer.include ?? ["**"],
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
|
|
770
|
+
// `exclude` replaces the defaults (`DEFAULT_INFER_EXCLUDE`'s doc
|
|
771
|
+
// comment owns the why); `excludeBeyondDefaults` then extends the
|
|
772
|
+
// effective set — the defaults when `exclude` is absent, the
|
|
773
|
+
// explicit list when it is present — without restating it.
|
|
774
|
+
exclude: [
|
|
775
|
+
.../** @type {string[]|undefined} */ (rawInfer.exclude ?? DEFAULT_INFER_EXCLUDE),
|
|
776
|
+
.../** @type {string[]|undefined} */ (rawInfer.excludeBeyondDefaults ?? []),
|
|
777
|
+
],
|
|
764
778
|
},
|
|
765
779
|
},
|
|
766
780
|
projectRules: /** @type {unknown[]} */ (raw.projectRules ?? []).map((row) => {
|