@ecoma-io/archkeep 0.20.0 → 0.21.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/package.json +1 -1
- package/src/commands/completeness.mjs +601 -0
- package/src/commands/evaluation-primitives.mjs +499 -0
- package/src/commands/impact-statement.mjs +31 -409
- package/src/commands/impact.mjs +5 -4
- package/src/commands/provenance-command.mjs +33 -2
- package/src/commands/scenario-evaluation.mjs +284 -196
- package/src/commands/scenario.mjs +4 -3
- package/src/governance/provenance-graph.mjs +479 -0
- package/src/report/provenance-text.mjs +30 -7
|
@@ -61,7 +61,7 @@ export function scenarioCommand(projectName, scenarioJson, commandContext, confi
|
|
|
61
61
|
const scenario = evaluateScenario(projectName, commandContext, scenarioInput, config);
|
|
62
62
|
|
|
63
63
|
const coverage = {
|
|
64
|
-
complete:
|
|
64
|
+
complete: true,
|
|
65
65
|
projects: Object.keys(graph.nodes).length,
|
|
66
66
|
analyzedFiles: commandContext.analysis.analyzed,
|
|
67
67
|
imports: commandContext.analysis.imports.length,
|
|
@@ -81,6 +81,7 @@ export function scenarioCommand(projectName, scenarioJson, commandContext, confi
|
|
|
81
81
|
virtual: scenario.virtual,
|
|
82
82
|
notAuthoritative: scenario.notAuthoritative,
|
|
83
83
|
complete: scenario.complete,
|
|
84
|
+
completeness: scenario.completeness,
|
|
84
85
|
project: scenario.project,
|
|
85
86
|
base: scenario.base,
|
|
86
87
|
changes: scenario.changes,
|
|
@@ -167,10 +168,10 @@ function formatScenarioReport(scenario) {
|
|
|
167
168
|
if (delta.dependentsAdded.length === 0 && delta.dependentsRemoved.length === 0) {
|
|
168
169
|
lines.push(" No change to dependent set");
|
|
169
170
|
}
|
|
170
|
-
if (delta.constraintsChanged) {
|
|
171
|
+
if (delta.constraintsChanged && delta.constraintsChanged.status === "changed") {
|
|
171
172
|
lines.push(" Constraint impact: CHANGED");
|
|
172
173
|
}
|
|
173
|
-
if (delta.decisionsChanged) {
|
|
174
|
+
if (delta.decisionsChanged && delta.decisionsChanged.status === "changed") {
|
|
174
175
|
lines.push(" Decision impact: CHANGED");
|
|
175
176
|
}
|
|
176
177
|
lines.push("");
|
|
@@ -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
|
+
}
|
|
@@ -31,13 +31,9 @@
|
|
|
31
31
|
* created: string|null, updated: string|null, supersedes: string[],
|
|
32
32
|
* supersededBy: string[], bindings: string[],
|
|
33
33
|
* attribution: {createdBy: object|null, lastChangedBy: object|null}|null,
|
|
34
|
-
* attested: boolean, note: string|null}[]
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* "no fact, no claim" bargain every optional axis in this tool states.
|
|
38
|
-
* `decisionLifecycle` (optional, default `[]`) is the decision-lifecycle
|
|
39
|
-
* section — it renders only when non-empty.
|
|
40
|
-
* @returns {string}
|
|
34
|
+
* attested: boolean, note: string|null}[],
|
|
35
|
+
* provenanceGraph?: {nodes: object[], edges: object[], claims: object[],
|
|
36
|
+
* causalChains: object[]}|null}} input
|
|
41
37
|
*/
|
|
42
38
|
export function formatProvenanceReport({
|
|
43
39
|
establishment,
|
|
@@ -47,6 +43,7 @@ export function formatProvenanceReport({
|
|
|
47
43
|
decisionRefTotal,
|
|
48
44
|
unresolvedDecisionRefs,
|
|
49
45
|
decisionLifecycle = [],
|
|
46
|
+
provenanceGraph = null,
|
|
50
47
|
}) {
|
|
51
48
|
const attestedCount = rowsTotal - unattested.length;
|
|
52
49
|
const text = [];
|
|
@@ -140,5 +137,31 @@ export function formatProvenanceReport({
|
|
|
140
137
|
);
|
|
141
138
|
}
|
|
142
139
|
}
|
|
140
|
+
|
|
141
|
+
// PR4 — provenance graph summary and one chain example
|
|
142
|
+
if (provenanceGraph !== null && provenanceGraph !== undefined) {
|
|
143
|
+
const nodeCount = provenanceGraph.nodes.length;
|
|
144
|
+
const edgeCount = provenanceGraph.edges.length;
|
|
145
|
+
const claimCount = provenanceGraph.claims.length;
|
|
146
|
+
const chainCount = provenanceGraph.causalChains.length;
|
|
147
|
+
text.push(
|
|
148
|
+
`graph ${nodeCount} nodes, ${edgeCount} edges, ${claimCount} claims, ` +
|
|
149
|
+
`${chainCount} causal chain${chainCount === 1 ? "" : "s"}`,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Show the first causal chain as an example (deterministic — sorted order)
|
|
153
|
+
if (chainCount > 0) {
|
|
154
|
+
const chain = provenanceGraph.causalChains[0];
|
|
155
|
+
text.push(
|
|
156
|
+
`chain ${chain.id} — ${chain.hops.length} hop${chain.hops.length === 1 ? "" : "s"}`,
|
|
157
|
+
);
|
|
158
|
+
text.push(` ${chain.startNode}`);
|
|
159
|
+
for (const hop of chain.hops) {
|
|
160
|
+
const evidence = hop.evidence.map((e) => e.detail).join("; ");
|
|
161
|
+
text.push(` → ${hop.toNode} (${hop.edgeKind}: ${evidence})`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
143
166
|
return text.join("\n");
|
|
144
167
|
}
|