@ecoma-io/archkeep 0.16.1 → 0.17.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.
@@ -0,0 +1,483 @@
1
+ /**
2
+ * The decision governance graph: a pure, descriptive, bidirectional walk over
3
+ * decisions (the ADR registry's records), the intent/constraint/fitness rows
4
+ * they govern, the projects those rows govern, and the projects' current
5
+ * findings.
6
+ *
7
+ * It answers the two directions of one question — "what does this decision
8
+ * make enforceable, and what does that enforcement currently see?" and "what
9
+ * decision governs this row/project/finding, and why was it made?" — as one
10
+ * pure data shape: `{ok, nodes, edges, unresolved}`.
11
+ *
12
+ * ## Descriptive by contract (Wave 2 scope)
13
+ *
14
+ * This module NEVER gates. It decides nothing about whether a finding IS one
15
+ * (the rule that produced it owns that, `../report/evidence.mjs`'), never
16
+ * changes `check`'s exit code, and never turns green or red on its own. It
17
+ * reports the graph the registry and the caller's row/finding facts describe
18
+ * — or, where a reference cannot resolve, names the gap. The `adr`/`report`
19
+ * surface of the wave renders the walk; nothing here is a verdict.
20
+ *
21
+ * ## The invariant
22
+ *
23
+ * The repository's governing rule (`../../../../AGENTS.md`): an empty result
24
+ * must mean "no violation", and nothing else. For a walk that means an
25
+ * `unresolved` list is the only honest answer to a reference that cannot
26
+ * resolve:
27
+ *
28
+ * - an unknown decision id, a `binding` that names no governed row, a row
29
+ * citing a decision that does not exist, a missing findings lookup, a row
30
+ * or finding with no governing decision — every one lands in `unresolved`
31
+ * with its `ref`, a `kind`, and a reason, and `ok` is false. A walk never
32
+ * returns an empty-looking `nodes: []` over a reference it could not
33
+ * resolve.
34
+ * - a reference that DID resolve and genuinely has nothing attached — a
35
+ * decision that binds no rule and is cited by no row — is a true fact and
36
+ * stays `ok: true`: the walk looked, and what it found is that nothing is
37
+ * bound.
38
+ *
39
+ * ## Determinism
40
+ *
41
+ * No wall-clock time, no randomness, no environment. Iteration follows the
42
+ * caller's data orders — `records` in registry (byte-sorted filename) order,
43
+ * `rows` in the order given, each record's `bindings`/`supersedes` in the
44
+ * order the record declares — and nodes/edges/unresolved are emitted in
45
+ * first-discovery order, so two runs over the same context produce
46
+ * byte-identical output.
47
+ *
48
+ * ## Injection, never IO
49
+ *
50
+ * The module reads no files. The context — the registry index
51
+ * (`./adr-registry.mjs`'s `readAdrContext` returns `records`/`byId`), the
52
+ * workspace's declared rule/fitness ids (`declaredFitnessNames(config)`), the
53
+ * governed rows, and the findings lookups — is assembled by the caller (Wave
54
+ * 2's report/adr surface), so the walk is a pure function of facts, testable
55
+ * on in-memory fixtures.
56
+ *
57
+ * ## The two name spaces, resolved the way the registry resolves them
58
+ *
59
+ * Decision references are the registry's: an ADR id (`0002-case`, or the
60
+ * documented `adr:` spelling `stripAdrPrefix` handles). Rule/fitness ids are
61
+ * the declared-name space `declaredFitnessNames` owns, with the documented
62
+ * `rule:`/`fitness:` prefixes `stripRuleFitnessPrefix` handles. Both helpers
63
+ * (and `resolveDecisionRef`, the classification every other surface shares)
64
+ * come from `./adr-registry.mjs` — this walk consumes the registry's meaning,
65
+ * never a second opinion about what a reference names. Normalization for row
66
+ * matching applies the same documented aliases: a record binding `hotspot`
67
+ * and a row id `fitness:hotspot` name the same thing, exactly as a
68
+ * `decisionRef` of either spelling resolves identically everywhere else.
69
+ */
70
+
71
+ import { resolveDecisionRef, stripAdrPrefix, stripRuleFitnessPrefix } from "./adr-registry.mjs";
72
+
73
+ /**
74
+ * One governed row the walk knows: an intent row, a boundary constraint row,
75
+ * or a fitness rule, each carrying the governance block the wave's shared
76
+ * row schema (`./row-schema.mjs`) validates. The caller assembles this list
77
+ * from the surfaces it owns — `architecture-intent.json` rows, the boundary
78
+ * law's `depConstraints`, the declared `fitness` list — so the walk never
79
+ * reads a row itself.
80
+ *
81
+ * @typedef {object} GovernedRow
82
+ * @property {string} id The row's own reference — an intent-row label
83
+ * (`projects.required[0]`) or a rule/fitness id (`rule:keep-a`,
84
+ * `fitness:hotspot`, or the bare `hotspot` spelling).
85
+ * @property {"intent"|"constraint"|"fitness"} kind What kind of row it is.
86
+ * @property {string} [decisionRef] The decision that makes the row
87
+ * enforceable — an ADR id (bare or `adr:`-prefixed), or a rule/fitness id
88
+ * naming a decision that binds it.
89
+ * @property {string[]} [governs] The project/part ids this row governs.
90
+ */
91
+
92
+ /**
93
+ * The facts a walk is a pure function of. Registry-shaped values come
94
+ * straight from `./adr-registry.mjs`'s `readAdrContext` and
95
+ * `declaredFitnessNames`; the rows and findings come from the caller.
96
+ *
97
+ * @typedef {object} GraphWalkContext
98
+ * @property {object[]} records The validated records, in registry order.
99
+ * @property {Map<string, object>} byId The registry index.
100
+ * @property {Set<string>} knownFitness Rule/fitness ids the workspace
101
+ * declares (`declaredFitnessNames(config)`).
102
+ * @property {GovernedRow[]} rows The governed rows this workspace carries.
103
+ * @property {(projectId: string) => object[]} [findingsByProject] The current
104
+ * findings/evidence for a project. Required for a forward walk's
105
+ * "current findings" leg; when absent, the walk cannot claim a project has
106
+ * no findings and says so in `unresolved`.
107
+ * @property {Map<string, object>} [findingsById] Finding id -> finding record,
108
+ * for the reverse walk's finding route. A finding record must carry at
109
+ * least `id`, `project`, and `ruleId`; any further fields pass through
110
+ * untouched in the finding node's `data`.
111
+ */
112
+
113
+ /**
114
+ * @typedef {"decision"|"intent"|"constraint"|"fitness"|"project"|"finding"} GraphNodeKind
115
+ * @typedef {object} GraphNode
116
+ * @property {string} id
117
+ * @property {GraphNodeKind} kind
118
+ * @property {string} label
119
+ * @property {object} [data]
120
+ * @typedef {"decisionRef"|"binding"|"governs"|"finding"|"supersedes"} GraphEdgeKind
121
+ * @typedef {object} GraphEdge
122
+ * @property {string} from
123
+ * @property {string} to
124
+ * @property {GraphEdgeKind} kind
125
+ * @typedef {"decision"|"intent"|"constraint"|"fitness"|"project"|"finding"|"rule"|"binding"} UnresolvedKind
126
+ * @typedef {object} UnresolvedRef
127
+ * @property {string} ref The reference that did not resolve.
128
+ * @property {UnresolvedKind} kind What the walk was trying to resolve.
129
+ * @property {string} reason Why it cannot.
130
+ * @typedef {object} GraphWalk
131
+ * @property {boolean} ok False iff `unresolved` is non-empty — a walk that
132
+ * could not resolve every reference it met never looks like a walk that
133
+ * found nothing.
134
+ * @property {GraphNode[]} nodes
135
+ * @property {GraphEdge[]} edges
136
+ * @property {UnresolvedRef[]} unresolved
137
+ */
138
+
139
+ /** A fresh, empty walk — `ok: true` until the first unresolved reference. */
140
+ function newWalk() {
141
+ const walk = { ok: true, nodes: [], edges: [], unresolved: [] };
142
+ const nodeIds = new Set();
143
+ const edgeKeys = new Set();
144
+ const unresolvedKeys = new Set();
145
+ return {
146
+ walk,
147
+ node(id, kind, label, data) {
148
+ if (nodeIds.has(id)) return;
149
+ nodeIds.add(id);
150
+ walk.nodes.push(data === undefined ? { id, kind, label } : { id, kind, label, data });
151
+ },
152
+ edge(from, to, kind) {
153
+ const key = `${from}\u0000${kind}\u0000${to}`;
154
+ if (edgeKeys.has(key)) return;
155
+ edgeKeys.add(key);
156
+ walk.edges.push({ from, to, kind });
157
+ },
158
+ unresolved(ref, kind, reason) {
159
+ const key = `${kind}\u0000${ref}`;
160
+ if (unresolvedKeys.has(key)) return;
161
+ unresolvedKeys.add(key);
162
+ walk.ok = false;
163
+ walk.unresolved.push({ ref, kind, reason });
164
+ },
165
+ };
166
+ }
167
+
168
+ /** The decision node a record contributes, with the record's own facts. */
169
+ function decisionNode(record) {
170
+ const data = { status: record.status };
171
+ for (const key of [
172
+ "created",
173
+ "updated",
174
+ "context",
175
+ "decision",
176
+ "rationale",
177
+ "alternatives",
178
+ "consequences",
179
+ "assumptions",
180
+ ]) {
181
+ if (record[key] !== undefined) data[key] = record[key];
182
+ }
183
+ if (record.supersedes.length > 0) data.supersedes = record.supersedes;
184
+ if ((record.supersededBy ?? []).length > 0) data.supersededBy = record.supersededBy;
185
+ return { id: record.id, kind: "decision", label: record.id, data };
186
+ }
187
+
188
+ /** `id`, with the documented `adr:` prefix stripped — the registry key. */
189
+ function decisionIdOf(ref) {
190
+ return stripAdrPrefix(ref);
191
+ }
192
+
193
+ /**
194
+ * The records whose `bindings` name `target`, normalized by the same
195
+ * `rule:`/`fitness:` aliases the registry's own resolution applies.
196
+ */
197
+ function bindingRecords(records, target) {
198
+ return records.filter((record) =>
199
+ record.bindings.some((binding) => stripRuleFitnessPrefix(binding) === target),
200
+ );
201
+ }
202
+
203
+ /** The derived reverse-lineage index every walk builds for itself. */
204
+ function supersededByIndex(records) {
205
+ const index = new Map();
206
+ for (const record of records) {
207
+ for (const ref of record.supersedes) {
208
+ const list = index.get(ref) ?? [];
209
+ list.push(record.id);
210
+ index.set(ref, list);
211
+ }
212
+ }
213
+ return index;
214
+ }
215
+
216
+ /**
217
+ * Adds a record's node and the full supersession chain in both directions —
218
+ * `supersedes` forward, derived `supersededBy` backward — cycle-safe (a
219
+ * `visited` set; a raw, hand-built context can present a cycle the registry
220
+ * would have refused, and the walk must still terminate).
221
+ */
222
+ function attachLineage(g, record, ctx) {
223
+ const index = supersededByIndex(ctx.records);
224
+ const visited = new Set();
225
+ const queue = [record.id];
226
+ while (queue.length > 0) {
227
+ const id = queue.shift();
228
+ if (visited.has(id)) continue;
229
+ visited.add(id);
230
+ const current = ctx.byId.get(id);
231
+ if (current === undefined) {
232
+ g.unresolved(
233
+ id,
234
+ "decision",
235
+ `"${id}" is named in a supersession chain but no matching ADR record is in the registry`,
236
+ );
237
+ continue;
238
+ }
239
+ g.node(current.id, "decision", current.id, decisionNode(current).data);
240
+ for (const next of current.supersedes) {
241
+ g.edge(current.id, next, "supersedes");
242
+ if (!visited.has(next)) queue.push(next);
243
+ }
244
+ for (const next of index.get(current.id) ?? []) {
245
+ g.edge(next, current.id, "supersedes");
246
+ if (!visited.has(next)) queue.push(next);
247
+ }
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Every decision that governs `row`, in discovery order: the decision its
253
+ * `decisionRef` names (an ADR record, or — for a `rule:`/`fitness:`-shaped
254
+ * citation — the decisions whose `bindings` carry that id), plus every
255
+ * decision whose `bindings` name the row's own id. A citation that resolves
256
+ * to nothing lands in `unresolved`, never in a silent skip.
257
+ *
258
+ * @returns {number} How many governing decisions were attached.
259
+ */
260
+ function governingDecisionsFor(g, row, ctx) {
261
+ let count = 0;
262
+ const decisionRef = row.decisionRef;
263
+ if (typeof decisionRef === "string" && decisionRef.trim() !== "") {
264
+ const resolution = resolveDecisionRef(ctx.byId, ctx.knownFitness, decisionRef);
265
+ if (resolution === "adr") {
266
+ const record = ctx.byId.get(decisionIdOf(decisionRef));
267
+ attachLineage(g, record, ctx);
268
+ g.edge(row.id, record.id, "decisionRef");
269
+ count += 1;
270
+ } else if (resolution === "fitness") {
271
+ const target = stripRuleFitnessPrefix(decisionRef);
272
+ for (const record of bindingRecords(ctx.records, target)) {
273
+ attachLineage(g, record, ctx);
274
+ g.edge(record.id, row.id, "binding");
275
+ count += 1;
276
+ }
277
+ } else {
278
+ g.unresolved(
279
+ decisionRef,
280
+ "decision",
281
+ `${row.id} cites "${decisionRef}", which does not resolve — no matching ADR, rule, or fitness record`,
282
+ );
283
+ }
284
+ }
285
+ const target = stripRuleFitnessPrefix(row.id);
286
+ for (const record of bindingRecords(ctx.records, target)) {
287
+ attachLineage(g, record, ctx);
288
+ g.edge(record.id, row.id, "binding");
289
+ count += 1;
290
+ }
291
+ return count;
292
+ }
293
+
294
+ /** Adds a governed row's node plus its governed projects and their findings. */
295
+ function attachRowLeg(g, row, ctx) {
296
+ g.node(row.id, row.kind, row.id);
297
+ for (const projectId of row.governs ?? []) {
298
+ g.node(projectId, "project", projectId);
299
+ g.edge(row.id, projectId, "governs");
300
+ if (ctx.findingsByProject === undefined) {
301
+ g.unresolved(
302
+ projectId,
303
+ "project",
304
+ `the context provides no findingsByProject lookup — the walk cannot claim "${projectId}" has no findings`,
305
+ );
306
+ continue;
307
+ }
308
+ for (const finding of ctx.findingsByProject(projectId)) {
309
+ g.node(finding.id, "finding", finding.id, finding);
310
+ g.edge(projectId, finding.id, "finding");
311
+ }
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Forward walk: a decision -> the governed rows that attach to it (rows whose
317
+ * `decisionRef` names it — bare or `adr:`-prefixed — plus the rows whose ids
318
+ * its `bindings` name) -> the projects/parts those rows govern -> the
319
+ * projects' current findings/evidence, all read-only.
320
+ *
321
+ * Every hop that cannot resolve is reported: an unknown decision id, a
322
+ * binding that names no governed row, a governed project the context cannot
323
+ * produce findings for. A decision that resolves and attaches nothing is the
324
+ * true fact "recorded but not enforceable", and stays `ok: true`.
325
+ *
326
+ * @param {string} decisionId An ADR id (`0002-case`, or `adr:0002-case`).
327
+ * @param {GraphWalkContext} ctx
328
+ * @returns {GraphWalk}
329
+ */
330
+ export function forwardDecision(decisionId, ctx) {
331
+ const g = newWalk();
332
+ const record = ctx.byId.get(decisionIdOf(decisionId));
333
+ if (record === undefined) {
334
+ g.unresolved(
335
+ decisionId,
336
+ "decision",
337
+ `"${decisionId}" does not resolve — no matching ADR record in the registry`,
338
+ );
339
+ return g.walk;
340
+ }
341
+ g.node(record.id, "decision", record.id, decisionNode(record).data);
342
+
343
+ const boundIds = new Set(record.bindings.map((binding) => stripRuleFitnessPrefix(binding)));
344
+ for (const row of ctx.rows) {
345
+ const viaDecisionRef =
346
+ typeof row.decisionRef === "string" &&
347
+ resolveDecisionRef(ctx.byId, ctx.knownFitness, row.decisionRef) === "adr" &&
348
+ decisionIdOf(row.decisionRef) === record.id;
349
+ const viaBinding = boundIds.has(stripRuleFitnessPrefix(row.id));
350
+ if (!viaDecisionRef && !viaBinding) continue;
351
+ attachRowLeg(g, row, ctx);
352
+ if (viaDecisionRef) g.edge(row.id, record.id, "decisionRef");
353
+ if (viaBinding) g.edge(record.id, row.id, "binding");
354
+ }
355
+
356
+ for (const binding of record.bindings) {
357
+ const target = stripRuleFitnessPrefix(binding);
358
+ if (!ctx.rows.some((row) => stripRuleFitnessPrefix(row.id) === target)) {
359
+ g.unresolved(
360
+ binding,
361
+ "binding",
362
+ `"${binding}" is bound by ${record.id} but no governed row in the context carries that id`,
363
+ );
364
+ }
365
+ }
366
+ return g.walk;
367
+ }
368
+
369
+ /**
370
+ * Reverse walk: a constraint/finding/rule id — a `rule:` id, a `fitness:` id,
371
+ * an intent-row label, or a finding id — -> the decision(s) that govern it
372
+ * (via the rows' `decisionRef` citations and the decisions' `bindings`) ->
373
+ * each governing decision's rationale/context/lineage/status, all read-only.
374
+ *
375
+ * A reference that matches nothing, a row whose `decisionRef` does not
376
+ * resolve, and a row or finding no decision binds or cites are all reported
377
+ * in `unresolved` — a reverse walk never answers "no decision governs this"
378
+ * about a reference it could not establish.
379
+ *
380
+ * @param {string} rowRef The id to walk back from.
381
+ * @param {GraphWalkContext} ctx
382
+ * @returns {GraphWalk}
383
+ */
384
+ export function reverseRow(rowRef, ctx) {
385
+ const g = newWalk();
386
+
387
+ const row = ctx.rows.find((candidate) => candidate.id === rowRef);
388
+ if (row !== undefined) {
389
+ g.node(row.id, row.kind, row.id);
390
+ const governing = governingDecisionsFor(g, row, ctx);
391
+ if (governing === 0) {
392
+ g.unresolved(
393
+ rowRef,
394
+ "row",
395
+ `"${rowRef}" is governed by no decision — no decisionRef cites the row and no decision binds its id`,
396
+ );
397
+ }
398
+ return g.walk;
399
+ }
400
+
401
+ const finding = ctx.findingsById?.get(rowRef);
402
+ if (finding !== undefined) {
403
+ g.node(finding.id, "finding", finding.id, finding);
404
+ let governing = 0;
405
+ for (const candidate of ctx.rows) {
406
+ if ((candidate.governs ?? []).includes(finding.project)) {
407
+ governing += governingDecisionsFor(g, candidate, ctx);
408
+ }
409
+ }
410
+ const ruleId = stripRuleFitnessPrefix(finding.ruleId ?? "");
411
+ for (const record of bindingRecords(ctx.records, ruleId)) {
412
+ attachLineage(g, record, ctx);
413
+ g.edge(record.id, finding.id, "binding");
414
+ governing += 1;
415
+ }
416
+ if (governing === 0) {
417
+ g.unresolved(
418
+ rowRef,
419
+ "finding",
420
+ `"${rowRef}" is governed by no decision — no row governing its project cites one and no decision binds rule "${ruleId}"`,
421
+ );
422
+ }
423
+ return g.walk;
424
+ }
425
+
426
+ if (rowRef.startsWith("rule:") || rowRef.startsWith("fitness:")) {
427
+ const kind = rowRef.startsWith("rule:") ? "rule" : "fitness";
428
+ const target = stripRuleFitnessPrefix(rowRef);
429
+ let governing = 0;
430
+ for (const candidate of ctx.rows) {
431
+ if (stripRuleFitnessPrefix(candidate.id) === target) {
432
+ g.node(candidate.id, candidate.kind, candidate.id);
433
+ governing += governingDecisionsFor(g, candidate, ctx);
434
+ }
435
+ }
436
+ for (const record of bindingRecords(ctx.records, target)) {
437
+ attachLineage(g, record, ctx);
438
+ g.edge(record.id, rowRef, "binding");
439
+ governing += 1;
440
+ }
441
+ if (governing === 0) {
442
+ g.unresolved(
443
+ rowRef,
444
+ kind,
445
+ `"${rowRef}" does not resolve — no governed row carries that id and no decision binds it`,
446
+ );
447
+ }
448
+ return g.walk;
449
+ }
450
+
451
+ g.unresolved(
452
+ rowRef,
453
+ "row",
454
+ `"${rowRef}" does not resolve — no governed row, finding, rule, or fitness id carries it`,
455
+ );
456
+ return g.walk;
457
+ }
458
+
459
+ /**
460
+ * Lineage walk: the full supersession chain of a decision, `supersedes`
461
+ * forward and derived `supersededBy` backward, cycle-safe and deterministic
462
+ * (registry order, first-discovery emission). An unknown start id — and a
463
+ * chain that names a record the registry does not hold — is reported in
464
+ * `unresolved`, never as an empty chain.
465
+ *
466
+ * @param {string} decisionId An ADR id (`0002-case`, or `adr:0002-case`).
467
+ * @param {GraphWalkContext} ctx
468
+ * @returns {GraphWalk}
469
+ */
470
+ export function lineage(decisionId, ctx) {
471
+ const g = newWalk();
472
+ const record = ctx.byId.get(decisionIdOf(decisionId));
473
+ if (record === undefined) {
474
+ g.unresolved(
475
+ decisionId,
476
+ "decision",
477
+ `"${decisionId}" does not resolve — no matching ADR record in the registry`,
478
+ );
479
+ return g.walk;
480
+ }
481
+ attachLineage(g, record, ctx);
482
+ return g.walk;
483
+ }
@@ -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
- // Replaces, never merges: an explicit list takes over the whole
762
- // decision `DEFAULT_INFER_EXCLUDE`'s doc comment owns the why.
763
- exclude: rawInfer.exclude ?? DEFAULT_INFER_EXCLUDE,
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) => {