@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
|
@@ -39,12 +39,7 @@
|
|
|
39
39
|
*
|
|
40
40
|
* @module
|
|
41
41
|
*/
|
|
42
|
-
import {
|
|
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,
|
|
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,
|
|
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
|
-
//
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
//
|
|
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:
|
|
507
|
-
impact:
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
}
|
package/src/commands/impact.mjs
CHANGED
|
@@ -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
|
|
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),
|