@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.
- package/README.md +5 -3
- package/package.json +2 -1
- package/src/analysis/jvm/gradle.mjs +23 -2
- package/src/commands/README.md +7 -4
- package/src/commands/change.mjs +3 -2
- package/src/commands/delta-snapshot.mjs +17 -0
- package/src/commands/delta.mjs +4 -2
- package/src/commands/scenario-evaluation.mjs +97 -3
- package/src/corpus/goldens/adr.json +1 -1
- package/src/corpus/goldens/change.json +2 -2
- package/src/corpus/goldens/check.json +1 -1
- package/src/corpus/goldens/context.json +1 -1
- package/src/corpus/goldens/debt.json +1 -1
- package/src/corpus/goldens/decisions.json +1 -1
- package/src/corpus/goldens/delta.json +2 -2
- package/src/corpus/goldens/diff.json +2 -2
- package/src/corpus/goldens/discover.json +1 -1
- package/src/corpus/goldens/drift.json +1 -1
- package/src/corpus/goldens/evolution.json +1 -1
- package/src/corpus/goldens/explain.json +1 -1
- package/src/corpus/goldens/fitness.json +1 -1
- package/src/corpus/goldens/graph.json +1 -1
- package/src/corpus/goldens/health.json +1 -1
- package/src/corpus/goldens/history.json +1 -1
- package/src/corpus/goldens/impact.json +1 -1
- package/src/corpus/goldens/provenance.json +1 -1
- package/src/corpus/goldens/reconcile.json +1 -1
- package/src/corpus/goldens/report.json +1 -1
- package/src/corpus/goldens/scenario.json +1 -1
- package/src/corpus/goldens/trajectory.json +1 -1
- package/src/corpus/goldens/waivers.json +1 -1
- package/src/governance/provenance-graph.mjs +13 -9
- package/src/governance/provenance-record.mjs +22 -8
- package/src/governance/reconcile-score.mjs +59 -9
- package/src/graph/create-dependencies.mjs +39 -6
- package/src/intent/intent-manifest.json +3 -3
- package/src/providers/nx-static.mjs +24 -3
- package/src/providers/nx.mjs +98 -2
- package/src/report/text.mjs +1 -1
- 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
|
|
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,
|
|
170
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
package/src/commands/README.md
CHANGED
|
@@ -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
|
|
58
|
-
|
|
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
|
|
88
|
-
mismatch, incomplete head coverage,
|
|
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
|
package/src/commands/change.mjs
CHANGED
|
@@ -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,
|
|
80
|
-
*
|
|
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(
|
package/src/commands/delta.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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[],
|
|
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 {
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
36
|
+
"toolVersion": "0.28.0",
|
|
37
37
|
"provenance": {
|
|
38
38
|
"commit": "1fd51709c377d99a6139891e1200291cf08aede9",
|
|
39
39
|
"remote": null,
|
|
@@ -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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|