@ecoma-io/archkeep 0.27.0 → 0.28.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 (40) hide show
  1. package/README.md +5 -3
  2. package/package.json +2 -1
  3. package/src/analysis/jvm/gradle.mjs +23 -2
  4. package/src/commands/README.md +7 -4
  5. package/src/commands/change.mjs +3 -2
  6. package/src/commands/delta-snapshot.mjs +17 -0
  7. package/src/commands/delta.mjs +4 -2
  8. package/src/commands/scenario-evaluation.mjs +97 -3
  9. package/src/corpus/goldens/adr.json +1 -1
  10. package/src/corpus/goldens/change.json +2 -2
  11. package/src/corpus/goldens/check.json +1 -1
  12. package/src/corpus/goldens/context.json +1 -1
  13. package/src/corpus/goldens/debt.json +1 -1
  14. package/src/corpus/goldens/decisions.json +1 -1
  15. package/src/corpus/goldens/delta.json +2 -2
  16. package/src/corpus/goldens/diff.json +2 -2
  17. package/src/corpus/goldens/discover.json +1 -1
  18. package/src/corpus/goldens/drift.json +1 -1
  19. package/src/corpus/goldens/evolution.json +1 -1
  20. package/src/corpus/goldens/explain.json +1 -1
  21. package/src/corpus/goldens/fitness.json +1 -1
  22. package/src/corpus/goldens/graph.json +1 -1
  23. package/src/corpus/goldens/health.json +1 -1
  24. package/src/corpus/goldens/history.json +1 -1
  25. package/src/corpus/goldens/impact.json +1 -1
  26. package/src/corpus/goldens/provenance.json +1 -1
  27. package/src/corpus/goldens/reconcile.json +1 -1
  28. package/src/corpus/goldens/report.json +1 -1
  29. package/src/corpus/goldens/scenario.json +1 -1
  30. package/src/corpus/goldens/trajectory.json +1 -1
  31. package/src/corpus/goldens/waivers.json +1 -1
  32. package/src/governance/provenance-graph.mjs +13 -9
  33. package/src/governance/provenance-record.mjs +22 -8
  34. package/src/governance/reconcile-score.mjs +59 -9
  35. package/src/graph/create-dependencies.mjs +39 -6
  36. package/src/intent/intent-manifest.json +3 -3
  37. package/src/providers/nx-static.mjs +24 -3
  38. package/src/providers/nx.mjs +98 -2
  39. package/src/report/text.mjs +1 -1
  40. package/src/fixtures/evolution-lifecycle/workspace.mjs +0 -248
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  **Architecture governance for polyglot repositories** — a deterministic
4
4
  authority that keeps the architecture your team declared aligned with the code
5
5
  your team keeps changing. Dependency graphs and module boundaries for Go, Rust,
6
- Python, TypeScript, JavaScript, Vue, Java, Kotlin and C#, with Nx and Moon as first-class
6
+ Python, TypeScript and JavaScript, Vue, Java, Kotlin and C#, with Nx and Moon as first-class
7
7
  integrations. Coding agents read the same verdicts, machine-readably, through
8
8
  the `arch-*` skills. The system boundary — what Archkeep is and what it is not —
9
9
  is owned by [architecture-authority.md](https://github.com/ecoma-io/archkeep/blob/main/docs/doctrine/architecture-authority.md).
@@ -166,8 +166,10 @@ a file it cannot edit ([overview.md](https://github.com/ecoma-io/archkeep/blob/m
166
166
  mismatch, undeclared imports) refuses the run — never a silent skip
167
167
  ([custom-rules.md](https://github.com/ecoma-io/archkeep/blob/main/docs/concepts/custom-rules.md),
168
168
  [writing one](https://github.com/ecoma-io/archkeep/blob/main/docs/usage/custom-rules.md)).
169
- - **Shipped policy packs** — Clean Architecture, hexagonal, layered modular
170
- monolith and DDD bounded contexts, as ready-made profile registries under
169
+ - **Shipped policy packs** — six shipped packs: Clean Architecture, hexagonal,
170
+ traditional layering, layered modular monolith, vertical slices and DDD
171
+ bounded contexts, as
172
+ ready-made profile registries under
171
173
  this package's `presets/` directory. Copy one into your workspace, or point
172
174
  the `profiles` option straight at it; either way it is enforced by the same
173
175
  path a registry you wrote yourself is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecoma-io/archkeep",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Architecture authority for human and agentic software development — deterministic, evidence-backed enforcement of declared architecture.",
5
5
  "keywords": [
6
6
  "architecture",
@@ -73,6 +73,7 @@
73
73
  "!src/custom-rules/wasm-fixture.mjs",
74
74
  "!src/report/envelope-shape.json",
75
75
  "!src/custom-rules/evidence-golden.json",
76
+ "!src/fixtures/",
76
77
  "presets/",
77
78
  "LICENSE",
78
79
  "README.md"
@@ -69,6 +69,12 @@
69
69
  * below THROWS on the same list (#364's posture, `../source-util.mjs`'s
70
70
  * `refuseUnreadTree`), so `nx affected` fails loudly instead of
71
71
  * under-selecting on it.
72
+ *
73
+ * A settings file that cannot be READ at all — one `readFile` throws on, an
74
+ * unreadable include file — is refused with a thrown Error naming the file
75
+ * path, not an empty include list (#847): a missing settings file is fine
76
+ * (a root may have no reactor), but a settings file whose read fails must
77
+ * not collapse the reactor to "no includes" silently.
72
78
  */
73
79
 
74
80
  import { normalizePath } from "../manifest-util.mjs";
@@ -380,12 +386,27 @@ function buildGradleModel(workspace) {
380
386
  for (const dir of candidateDirs) {
381
387
  for (const name of SETTINGS_FILENAMES) {
382
388
  const path = normalizePath(dir, name);
383
- if (readFile(path) !== null && readFile(path) !== undefined) settingsFiles.push(path);
389
+ try {
390
+ if (readFile(path) !== null && readFile(path) !== undefined) settingsFiles.push(path);
391
+ } catch (cause) {
392
+ throw new Error(
393
+ `Gradle settings file '${path}' could not be read: ${cause?.message ?? cause}`,
394
+ { cause },
395
+ );
396
+ }
384
397
  }
385
398
  }
386
399
  for (const settingsPath of settingsFiles) {
387
400
  const settingsDir = dirnameOf(settingsPath);
388
- const settingsText = readFile(settingsPath);
401
+ let settingsText;
402
+ try {
403
+ settingsText = readFile(settingsPath);
404
+ } catch (cause) {
405
+ throw new Error(
406
+ `Gradle settings file '${settingsPath}' could not be read: ${cause?.message ?? cause}`,
407
+ { cause },
408
+ );
409
+ }
389
410
  const settingsParsed = parseGradleSettings(settingsText ?? "");
390
411
  if (settingsParsed.reason !== undefined) {
391
412
  failures.push({
@@ -54,8 +54,10 @@ the resolution order.
54
54
  introduced/resolved/unchanged/unknown (`./delta-classify.mjs`), with
55
55
  unresolvable import sites carried as their own category, never counted as
56
56
  violations. Refuses an unreadable, malformed, foreign-schema, or
57
- incomplete-coverage baseline, a provider mismatch (stricter than `diff`'s
58
- note violation identity across two project models is not evidence),
57
+ incomplete-coverage baseline, a `command`-carrying report envelope (a graph
58
+ snapshot is `diff`'s input, not delta evidence), a provider mismatch
59
+ (stricter than `diff`'s note — violation identity across two project models
60
+ is not evidence),
59
61
  incomplete head coverage, and an Nx workspace with polyglot manifests but no
60
62
  plugin registration; a policy-fingerprint change is a loud coverage note, not
61
63
  a refusal. A verdict, not a description: a non-waived introduced violation is
@@ -84,8 +86,9 @@ the resolution order.
84
86
  pins); an unproven base identity or an undeterminable
85
87
  constraint is exit 3, and constraints are left unevaluated over a base the
86
88
  run cannot vouch for. Refuses a manifest that fails shape or reference
87
- validation, an unreadable/malformed/incomplete baseline, a provider
88
- mismatch, incomplete head coverage, and the unregistered-plugin graph.
89
+ validation, an unreadable/malformed/incomplete baseline, a `command`-carrying
90
+ report envelope in its place, a provider mismatch, incomplete head coverage,
91
+ and the unregistered-plugin graph.
89
92
 
90
93
  - **`impact`** (`./impact.mjs`'s `impactCommand`) — reverse reachability from
91
94
  the project graph: given a project name, lists every project that transitively
@@ -76,8 +76,9 @@
76
76
  * exit 3, a `coverage` block naming every file and site the run could not
77
77
  * judge — where a parser and `--output` can read it; the rest are throws →
78
78
  * exit 3 upstream: a manifest that fails shape or reference validation, an
79
- * unreadable/malformed/foreign-schema baseline, incomplete baseline coverage,
80
- * a provider mismatch, an unregistered-plugin graph over polyglot manifests,
79
+ * unreadable/malformed/foreign-schema baseline, a `command`-carrying report
80
+ * envelope in its place, incomplete baseline coverage, a provider mismatch,
81
+ * an unregistered-plugin graph over polyglot manifests,
81
82
  * and a run with no boundary law (constraints and the law fingerprint need
82
83
  * one).
83
84
  *
@@ -320,6 +320,10 @@ export function readEvidenceSnapshot(path, io = {}) {
320
320
  *
321
321
  * Refusals, each loud:
322
322
  * - unreadable/malformed JSON — named with the path and the parse error;
323
+ * - a `command` field — that marker belongs to a report envelope (the graph
324
+ * family), so the document is not delta evidence at all; the family is
325
+ * decided before the schemaVersion refusals, whose "newer version" advice
326
+ * would be false for a file this binary itself wrote;
323
327
  * - a `schemaVersion` that is not the integer this format uses — a FUTURE
324
328
  * version refuses too: a reader that half-understood a newer format would
325
329
  * classify over evidence it misread;
@@ -360,6 +364,19 @@ export function parseEvidenceSnapshot(text, path) {
360
364
  );
361
365
  }
362
366
 
367
+ // A `command` field marks a report envelope — the graph family's document,
368
+ // not delta evidence. The family decides before the schemaVersion refusals
369
+ // below: that number is the OTHER format's version, so its "newer version;
370
+ // upgrade" advice would be false for a file this binary itself wrote (#810).
371
+ if (typeof parsed.command === "string") {
372
+ const pointer = parsed.command === "graph" ? " For graph snapshots use 'diff <baseline>'." : "";
373
+ throw new Error(
374
+ `archkeep: the evidence snapshot '${path}' has a 'command' field — it is not a delta ` +
375
+ `evidence snapshot, it is a '${parsed.command}' envelope. delta requires an evidence ` +
376
+ `snapshot (from 'delta --capture').${pointer}`,
377
+ );
378
+ }
379
+
363
380
  const problems = [];
364
381
  if (!Number.isInteger(parsed.schemaVersion)) {
365
382
  problems.push(
@@ -29,8 +29,10 @@
29
29
  * status "no-verdict", exit 3, a `coverage` block naming every file and site
30
30
  * the run could not judge — where a parser and `--output` can read it; the
31
31
  * rest are throws, exit 3 upstream:
32
- * - a baseline that cannot be read, parsed, or holds a foreign schemaVersion
33
- * (`./delta-snapshot.mjs`'s loader owns those);
32
+ * - a baseline that cannot be read, parsed, or holds a foreign schemaVersion,
33
+ * or a report envelope — a document carrying a `command` field (a graph
34
+ * snapshot is `diff`'s input), refused as the wrong family before any
35
+ * schemaVersion reading (`./delta-snapshot.mjs`'s loader owns those);
34
36
  * - a provider mismatch between baseline and this run (`providerMismatch`) —
35
37
  * a THROW here where `diff` settles for a note, because violation IDENTITY
36
38
  * across two different project models is not trustworthy: the same tree
@@ -152,11 +152,13 @@ function cloneGraph(graph) {
152
152
  *
153
153
  * @param {object} graph The base graph to apply changes to.
154
154
  * @param {DependencyChange[]} changes The hypothetical changes.
155
- * @returns {{graph: object, applied: string[], refused: string[]}}
155
+ * @returns {{graph: object, applied: string[],
156
+ * mutations: {type: string, source: string, target: string}[], refused: string[]}}
156
157
  */
157
158
  function applyChanges(graph, changes) {
158
159
  const cloned = cloneGraph(graph);
159
160
  const applied = [];
161
+ const mutations = [];
160
162
  const refused = [];
161
163
 
162
164
  for (const change of changes) {
@@ -203,6 +205,7 @@ function applyChanges(graph, changes) {
203
205
  source: change.source,
204
206
  });
205
207
  applied.push(`added dependency: ${change.source} → ${change.target} (${change.edgeType})`);
208
+ mutations.push({ type: "dependency_added", source: change.source, target: change.target });
206
209
  }
207
210
 
208
211
  if (change.type === "dependency_removed") {
@@ -238,6 +241,7 @@ function applyChanges(graph, changes) {
238
241
  existing.splice(idx, 1);
239
242
  const typeLabel = change.edgeType ? ` (${change.edgeType})` : "";
240
243
  applied.push(`removed dependency: ${change.source} → ${change.target}${typeLabel}`);
244
+ mutations.push({ type: "dependency_removed", source: change.source, target: change.target });
241
245
  }
242
246
  }
243
247
  // Clean up empty dependency arrays
@@ -247,7 +251,7 @@ function applyChanges(graph, changes) {
247
251
  }
248
252
  }
249
253
 
250
- return { graph: cloned, applied, refused };
254
+ return { graph: cloned, applied, mutations, refused };
251
255
  }
252
256
 
253
257
  /**
@@ -410,6 +414,50 @@ function resolveBaseRevision(root, userBase) {
410
414
  };
411
415
  }
412
416
 
417
+ /**
418
+ * Merges constraint-impact rows for the named project's own changed edges
419
+ * into one side's rows (#809), preserving the row shape the primitive
420
+ * returns — no new fields, no second judgment.
421
+ *
422
+ * One row per project: when the side already carries a row for the project,
423
+ * the edge, constraint-row and violation lists union without duplicates
424
+ * (constraint rows match by identity — they are the config's own row
425
+ * objects; violations by value, since `judgeEdge` builds fresh objects);
426
+ * otherwise the row is appended. Appending keeps every pre-existing row
427
+ * byte-identical and the order deterministic, because it follows the
428
+ * input changes' order.
429
+ *
430
+ * @param {{project: string, edges: object[], constraintRows: object[],
431
+ * violations: object[]}[]} rows The side's constraint impact, mutated in place.
432
+ * @param {{project: string, edges: object[], constraintRows: object[],
433
+ * violations: object[]}[]} merged Rows computed for the changed edge.
434
+ */
435
+ function mergeConstraintImpact(rows, merged) {
436
+ for (const row of merged) {
437
+ const existing = rows.find((r) => r.project === row.project);
438
+ if (!existing) {
439
+ rows.push(row);
440
+ continue;
441
+ }
442
+ for (const edge of row.edges) {
443
+ if (!existing.edges.some((e) => e.target === edge.target && e.type === edge.type)) {
444
+ existing.edges.push(edge);
445
+ }
446
+ }
447
+ for (const constraintRow of row.constraintRows) {
448
+ if (!existing.constraintRows.includes(constraintRow)) {
449
+ existing.constraintRows.push(constraintRow);
450
+ }
451
+ }
452
+ for (const violation of row.violations) {
453
+ const identity = JSON.stringify(violation);
454
+ if (!existing.violations.some((v) => JSON.stringify(v) === identity)) {
455
+ existing.violations.push(violation);
456
+ }
457
+ }
458
+ }
459
+ }
460
+
413
461
  /**
414
462
  * Evaluates a scenario against the current workspace.
415
463
  *
@@ -452,7 +500,12 @@ export function evaluateScenario(
452
500
  }
453
501
 
454
502
  // Step 3: Apply scenario changes to the graph
455
- const { graph: scenarioGraph, applied, refused } = applyChanges(graph, scenarioInput.changes);
503
+ const {
504
+ graph: scenarioGraph,
505
+ applied,
506
+ mutations,
507
+ refused,
508
+ } = applyChanges(graph, scenarioInput.changes);
456
509
 
457
510
  // Step 4: Compute scenario impact
458
511
  const scenarioImpact = computeImpact(projectName, scenarioGraph);
@@ -468,6 +521,47 @@ export function evaluateScenario(
468
521
  );
469
522
  }
470
523
 
524
+ // Step 4b: the named project's own changed edges (#809).
525
+ //
526
+ // `computeImpactConstraints` judges edges INTO the target from its
527
+ // dependents, so a change whose SOURCE is the named project never enters
528
+ // that frame — an edge pointing out of the target is invisible to it, and
529
+ // the source-named run reported `unchanged` where the target-named run of
530
+ // the same change reported the violation. The rows for the changed edge
531
+ // are computed here by the same primitive — never a second judgment — and
532
+ // merged into the frame the edge actually belongs to: additions into the
533
+ // scenario side (the edge exists only there), removals into the current
534
+ // side (it exists only there).
535
+ if (config && config.depConstraints) {
536
+ for (const mutation of mutations) {
537
+ if (mutation.source !== projectName) continue;
538
+ if (mutation.type === "dependency_added") {
539
+ mergeConstraintImpact(
540
+ scenarioConstraintImpact,
541
+ computeImpactConstraints(
542
+ mutation.target,
543
+ [mutation.source],
544
+ scenarioGraph.nodes,
545
+ scenarioGraph.dependencies,
546
+ config.depConstraints,
547
+ ),
548
+ );
549
+ }
550
+ if (mutation.type === "dependency_removed") {
551
+ mergeConstraintImpact(
552
+ currentConstraintImpact,
553
+ computeImpactConstraints(
554
+ mutation.target,
555
+ [mutation.source],
556
+ graph.nodes,
557
+ graph.dependencies,
558
+ config.depConstraints,
559
+ ),
560
+ );
561
+ }
562
+ }
563
+ }
564
+
471
565
  // Step 5: Build decision impact for both sides
472
566
  const currentDecisionImpact = buildDecisionImpact(root, currentConstraintImpact, config);
473
567
  const scenarioDecisionImpact = buildDecisionImpact(root, scenarioConstraintImpact, config);
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "adr",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "change",
8
8
  "workspace": {
@@ -45,7 +45,7 @@
45
45
  "path": "<fixture-root>/.archkeep-delta.json",
46
46
  "tool": {
47
47
  "name": "@ecoma-io/archkeep",
48
- "version": "0.27.0"
48
+ "version": "0.28.0"
49
49
  },
50
50
  "provider": "native",
51
51
  "provenance": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "check",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "context",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "debt",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "decisions",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "delta",
8
8
  "workspace": {
@@ -31,7 +31,7 @@
31
31
  "path": "<fixture-root>/.archkeep-delta.json",
32
32
  "tool": {
33
33
  "name": "@ecoma-io/archkeep",
34
- "version": "0.27.0"
34
+ "version": "0.28.0"
35
35
  },
36
36
  "provider": "native",
37
37
  "provenance": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "diff",
8
8
  "workspace": {
@@ -33,7 +33,7 @@
33
33
  "path": "<fixture-root>/.archkeep-graph.json",
34
34
  "projects": 3,
35
35
  "edges": 2,
36
- "toolVersion": "0.27.0",
36
+ "toolVersion": "0.28.0",
37
37
  "provenance": {
38
38
  "commit": "1fd51709c377d99a6139891e1200291cf08aede9",
39
39
  "remote": null,
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "discover",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "drift",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "evolution",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "explain",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "fitness",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "graph",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "health",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "history",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "impact",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "provenance",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "reconcile",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "report",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "scenario",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "trajectory",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.28.0"
6
6
  },
7
7
  "command": "waivers",
8
8
  "workspace": {
@@ -5,9 +5,10 @@
5
5
  * to `{attested, attribution}`. When `fileAttribution` cannot answer (returns
6
6
  * null), every decision is unattested.
7
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).
8
+ * This is the shared helper the impact and scenario evaluation callers use, so
9
+ * decision provenance is computed identically everywhere — "via the same graph
10
+ * helper, never re-derived" (PR4). `buildProvenanceGraph` is not a caller: it
11
+ * reads attestation from its `decisionLifecycle` input directly (#882).
11
12
  *
12
13
  * @param {{id: string}[]} records ADR records
13
14
  * @param {(path: string) => {createdBy: object|null,
@@ -92,9 +93,6 @@ import { resolveDecisionRef, stripRuleFitnessPrefix, stripAdrPrefix } from "./ad
92
93
  * Decision record lookup map.
93
94
  * @property {Set<string>} knownFitness
94
95
  * 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
96
  * @property {{id: string, attested: boolean, attribution: object|null}[]}
99
97
  * decisionLifecycle
100
98
  * @typedef {object} ProvenanceGraphNode
@@ -172,7 +170,6 @@ export function buildProvenanceGraph({
172
170
  records = [],
173
171
  byId = new Map(),
174
172
  knownFitness = new Set(),
175
- fileAttribution: _fileAttribution = () => null,
176
173
  decisionLifecycle = [],
177
174
  }) {
178
175
  const nodes = [];
@@ -408,7 +405,12 @@ export function buildProvenanceGraph({
408
405
  for (const nextId of sortedArray(record.supersedes)) {
409
406
  if (!visited.has(nextId)) {
410
407
  queue.push(nextId);
411
- parentMap.set(nextId, currentId);
408
+ // First queued parent wins: when a second supersession path reaches
409
+ // a decision already queued, overwriting its parent would drop the
410
+ // first path's hop from the chain while `edges` keeps both relations.
411
+ if (!parentMap.has(nextId)) {
412
+ parentMap.set(nextId, currentId);
413
+ }
412
414
  }
413
415
  }
414
416
 
@@ -457,7 +459,9 @@ export function buildProvenanceGraph({
457
459
  causalChains.push({
458
460
  id: chainId,
459
461
  startNode: rowId,
460
- endNode: `decision:${chainNodes[chainNodes.length - 1]}`,
462
+ // `chainNodes` entries are node ids already (`decision:<id>`); wrapping
463
+ // them a second time emitted the dangling `decision:decision:<id>`.
464
+ endNode: chainNodes[chainNodes.length - 1],
461
465
  hops: chainEdges,
462
466
  });
463
467
  }