@ecoma-io/archkeep 0.20.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/cli.mjs +156 -66
  2. package/package.json +1 -1
  3. package/src/analysis/contract.md +32 -5
  4. package/src/analysis/source-util.mjs +107 -0
  5. package/src/analysis/typescript.mjs +86 -5
  6. package/src/commands/change.mjs +59 -28
  7. package/src/commands/check.mjs +65 -26
  8. package/src/commands/completeness.mjs +708 -0
  9. package/src/commands/context-command.mjs +13 -5
  10. package/src/commands/context.mjs +31 -4
  11. package/src/commands/coverage-verdict.mjs +184 -0
  12. package/src/commands/debt.mjs +18 -15
  13. package/src/commands/delta-classify.mjs +13 -18
  14. package/src/commands/delta.mjs +95 -33
  15. package/src/commands/diff.mjs +31 -24
  16. package/src/commands/discover.mjs +30 -10
  17. package/src/commands/drift.mjs +21 -21
  18. package/src/commands/edge-constraints.mjs +47 -1
  19. package/src/commands/evaluation-primitives.mjs +691 -0
  20. package/src/commands/evolution.mjs +27 -10
  21. package/src/commands/explain.mjs +14 -13
  22. package/src/commands/fitness.mjs +20 -19
  23. package/src/commands/graph.mjs +14 -5
  24. package/src/commands/health.mjs +12 -5
  25. package/src/commands/history.mjs +29 -15
  26. package/src/commands/impact-statement.mjs +31 -409
  27. package/src/commands/impact.mjs +18 -18
  28. package/src/commands/plan-context-command.mjs +10 -5
  29. package/src/commands/provenance-command.mjs +33 -2
  30. package/src/commands/reconcile.mjs +14 -17
  31. package/src/commands/scenario-evaluation.mjs +363 -198
  32. package/src/commands/scenario.mjs +32 -21
  33. package/src/commands/waivers.mjs +36 -28
  34. package/src/governance/evolution-event.mjs +62 -9
  35. package/src/governance/provenance-graph.mjs +479 -0
  36. package/src/intent/intent-manifest.json +83 -39
  37. package/src/report/json.mjs +32 -5
  38. package/src/report/provenance-text.mjs +30 -7
  39. package/src/report/text.mjs +82 -12
  40. package/src/verdict.mjs +78 -36
  41. package/src/workspace.mjs +126 -2
@@ -0,0 +1,479 @@
1
+ /**
2
+ * Computes per-decision provenance from ADR records and file attribution.
3
+ *
4
+ * Pure function — no filesystem, no wall clock. Returns a Map of decision id
5
+ * to `{attested, attribution}`. When `fileAttribution` cannot answer (returns
6
+ * null), every decision is unattested.
7
+ *
8
+ * This is the shared helper both `buildProvenanceGraph` and the impact/scenario
9
+ * evaluation callers use, so decision provenance is computed identically
10
+ * everywhere — "via the same graph helper, never re-derived" (PR4).
11
+ *
12
+ * @param {{id: string}[]} records ADR records
13
+ * @param {(path: string) => {createdBy: object|null,
14
+ * lastChangedBy: object|null}|null} [fileAttribution]
15
+ * Resolves git attribution for a decision record file. Defaults to a
16
+ * function that always returns null.
17
+ * @returns {Map<string, {attested: boolean, attribution: object|null}>}
18
+ */
19
+ export function computeDecisionProvenance(records, fileAttribution = () => null) {
20
+ const provenance = new Map();
21
+ for (const record of records) {
22
+ const attribution = fileAttribution(`docs/adr/${record.id}.md`);
23
+ provenance.set(record.id, {
24
+ attested: attribution !== null,
25
+ attribution: attribution ?? null,
26
+ });
27
+ }
28
+ return provenance;
29
+ }
30
+ /**
31
+ * The provenance graph: a pure, deterministic composition of every existing
32
+ * provenance capability into a single traversable structure with nodes, edges,
33
+ * claims, and causal chains.
34
+ *
35
+ * ## What it composes
36
+ *
37
+ * - `decision-graph.mjs` — the supersession lineage and decision-ref resolution
38
+ * - `provenance-record.mjs` / `row-schema.mjs` — origin attestation
39
+ * - `adr-registry.mjs` — `resolveDecisionRef`, `stripRuleFitnessPrefix`,
40
+ * `supersededByIndex`
41
+ *
42
+ * ## Node kinds
43
+ *
44
+ * - `repo` — the workspace's git state. One node. Evidence: git provenance.
45
+ * - `row:<kind>:<index>` — one governance row. Evidence: the row's origin
46
+ * record, or a note that none exists.
47
+ * - `decision:<id>` — one ADR record. Evidence: the record file's git
48
+ * attribution, or a note that none is available.
49
+ *
50
+ * ## Edge kinds
51
+ *
52
+ * - `provenance` — repo → row. The workspace provenance attests the row.
53
+ * - `decisionRef` — row → decision. The row's `decisionRef` cites a decision.
54
+ * Evidence carries `{resolved: boolean, reason}` from `resolveDecisionRef`.
55
+ * - `binding` — decision → row. The decision's `bindings` name the row's id.
56
+ * - `supersedes` — decision → decision. Supersession chain forward.
57
+ * - `supersededBy` — decision → decision. Supersession chain reverse (derived
58
+ * from the supersededBy index).
59
+ *
60
+ * ## Determinism
61
+ *
62
+ * Every emitted array is sorted byte-wise (no `localeCompare`), and every
63
+ * node/edge/claim is deduplicated by id/key. Input order (the caller's row
64
+ * list) is preserved for row nodes; decision nodes are sorted by id; edges
65
+ * and claims are sorted by their canonical keys.
66
+ *
67
+ * ## Claims
68
+ *
69
+ * Three categories, each a flat list of `{id, kind, verdict, evidence}`:
70
+ *
71
+ * - `"attestation"` — per row: whether the row carries an origin.
72
+ * - `"resolution"` — per decisionRef on a row: whether the ref resolves.
73
+ * - `"lifecycle"` — per decision: whether its lifecycle is attributed.
74
+ *
75
+ * An empty `evidence` array is itself a claim — present, not a missing key.
76
+ *
77
+ * @module
78
+ */
79
+
80
+ import { resolveDecisionRef, stripRuleFitnessPrefix, stripAdrPrefix } from "./adr-registry.mjs";
81
+
82
+ /**
83
+ * @typedef {object} ProvenanceGraphInput
84
+ * @property {object|null} repo
85
+ * Git provenance info (`{commit, remote, dirty}`) or null when unavailable.
86
+ * @property {{kind: string, attested: boolean, origin: object|null,
87
+ * decisionRef?: string, label: string, id?: string}[]} rows
88
+ * Governance rows to include as nodes.
89
+ * @property {object[]} records
90
+ * ADR records for decision nodes and supersession edges.
91
+ * @property {Map<string, object>} byId
92
+ * Decision record lookup map.
93
+ * @property {Set<string>} knownFitness
94
+ * Fitness record names for resolution.
95
+ * @property {(path: string) => object|null} [fileAttribution]
96
+ * Resolves git attribution for a decision record file. Passed through to
97
+ * `computeDecisionProvenance`. Defaults to a function that always returns null.
98
+ * @property {{id: string, attested: boolean, attribution: object|null}[]}
99
+ * decisionLifecycle
100
+ * @typedef {object} ProvenanceGraphNode
101
+ * @property {string} id
102
+ * @property {string} kind
103
+ * @property {string} label
104
+ * @property {{origin: object|null, evidence: {kind: string, file: string|null,
105
+ * commit: string|null}}} data
106
+ */
107
+
108
+ /**
109
+ * @typedef {object} ProvenanceGraphEdge
110
+ * @property {string} from
111
+ * @property {string} to
112
+ * @property {string} kind
113
+ * @property {{resolved?: boolean, reason?: string}} [evidence]
114
+ * Present on `decisionRef` edges; absent on structural edges.
115
+ */
116
+
117
+ /**
118
+ * @typedef {object} ProvenanceClaim
119
+ * @property {string} id
120
+ * @property {"attestation"|"resolution"|"lifecycle"} kind
121
+ * @property {"attested"|"unattested"|"resolved"|"unresolved"} verdict
122
+ * @property {{kind: string, detail: string}[]} evidence
123
+ */
124
+
125
+ /**
126
+ * @typedef {object} CausalChainLink
127
+ * @property {string} fromNode
128
+ * @property {string} toNode
129
+ * @property {string} edgeKind
130
+ * @property {{kind: string, detail: string}[]} evidence
131
+ */
132
+
133
+ /**
134
+ * @typedef {object} CausalChain
135
+ * @property {string} id
136
+ * @property {string} startNode
137
+ * @property {string} endNode
138
+ * @property {CausalChainLink[]} hops
139
+ */
140
+
141
+ /**
142
+ * @typedef {object} ProvenanceGraph
143
+ * @property {ProvenanceGraphNode[]} nodes
144
+ * @property {ProvenanceGraphEdge[]} edges
145
+ * @property {ProvenanceClaim[]} claims
146
+ * @property {CausalChain[]} causalChains
147
+ */
148
+
149
+ /** @type {(value: unknown) => string[]} */
150
+ function sortedArray(value) {
151
+ if (!Array.isArray(value)) return [];
152
+ return [...value].sort();
153
+ }
154
+
155
+ /** @type {(value: unknown) => string} */
156
+ function str(value) {
157
+ if (typeof value === "string") return value;
158
+ return "";
159
+ }
160
+
161
+ /**
162
+ * Builds the provenance graph: nodes, edges, claims, and causal chains.
163
+ *
164
+ * Pure function of its inputs — no filesystem, no wall clock.
165
+ * Every output array is sorted deterministically.
166
+ *
167
+ * @param {ProvenanceGraphInput} input
168
+ */
169
+ export function buildProvenanceGraph({
170
+ repo,
171
+ rows = [],
172
+ records = [],
173
+ byId = new Map(),
174
+ knownFitness = new Set(),
175
+ fileAttribution: _fileAttribution = () => null,
176
+ decisionLifecycle = [],
177
+ }) {
178
+ const nodes = [];
179
+ const edges = [];
180
+ const claims = [];
181
+ const nodeIds = new Set();
182
+ const edgeKeys = new Set();
183
+ const claimKeys = new Set();
184
+
185
+ /** @type {(id: string, kind: string, label: string, data: object) => void} */
186
+ function addNode(id, kind, label, data) {
187
+ if (nodeIds.has(id)) return;
188
+ nodeIds.add(id);
189
+ nodes.push({ id, kind, label, data });
190
+ }
191
+
192
+ /** @type {(from: string, to: string, kind: string, evidence?: object) => void} */
193
+ function addEdge(from, to, kind, evidence) {
194
+ const key = `${from}\u0000${kind}\u0000${to}`;
195
+ if (edgeKeys.has(key)) return;
196
+ edgeKeys.add(key);
197
+ const edge = { from, to, kind };
198
+ if (evidence !== undefined) edge.evidence = evidence;
199
+ edges.push(edge);
200
+ }
201
+
202
+ /** @type {(id: string, kind: "attestation"|"resolution"|"lifecycle", verdict: string, evidence: {kind: string, detail: string}[]) => void} */
203
+ function addClaim(id, kind, verdict, evidence) {
204
+ const key = `${kind}\u0000${id}`;
205
+ if (claimKeys.has(key)) return;
206
+ claimKeys.add(key);
207
+ claims.push({ id, kind, verdict, evidence: evidence ?? [] });
208
+ }
209
+
210
+ // ── Repo node ──────────────────────────────────────────────────────────
211
+ const repoCommit = repo?.commit ?? null;
212
+ const repoId = repoCommit !== null ? `repo:${repoCommit}` : "repo:unavailable";
213
+ addNode(repoId, "repo", repoCommit ?? "unavailable", {
214
+ origin: repo ?? null,
215
+ evidence: {
216
+ kind: "git",
217
+ file: null,
218
+ commit: repoCommit,
219
+ },
220
+ });
221
+
222
+ // ── Row nodes ──────────────────────────────────────────────────────────
223
+ const rowEntries = [];
224
+ for (let i = 0; i < rows.length; i++) {
225
+ const row = rows[i];
226
+ const rowId = `row:${row.kind}:${i}`;
227
+ const label = row.label ?? `${row.kind}[${i}]`;
228
+ addNode(rowId, "row", label, {
229
+ origin: row.origin ?? null,
230
+ evidence: {
231
+ kind: "governance-row",
232
+ file: null,
233
+ commit: null,
234
+ },
235
+ });
236
+
237
+ // Edge: repo → row
238
+ addEdge(repoId, rowId, "provenance");
239
+
240
+ // Claim: row attestation
241
+ const attestationVerdict = row.attested ? "attested" : "unattested";
242
+ const attestationEvidence = row.attested
243
+ ? [
244
+ {
245
+ kind: "origin",
246
+ detail: `origin recorded: by=${str(row.origin?.by)}, tool=${str(row.origin?.tool)}`,
247
+ },
248
+ ]
249
+ : [{ kind: "origin", detail: "no origin recorded" }];
250
+ addClaim(rowId, "attestation", attestationVerdict, attestationEvidence);
251
+
252
+ rowEntries.push({ rowId, row, index: i });
253
+
254
+ // ── DecisionRef edge: row → decision ─────────────────────────────────
255
+ if (typeof row.decisionRef === "string" && row.decisionRef.trim() !== "") {
256
+ const ref = row.decisionRef.trim();
257
+ const resolution = resolveDecisionRef(byId, knownFitness, ref);
258
+ const resolved = resolution !== "unknown";
259
+ const decisionId = resolved ? stripAdrPrefix(ref) : ref;
260
+ const targetId = resolved ? `decision:${decisionId}` : `unresolved:${ref}`;
261
+
262
+ addEdge(rowId, targetId, "decisionRef", {
263
+ resolved,
264
+ reason: resolved
265
+ ? `resolves as ${resolution}`
266
+ : `"${ref}" does not resolve — no matching ADR, rule, or fitness record`,
267
+ });
268
+
269
+ // Claim: decisionRef resolution
270
+ const resolutionVerdict = resolved ? "resolved" : "unresolved";
271
+ const resolutionEvidence = [
272
+ {
273
+ kind: "decisionRef",
274
+ detail: resolved ? `resolves to ${decisionId}` : `unresolved ref: ${ref}`,
275
+ },
276
+ {
277
+ kind: "resolveDecisionRef",
278
+ detail:
279
+ resolution === "adr"
280
+ ? "resolved via ADR registry"
281
+ : resolution === "fitness"
282
+ ? "resolved via fitness names"
283
+ : "unknown",
284
+ },
285
+ ];
286
+ addClaim(`${rowId}\u0000${ref}`, "resolution", resolutionVerdict, resolutionEvidence);
287
+ }
288
+ }
289
+
290
+ // ── Decision nodes ─────────────────────────────────────────────────────
291
+ const lifecycleById = new Map();
292
+ for (const entry of decisionLifecycle) {
293
+ lifecycleById.set(entry.id, entry);
294
+ }
295
+
296
+ // Sort records by id for determinism
297
+ const sortedRecords = [...records].sort((a, b) => {
298
+ if (a.id < b.id) return -1;
299
+ if (a.id > b.id) return 1;
300
+ return 0;
301
+ });
302
+
303
+ for (const record of sortedRecords) {
304
+ const decisionId = `decision:${record.id}`;
305
+
306
+ // Attribution evidence
307
+ const lifecycle = lifecycleById.get(record.id);
308
+ const attested = lifecycle?.attested ?? false;
309
+ const attribution = lifecycle?.attribution ?? null;
310
+
311
+ const attributionEvidence = attested
312
+ ? [
313
+ { kind: "file-attribution", detail: `created by ${str(attribution?.createdBy?.by)}` },
314
+ {
315
+ kind: "file-attribution",
316
+ detail: `last changed by ${str(attribution?.lastChangedBy?.by)}`,
317
+ },
318
+ ]
319
+ : [{ kind: "file-attribution", detail: "no origin recorded — cannot attest" }];
320
+
321
+ addNode(decisionId, "decision", record.id, {
322
+ attribution,
323
+ evidence: {
324
+ kind: "adr-record",
325
+ file: `docs/adr/${record.id}.md`,
326
+ commit: repoCommit,
327
+ },
328
+ });
329
+
330
+ // Claim: lifecycle attestation
331
+ const lifecycleVerdict = attested ? "attested" : "unattested";
332
+ addClaim(decisionId, "lifecycle", lifecycleVerdict, attributionEvidence);
333
+
334
+ // ── Supersession edges ───────────────────────────────────────────────
335
+ for (const supersedes of sortedArray(record.supersedes)) {
336
+ addEdge(decisionId, `decision:${supersedes}`, "supersedes");
337
+ }
338
+ for (const supersededBy of sortedArray(record.supersededBy)) {
339
+ addEdge(`decision:${supersededBy}`, decisionId, "supersedes");
340
+ }
341
+
342
+ // ── Binding edges: decision → row ────────────────────────────────────
343
+ const bindings = sortedArray(record.bindings);
344
+ for (const binding of bindings) {
345
+ const target = stripRuleFitnessPrefix(binding);
346
+ for (const { rowId, row } of rowEntries) {
347
+ if (stripRuleFitnessPrefix(row.id ?? row.label ?? "") === target) {
348
+ addEdge(decisionId, rowId, "binding");
349
+ }
350
+ }
351
+ }
352
+ }
353
+
354
+ // ── Sort outputs deterministically ─────────────────────────────────────
355
+ const sortedNodes = [...nodes].sort((a, b) => {
356
+ if (a.id < b.id) return -1;
357
+ if (a.id > b.id) return 1;
358
+ return 0;
359
+ });
360
+
361
+ const sortedEdges = [...edges].sort((a, b) => {
362
+ const ka = `${a.from}\u0000${a.kind}\u0000${a.to}`;
363
+ const kb = `${b.from}\u0000${b.kind}\u0000${b.to}`;
364
+ if (ka < kb) return -1;
365
+ if (ka > kb) return 1;
366
+ return 0;
367
+ });
368
+
369
+ const sortedClaims = [...claims].sort((a, b) => {
370
+ if (a.id < b.id) return -1;
371
+ if (a.id > b.id) return 1;
372
+ return 0;
373
+ });
374
+
375
+ // ── Causal chains ─────────────────────────────────────────────────────
376
+ // For each row that has a decisionRef, BFS through decision → lineage.
377
+ // Each chain walks from the row through the decision lineage, creating
378
+ // one hop per decision. The first hop connects the row to its referenced
379
+ // decision; subsequent hops follow supersedes links forward.
380
+ const causalChains = [];
381
+
382
+ for (const { rowId, row } of rowEntries) {
383
+ if (typeof row.decisionRef !== "string" || row.decisionRef.trim() === "") continue;
384
+
385
+ const ref = row.decisionRef.trim();
386
+ const resolution = resolveDecisionRef(byId, knownFitness, ref);
387
+ if (resolution !== "adr") continue;
388
+
389
+ const decisionId = stripAdrPrefix(ref);
390
+ const chainId = `${rowId}→decision:${decisionId}`;
391
+ const chainNodes = [];
392
+ const chainEdges = [];
393
+ const visited = new Set();
394
+ const queue = [decisionId];
395
+ /** @type {Map<string, string|null>} parent of each decision id; null means root (row-linked) */
396
+ const parentMap = new Map();
397
+ parentMap.set(decisionId, null);
398
+
399
+ while (queue.length > 0) {
400
+ const currentId = queue.shift();
401
+ if (visited.has(currentId)) continue;
402
+ visited.add(currentId);
403
+
404
+ const record = byId.get(currentId);
405
+ if (record === undefined) continue;
406
+
407
+ // Enqueue supersedes children
408
+ for (const nextId of sortedArray(record.supersedes)) {
409
+ if (!visited.has(nextId)) {
410
+ queue.push(nextId);
411
+ parentMap.set(nextId, currentId);
412
+ }
413
+ }
414
+
415
+ const nodeId = `decision:${currentId}`;
416
+ chainNodes.push(nodeId);
417
+
418
+ // Create edge from parent → current
419
+ // parentMap always has currentId after initialization above
420
+ if (parentMap.has(currentId)) {
421
+ const parent = parentMap.get(currentId);
422
+
423
+ // Build evidence for this hop
424
+ const hopEvidence = [];
425
+
426
+ // Row origin evidence on the first hop (parent === null means root)
427
+ if (parent === null && row.origin) {
428
+ hopEvidence.push({ kind: "origin", detail: `row origin: by=${str(row.origin.by)}` });
429
+ }
430
+
431
+ // Supersedes evidence — this edge exists because currentId supersedes parent
432
+ if (parent !== null) {
433
+ hopEvidence.push({ kind: "supersedes", detail: `supersedes ${parent}` });
434
+ }
435
+
436
+ // Decision attribution evidence
437
+ const lc = lifecycleById.get(currentId);
438
+ if (lc?.attested && lc?.attribution) {
439
+ hopEvidence.push({
440
+ kind: "file-attribution",
441
+ detail: `decision attributed: ${str(lc.attribution.createdBy?.by)}`,
442
+ });
443
+ } else {
444
+ hopEvidence.push({ kind: "file-attribution", detail: "decision not attributed" });
445
+ }
446
+
447
+ chainEdges.push({
448
+ fromNode: parent !== null ? `decision:${parent}` : rowId,
449
+ toNode: nodeId,
450
+ edgeKind: "supersedes",
451
+ evidence: hopEvidence,
452
+ });
453
+ }
454
+ }
455
+
456
+ if (chainNodes.length > 0) {
457
+ causalChains.push({
458
+ id: chainId,
459
+ startNode: rowId,
460
+ endNode: `decision:${chainNodes[chainNodes.length - 1]}`,
461
+ hops: chainEdges,
462
+ });
463
+ }
464
+ }
465
+
466
+ // Sort causal chains deterministically
467
+ const sortedChains = [...causalChains].sort((a, b) => {
468
+ if (a.id < b.id) return -1;
469
+ if (a.id > b.id) return 1;
470
+ return 0;
471
+ });
472
+
473
+ return {
474
+ nodes: sortedNodes,
475
+ edges: sortedEdges,
476
+ claims: sortedClaims,
477
+ causalChains: sortedChains,
478
+ };
479
+ }