@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.
@@ -39,12 +39,7 @@
39
39
  *
40
40
  * @module
41
41
  */
42
- import { readAdrContext } from "./adr.mjs";
43
- import { computeImpactConstraints } from "./edge-constraints.mjs";
44
- import { computeImpact } from "./impact.mjs";
45
- import { hasAuthority, resolveDecisionRef, stripAdrPrefix } from "../governance/adr-registry.mjs";
46
- import { isComboDepConstraint } from "../rules/tags.mjs";
47
-
42
+ import { evaluateArchitectureState } from "./evaluation-primitives.mjs";
48
43
  /**
49
44
  * @typedef {object} ImpactStatement
50
45
  * @property {string} project The target project name.
@@ -60,365 +55,23 @@ import { isComboDepConstraint } from "../rules/tags.mjs";
60
55
  * @property {{projects: string[], boundaries: string[], constraints: string[],
61
56
  * decisions: string[]}} [evolutionAlignment] The `affected` shape matching
62
57
  * `EvolutionEvent.affected` vocabulary.
63
- * @property {{evaluated: boolean, entries: object[], note: string|null}} findingsImpact
58
+ * @property {{evaluated: boolean, findings: object[], count: number}} findingsImpact
64
59
  * Findings that affect the impacted projects. `evaluated: false` when no
65
60
  * findings data was provided — reported as a gap, never as "no findings".
66
- * @property {{evaluated: boolean, entries: object[], note: string|null}} debtImpact
61
+ * @property {{evaluated: boolean, debt: object[], count: number}} debtImpact
67
62
  * Debt entries that affect the impacted projects. `evaluated: false` when no
68
63
  * debt data was provided — reported as a gap, never as "no debt".
69
64
  * @property {{boundaries: object[], evaluated: boolean}} boundaryImpact
70
65
  * Boundary-crossing analysis for each affected edge. `evaluated: false` when
71
66
  * no constraint impact was available.
67
+ * @property {{domains: {structural: {status: string, note?: string}, constraint: {status: string, note?: string}, boundary: {status: string, note?: string}, decision: {status: string, note?: string}, governance: {status: string, note?: string}}, overallComplete: boolean, overallStatus: string}} completeness
68
+ * Completeness of each evaluation domain and the overall assessment.
72
69
  * @property {boolean} complete Whether the statement could be fully composed.
70
+ * Backward-compat alias for `completeness.overallComplete`.
73
71
  * @property {string[]} notes Caveats about statement completeness and
74
72
  * governance gaps.
75
73
  */
76
74
 
77
- /**
78
- * Resolve a decisionRef to its record details.
79
- *
80
- * @param {string} ref The decision reference (bare, `adr:`, or `rule:`/`fitness:`-prefixed).
81
- * @param {Map<string, object>} byId The ADR registry index.
82
- * @param {Set<string>} knownFitness Declared fitness names.
83
- * @returns {{resolution: "adr"|"fitness"|"unknown", record?: object}}
84
- */
85
- function resolveDecision(ref, byId, knownFitness) {
86
- const resolution = resolveDecisionRef(byId, knownFitness, ref);
87
- if (resolution === "adr") {
88
- const record = byId.get(stripAdrPrefix(ref));
89
- return { resolution, record };
90
- }
91
- if (resolution === "fitness") {
92
- // A fitness ref resolves but has no ADR record entry — it's a
93
- // rule/fitness id, not an ADR. We report the resolution but have
94
- // no record details for it.
95
- return { resolution };
96
- }
97
- return { resolution };
98
- }
99
-
100
- /**
101
- * Builds the decision impact section: which recorded decisions bind the
102
- * affected constraint rows.
103
- *
104
- * @param {string} root Workspace root path.
105
- * @param {object[]} constraintImpact Per-dependent constraint analysis.
106
- * @param {object} config The loaded boundary config (with `depConstraints`).
107
- * @returns {{decisions: object[], unresolvedDecisionRefs: string[]}|null}
108
- * null when the ADR registry is unreadable.
109
- */
110
- function buildDecisionImpact(root, constraintImpact, config) {
111
- // Collect unique decisionRefs ONLY from constraint rows that are actually
112
- // AFFECTED by the change — rows that govern edges from impacted dependents.
113
- // A decisionRef in the config is not enough: the decision must be causally
114
- // bound to a governance entity the change touches.
115
- const seenRefs = new Set();
116
- const affectedRefs = [];
117
-
118
- // Build evidence map: decisionRef -> { constraintRows, dependentProjects }
119
- /** @type {Map<string, {constraintRows: number[], dependentProjects: string[]}>} */
120
- const evidenceByRef = new Map();
121
-
122
- if (constraintImpact && config && config.depConstraints) {
123
- // Use identity matching: constraintImpact.constraintRows are the actual
124
- // config row objects returned by findConstraintsFor — check by reference,
125
- // not by string label, for exact causal binding.
126
- for (const entry of constraintImpact) {
127
- const activeRows = new Set(entry.constraintRows);
128
- const sourceProject = entry.project;
129
-
130
- for (let i = 0; i < config.depConstraints.length; i++) {
131
- const row = config.depConstraints[i];
132
- if (!row.decisionRef) continue;
133
- if (!activeRows.has(row)) continue;
134
-
135
- if (!evidenceByRef.has(row.decisionRef)) {
136
- evidenceByRef.set(row.decisionRef, {
137
- constraintRows: [],
138
- dependentProjects: [],
139
- });
140
- }
141
- const evidence = evidenceByRef.get(row.decisionRef);
142
- if (!evidence.constraintRows.includes(i)) {
143
- evidence.constraintRows.push(i);
144
- }
145
- if (!evidence.dependentProjects.includes(sourceProject)) {
146
- evidence.dependentProjects.push(sourceProject);
147
- }
148
-
149
- if (!seenRefs.has(row.decisionRef)) {
150
- seenRefs.add(row.decisionRef);
151
- affectedRefs.push(row.decisionRef);
152
- }
153
- }
154
- }
155
- }
156
-
157
- if (affectedRefs.length === 0) {
158
- return { decisions: [], unresolvedDecisionRefs: [] };
159
- }
160
-
161
- // Try to read the ADR registry — if it fails, all refs are unresolved
162
- let adrContext;
163
- try {
164
- adrContext = readAdrContext(root);
165
- } catch {
166
- return {
167
- decisions: [],
168
- unresolvedDecisionRefs: [...affectedRefs],
169
- };
170
- }
171
-
172
- const { byId, knownFitness } = adrContext;
173
- const unresolvedDecisionRefs = [];
174
- const decisions = [];
175
-
176
- for (const ref of affectedRefs) {
177
- const resolved = resolveDecision(ref, byId, knownFitness);
178
- const evidence = evidenceByRef.get(ref);
179
-
180
- if (resolved.resolution === "unknown") {
181
- unresolvedDecisionRefs.push(ref);
182
- continue;
183
- }
184
-
185
- if (resolved.resolution === "fitness") {
186
- // Fitness refs are not ADR records — report them as resolved
187
- // but with no record-level details
188
- decisions.push({
189
- id: ref,
190
- kind: "fitness",
191
- resolution: "known",
192
- evidence,
193
- });
194
- continue;
195
- }
196
-
197
- // ADR record
198
- const record = resolved.record;
199
- decisions.push({
200
- id: record.id,
201
- kind: "adr",
202
- status: record.status,
203
- hasAuthority: hasAuthority(record.status),
204
- supersedes: record.supersedes.length > 0 ? record.supersedes : undefined,
205
- supersededBy: (record.supersededBy ?? []).length > 0 ? record.supersededBy : undefined,
206
- evidence,
207
- });
208
- }
209
-
210
- return {
211
- decisions: decisions.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
212
- unresolvedDecisionRefs: [...new Set(unresolvedDecisionRefs)].sort(),
213
- };
214
- }
215
-
216
- /**
217
- * Builds the evolution alignment section: the `affected` shape matching
218
- * `EvolutionEvent.affected` vocabulary.
219
- *
220
- *
221
- * @param {{direct: string[], transitive: string[], dependents: string[]}} impact
222
- * @param {object[]} [constraintImpact] Per-dependent constraint rows.
223
- * @param {string[]} [resolvedDecisions] Decision IDs that bind affected rows.
224
- * @returns {{projects: string[], boundaries: string[], constraints: string[],
225
- * decisions: string[]}}
226
- */
227
- function buildEvolutionAlignment(projectName, impact, constraintImpact, resolvedDecisions) {
228
- const affectedProjects = [projectName, ...impact.dependents];
229
- const affectedConstraints = [];
230
- const affectedBoundaries = [];
231
-
232
- if (constraintImpact) {
233
- for (const entry of constraintImpact) {
234
- // Collect edge identities for each affected boundary
235
- for (const edge of entry.edges) {
236
- const edgeId = `${entry.project}>${edge.target}:${edge.type}`;
237
- if (!affectedBoundaries.includes(edgeId)) {
238
- affectedBoundaries.push(edgeId);
239
- }
240
- }
241
- // Collect constraint row labels
242
- for (const row of entry.constraintRows) {
243
- const label = isComboDepConstraint(row)
244
- ? `allSourceTags:${row.allSourceTags.join(",")}`
245
- : `sourceTag:${row.sourceTag}`;
246
- if (!affectedConstraints.includes(label)) {
247
- affectedConstraints.push(label);
248
- }
249
- }
250
- }
251
- }
252
-
253
- return {
254
- projects: [...new Set(affectedProjects)].sort(),
255
- boundaries: affectedBoundaries.sort(),
256
- constraints: affectedConstraints.sort(),
257
- decisions: resolvedDecisions ? [...new Set(resolvedDecisions)].sort() : [],
258
- };
259
- }
260
-
261
- // ---------------------------------------------------------------------------
262
- // Findings and Debt impact (governance integration)
263
- // ---------------------------------------------------------------------------
264
-
265
- /**
266
- * Evaluates findings impact for the affected projects.
267
- *
268
- * When no findings data is provided, reports the gap explicitly rather than
269
- * claiming no findings exist.
270
- *
271
- * @param {string[]} affectedProjects The projects affected by the change.
272
- * @param {object[]|null} [availableFindings] Optional pre-computed findings
273
- * from the check pipeline.
274
- * @returns {{evaluated: boolean, entries: object[], note: string|null}}
275
- * `evaluated: true` when findings data was available and filtered.
276
- * `evaluated: false` when findings were not provided.
277
- */
278
- function evaluateFindingsImpact(affectedProjects, availableFindings = null) {
279
- if (!availableFindings) {
280
- return {
281
- evaluated: false,
282
- entries: [],
283
- note: "findings impact not evaluated — no findings data provided to impact statement",
284
- };
285
- }
286
-
287
- // Filter findings by affected projects
288
- const affectedSet = new Set(affectedProjects);
289
- const entries = availableFindings.filter((f) => {
290
- const source = f.source ?? f.project ?? "";
291
- const target = f.target ?? "";
292
- return affectedSet.has(source) || affectedSet.has(target);
293
- });
294
-
295
- return {
296
- evaluated: true,
297
- entries,
298
- note:
299
- entries.length > 0
300
- ? `${entries.length} finding(s) affect impacted projects`
301
- : "no findings affect impacted projects",
302
- };
303
- }
304
-
305
- /**
306
- * Evaluates debt impact for the affected projects.
307
- *
308
- *
309
- * Debt entries have `source` as either a project name (for drift entries) or
310
- * a file path (for waiver entries). When a `resolveProject` function is
311
- * provided, file-path sources are resolved to project names for matching.
312
- * When no debt data is provided, reports the gap explicitly rather than
313
- * claiming no debt exists.
314
- *
315
- * @param {string[]} affectedProjects The projects affected by the change.
316
- * @param {object[]|null} [availableDebt] Optional pre-computed debt entries
317
- * from the debt ledger (`computeDebtLedger().entries`).
318
- * @param {function(string): string|null} [resolveProject] Optional function
319
- * to resolve a file path to its owning project name. Used for waiver entries
320
- * whose `source` is a file path, not a project name.
321
- * @returns {{evaluated: boolean, entries: object[], note: string|null}}
322
- * `evaluated: true` when debt data was available and filtering was attempted.
323
- * `evaluated: false` when debt was not provided.
324
- */
325
- function evaluateDebtImpact(affectedProjects, availableDebt = null, resolveProject = null) {
326
- if (!availableDebt) {
327
- return {
328
- evaluated: false,
329
- entries: [],
330
- note: "debt impact not evaluated — no debt data provided to impact statement",
331
- };
332
- }
333
-
334
- // Filter debt entries by affected projects.
335
- // Debt entries use `source` as either a project name (drift, unresolved) or
336
- // a file path (waiver, expired-waiver). For path-based sources, use the
337
- // resolveProject function when available.
338
- const affectedSet = new Set(affectedProjects);
339
- const entries = availableDebt.filter((d) => {
340
- // Drift and unresolved entries have source = project name directly
341
- if (d.kind === "drift" || d.kind === "unresolved") {
342
- return affectedSet.has(d.source ?? "");
343
- }
344
- // Waiver entries have source = file path; resolve via owning project
345
- if (d.kind === "waiver" || d.kind === "expired-waiver") {
346
- if (typeof resolveProject === "function") {
347
- const project = resolveProject(d.source ?? "");
348
- return project !== null && affectedSet.has(project);
349
- }
350
- // Without resolveProject, we cannot match path-based sources
351
- return false;
352
- }
353
- // Aspirational-gap entries have source = note text — no project match
354
- return false;
355
- });
356
-
357
- return {
358
- evaluated: true,
359
- entries,
360
- note:
361
- entries.length > 0
362
- ? `${entries.length} debt entry(ies) affect impacted projects`
363
- : "no debt entries affect impacted projects",
364
- };
365
- }
366
-
367
- /**
368
- * Evaluates boundary impact: which boundary tags/layers are crossed by the
369
- * affected edges.
370
- *
371
- * Unlike `evolutionAlignment.boundaries` which collects edge identities, this
372
- * evaluates whether the change crosses meaningful governance boundaries (e.g.
373
- * layer transitions, scope crossings).
374
- *
375
- * @param {object} graph The project graph.
376
- * @param {object[]} constraintImpact Per-dependent constraint analysis.
377
- * @param {string} targetProject The target of the impact analysis.
378
- * @returns {{boundaries: object[], evaluated: boolean}}
379
- */
380
- function evaluateBoundaryImpact(graph, constraintImpact, targetProject) {
381
- if (!constraintImpact || constraintImpact.length === 0) {
382
- return { boundaries: [], evaluated: false };
383
- }
384
-
385
- const targetNode = graph.nodes[targetProject];
386
- const targetTags = targetNode?.data?.tags ?? [];
387
- const boundaries = [];
388
-
389
- for (const entry of constraintImpact) {
390
- const sourceTags = graph.nodes[entry.project]?.data?.tags ?? [];
391
-
392
- for (const edge of entry.edges) {
393
- // Determine if this edge crosses a layer boundary
394
- const sourceLayer = sourceTags.find((t) => t.startsWith("layer:"));
395
- const targetLayer = targetTags.find((t) => t.startsWith("layer:"));
396
- const crossesLayer = sourceLayer && targetLayer && sourceLayer !== targetLayer;
397
-
398
- // Determine if this edge crosses a scope boundary
399
- const sourceScope = sourceTags.find((t) => t.startsWith("scope:"));
400
- const targetScope = targetTags.find((t) => t.startsWith("scope:"));
401
- const crossesScope = sourceScope && targetScope && sourceScope !== targetScope;
402
-
403
- // Determine if any constraint row governs this edge
404
- const violated = entry.violations?.length > 0;
405
- const governingRowCount = entry.constraintRows?.length ?? 0;
406
-
407
- boundaries.push({
408
- source: entry.project,
409
- target: edge.target,
410
- type: edge.type,
411
- crossesLayer,
412
- crossesScope,
413
- violated,
414
- governingConstraintRows: governingRowCount,
415
- });
416
- }
417
- }
418
-
419
- return { boundaries, evaluated: true };
420
- }
421
-
422
75
  /**
423
76
  * Composes the full Impact Statement for a project.
424
77
  *
@@ -439,45 +92,17 @@ export function composeImpactStatement(projectName, commandContext, config = nul
439
92
  const { root, graph } = commandContext;
440
93
  const { findings: availableFindings = null, debt: availableDebt = null } = options;
441
94
 
442
- // Step 1: Reverse reachability (existing primitive)
443
- const impact = computeImpact(projectName, graph);
444
-
445
- // Step 2: Edge and constraint impact (existing primitive)
446
- let constraintImpact = null;
447
- if (config && config.depConstraints) {
448
- constraintImpact = computeImpactConstraints(
449
- projectName,
450
- impact.dependents,
451
- graph.nodes,
452
- graph.dependencies,
453
- config.depConstraints,
454
- );
455
- }
456
-
457
- // Step 3: Decision impact (with evidence)
458
- let decisionImpact = null;
459
- if (constraintImpact) {
460
- decisionImpact = buildDecisionImpact(root, constraintImpact, config);
461
- }
462
-
463
- // Step 4: Evolution alignment
464
- const resolvedDecisions = decisionImpact ? decisionImpact.decisions.map((d) => d.id) : [];
465
- const evolutionAlignment = buildEvolutionAlignment(
95
+ // Delegate to the canonical evaluator — one semantic evaluator, many views
96
+ const evaluation = evaluateArchitectureState({
97
+ graph,
98
+ config,
466
99
  projectName,
467
- impact,
468
- constraintImpact,
469
- resolvedDecisions,
470
- );
471
-
472
- // Step 5: Boundary impact evaluation
473
- const boundaryImpact = evaluateBoundaryImpact(graph, constraintImpact, projectName);
474
-
475
- // Step 6: Findings and Debt impact evaluation
476
- const affectedProjects = [projectName, ...impact.dependents];
477
- const findingsImpact = evaluateFindingsImpact(affectedProjects, availableFindings);
478
- const debtImpact = evaluateDebtImpact(affectedProjects, availableDebt);
100
+ root,
101
+ findings: availableFindings,
102
+ debt: availableDebt,
103
+ });
479
104
 
480
- // Step 7: Assemble the statement with evidence and coverage notes
105
+ // Build impact-statement-specific notes
481
106
  const notes = [];
482
107
 
483
108
  if (config && config.depConstraints) {
@@ -488,14 +113,14 @@ export function composeImpactStatement(projectName, commandContext, config = nul
488
113
  );
489
114
  }
490
115
 
491
- if (!findingsImpact.evaluated) {
116
+ if (!evaluation.findingsImpact.evaluated) {
492
117
  notes.push(
493
118
  "finding impact not evaluated — no findings data provided to impact statement. " +
494
119
  "Pass findings data for complete governance evaluation.",
495
120
  );
496
121
  }
497
122
 
498
- if (!debtImpact.evaluated) {
123
+ if (!evaluation.debtImpact.evaluated) {
499
124
  notes.push(
500
125
  "debt impact not evaluated — no debt data provided to impact statement. " +
501
126
  "Pass debt data for complete governance evaluation.",
@@ -503,29 +128,26 @@ export function composeImpactStatement(projectName, commandContext, config = nul
503
128
  }
504
129
 
505
130
  const statement = {
506
- project: impact.project,
507
- impact: {
508
- direct: impact.direct,
509
- transitive: impact.transitive,
510
- dependents: impact.dependents,
511
- },
512
- evolutionAlignment,
513
- findingsImpact,
514
- debtImpact,
515
- boundaryImpact,
516
- complete: true,
131
+ project: evaluation.project,
132
+ impact: evaluation.impact,
133
+ evolutionAlignment: evaluation.evolutionAlignment,
134
+ findingsImpact: evaluation.findingsImpact,
135
+ debtImpact: evaluation.debtImpact,
136
+ boundaryImpact: evaluation.boundaryImpact,
137
+ completeness: evaluation.completeness,
138
+ complete: evaluation.completeness.overallComplete,
517
139
  notes,
518
140
  };
519
141
 
520
- if (constraintImpact) {
521
- statement.constraintImpact = constraintImpact;
142
+ if (evaluation.constraintImpact) {
143
+ statement.constraintImpact = evaluation.constraintImpact;
522
144
  }
523
145
 
524
- if (decisionImpact) {
525
- statement.decisionImpact = decisionImpact;
526
- if (decisionImpact.unresolvedDecisionRefs.length > 0) {
146
+ if (evaluation.decisionImpact) {
147
+ statement.decisionImpact = evaluation.decisionImpact;
148
+ if (evaluation.decisionImpact.unresolvedDecisionRefs.length > 0) {
527
149
  statement.notes.push(
528
- `unresolved decision references: ${decisionImpact.unresolvedDecisionRefs.join(", ")}`,
150
+ `unresolved decision references: ${evaluation.decisionImpact.unresolvedDecisionRefs.join(", ")}`,
529
151
  );
530
152
  }
531
153
  }
@@ -161,8 +161,11 @@ export function impactCommand(projectName, commandContext, config = null) {
161
161
  `under-represent the real architecture. Fix the unanalyzed files and re-run.`,
162
162
  );
163
163
  }
164
+ const blindSpots = commandContext.analysis.failures
165
+ .filter((f) => !isWholeFileFailure(f))
166
+ .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason }));
164
167
 
165
- const complete = true;
168
+ const complete = true; // whole-file failures already threw above
166
169
  const status = "ok";
167
170
  const exitCode = 0;
168
171
 
@@ -172,9 +175,7 @@ export function impactCommand(projectName, commandContext, config = null) {
172
175
  analyzedFiles: commandContext.analysis.analyzed,
173
176
  imports: commandContext.analysis.imports.length,
174
177
  notAnalyzed: [],
175
- blindSpots: commandContext.analysis.failures
176
- .filter((f) => !isWholeFileFailure(f))
177
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
178
+ blindSpots,
178
179
  notes: [
179
180
  "per-edge violations cover only depConstraints (3 of 15 violation types). " +
180
181
  "A dependent with no violations here may still violate npm-ban, circular-dependency, " +
@@ -82,6 +82,7 @@ import {
82
82
  hasAuthority,
83
83
  unresolvedDecisionRefRows,
84
84
  } from "../governance/adr-registry.mjs";
85
+ import { buildProvenanceGraph } from "../governance/provenance-graph.mjs";
85
86
 
86
87
  /**
87
88
  * Whether a row declares a governance origin (`origin.by`/`origin.tool`).
@@ -224,6 +225,10 @@ export function unresolvedDecisionRefNote(decisionRef) {
224
225
  * supersededBy: string[], bindings: string[],
225
226
  * attribution: {createdBy: object|null, lastChangedBy: object|null},
226
227
  * attested: boolean, note: string|null}[],
228
+ * provenanceGraph: {nodes: object[], edges: object[], claims: object[],
229
+ * causalChains: object[]},
230
+ * claims: {id: string, kind: string, verdict: string,
231
+ * evidence: {kind: string, detail: string}[]}[],
227
232
  * report: {text: string, json: string}}>}
228
233
  * @throws {Error} on a malformed intent, boundary config, or ADR registry —
229
234
  * exit 3, the loud refusal every command that reads them makes.
@@ -339,6 +344,24 @@ export async function provenanceCommand(commandContext, io = {}) {
339
344
  });
340
345
  }
341
346
 
347
+ // PR4 — provenance graph: compose nodes, edges, claims, and causal chains
348
+ // from the already-resolved repo, rows, and decision lifecycle.
349
+ const graphRows = governanceRows.map(({ kind, row }) => ({
350
+ kind,
351
+ attested: hasOrigin(row),
352
+ origin: hasOrigin(row) ? row.origin : null,
353
+ decisionRef: row?.decisionRef ?? undefined,
354
+ label: rowLabel(kind, row),
355
+ }));
356
+ const provenanceGraph = buildProvenanceGraph({
357
+ repo,
358
+ rows: graphRows,
359
+ records: adrContext.records,
360
+ byId: adrContext.byId,
361
+ knownFitness: declaredFitnessNames(loadedConfig),
362
+ decisionLifecycle,
363
+ });
364
+
342
365
  const establishment = repo !== null;
343
366
  const repoResult = establishment ? repo : { commit: null, remote: null, dirty: null };
344
367
  const rowsTotal = rowList.length;
@@ -358,6 +381,7 @@ export async function provenanceCommand(commandContext, io = {}) {
358
381
  decisionRefTotal: decisionRefRows.length,
359
382
  unresolvedDecisionRefs,
360
383
  decisionLifecycle,
384
+ provenanceGraph,
361
385
  });
362
386
 
363
387
  const context = {
@@ -397,14 +421,19 @@ export async function provenanceCommand(commandContext, io = {}) {
397
421
  unattested: unattested.map(({ kind, label, note }) => ({ kind, label, note })),
398
422
  unresolvedDecisionRefs,
399
423
  decisionLifecycle,
424
+ provenanceGraph: {
425
+ nodes: provenanceGraph.nodes,
426
+ edges: provenanceGraph.edges,
427
+ claims: provenanceGraph.claims,
428
+ causalChains: provenanceGraph.causalChains,
429
+ },
430
+ claims: provenanceGraph.claims,
400
431
  },
401
432
  });
402
433
 
403
434
  return {
404
435
  status: "ok",
405
436
  repo: { ...repoResult, established: establishment },
406
- // The four answer surfaces, also available readably (not only inside the
407
- // envelope) so `cli.mjs` can drive the text report from the same facts.
408
437
  rows: rowList.map(({ kind, attested, origin }) => ({
409
438
  kind,
410
439
  attested,
@@ -413,6 +442,8 @@ export async function provenanceCommand(commandContext, io = {}) {
413
442
  unattested: unattested.map(({ kind, label, note }) => ({ kind, label, note })),
414
443
  unresolvedDecisionRefs,
415
444
  decisionLifecycle,
445
+ provenanceGraph,
446
+ claims: provenanceGraph.claims,
416
447
  report: {
417
448
  text: reportText,
418
449
  json: renderJson(envelope),