@ecoma-io/archkeep 0.19.0 → 0.20.1
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecoma-io/archkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.1",
|
|
4
4
|
"description": "Architecture enforcement for polyglot repositories — dependency graphs and module boundaries for Go, Rust, Python, TypeScript, JavaScript, Vue, Java and Kotlin",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"architecture",
|
|
@@ -53,13 +53,25 @@ import { isComboDepConstraint } from "../rules/tags.mjs";
|
|
|
53
53
|
* @property {object[]} [constraintImpact] Per-dependent edge constraint
|
|
54
54
|
* analysis. Present only when a boundary config was provided.
|
|
55
55
|
* @property {{decisions: object[], unresolvedDecisionRefs: string[]}} [decisionImpact]
|
|
56
|
-
* Which recorded decisions bind the affected constraint rows.
|
|
57
|
-
*
|
|
56
|
+
* Which recorded decisions bind the affected constraint rows. Each decision
|
|
57
|
+
* carries an `evidence` field tracing the causal chain: which constraint
|
|
58
|
+
* row index and which dependent project triggered the binding.
|
|
59
|
+
* Present only when a boundary config with `depConstraints` was provided.
|
|
58
60
|
* @property {{projects: string[], boundaries: string[], constraints: string[],
|
|
59
61
|
* decisions: string[]}} [evolutionAlignment] The `affected` shape matching
|
|
60
62
|
* `EvolutionEvent.affected` vocabulary.
|
|
63
|
+
* @property {{evaluated: boolean, entries: object[], note: string|null}} findingsImpact
|
|
64
|
+
* Findings that affect the impacted projects. `evaluated: false` when no
|
|
65
|
+
* findings data was provided — reported as a gap, never as "no findings".
|
|
66
|
+
* @property {{evaluated: boolean, entries: object[], note: string|null}} debtImpact
|
|
67
|
+
* Debt entries that affect the impacted projects. `evaluated: false` when no
|
|
68
|
+
* debt data was provided — reported as a gap, never as "no debt".
|
|
69
|
+
* @property {{boundaries: object[], evaluated: boolean}} boundaryImpact
|
|
70
|
+
* Boundary-crossing analysis for each affected edge. `evaluated: false` when
|
|
71
|
+
* no constraint impact was available.
|
|
61
72
|
* @property {boolean} complete Whether the statement could be fully composed.
|
|
62
|
-
* @property {string[]} notes Caveats about statement completeness
|
|
73
|
+
* @property {string[]} notes Caveats about statement completeness and
|
|
74
|
+
* governance gaps.
|
|
63
75
|
*/
|
|
64
76
|
|
|
65
77
|
/**
|
|
@@ -103,17 +115,41 @@ function buildDecisionImpact(root, constraintImpact, config) {
|
|
|
103
115
|
const seenRefs = new Set();
|
|
104
116
|
const affectedRefs = [];
|
|
105
117
|
|
|
118
|
+
// Build evidence map: decisionRef -> { constraintRows, dependentProjects }
|
|
119
|
+
/** @type {Map<string, {constraintRows: number[], dependentProjects: string[]}>} */
|
|
120
|
+
const evidenceByRef = new Map();
|
|
121
|
+
|
|
106
122
|
if (constraintImpact && config && config.depConstraints) {
|
|
107
123
|
// Use identity matching: constraintImpact.constraintRows are the actual
|
|
108
124
|
// config row objects returned by findConstraintsFor — check by reference,
|
|
109
125
|
// not by string label, for exact causal binding.
|
|
110
|
-
const
|
|
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;
|
|
111
134
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
+
}
|
|
117
153
|
}
|
|
118
154
|
}
|
|
119
155
|
}
|
|
@@ -139,6 +175,7 @@ function buildDecisionImpact(root, constraintImpact, config) {
|
|
|
139
175
|
|
|
140
176
|
for (const ref of affectedRefs) {
|
|
141
177
|
const resolved = resolveDecision(ref, byId, knownFitness);
|
|
178
|
+
const evidence = evidenceByRef.get(ref);
|
|
142
179
|
|
|
143
180
|
if (resolved.resolution === "unknown") {
|
|
144
181
|
unresolvedDecisionRefs.push(ref);
|
|
@@ -152,6 +189,7 @@ function buildDecisionImpact(root, constraintImpact, config) {
|
|
|
152
189
|
id: ref,
|
|
153
190
|
kind: "fitness",
|
|
154
191
|
resolution: "known",
|
|
192
|
+
evidence,
|
|
155
193
|
});
|
|
156
194
|
continue;
|
|
157
195
|
}
|
|
@@ -165,6 +203,7 @@ function buildDecisionImpact(root, constraintImpact, config) {
|
|
|
165
203
|
hasAuthority: hasAuthority(record.status),
|
|
166
204
|
supersedes: record.supersedes.length > 0 ? record.supersedes : undefined,
|
|
167
205
|
supersededBy: (record.supersededBy ?? []).length > 0 ? record.supersededBy : undefined,
|
|
206
|
+
evidence,
|
|
168
207
|
});
|
|
169
208
|
}
|
|
170
209
|
|
|
@@ -219,6 +258,167 @@ function buildEvolutionAlignment(projectName, impact, constraintImpact, resolved
|
|
|
219
258
|
};
|
|
220
259
|
}
|
|
221
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
|
+
|
|
222
422
|
/**
|
|
223
423
|
* Composes the full Impact Statement for a project.
|
|
224
424
|
*
|
|
@@ -227,11 +427,17 @@ function buildEvolutionAlignment(projectName, impact, constraintImpact, resolved
|
|
|
227
427
|
* root, provider, etc.).
|
|
228
428
|
* @param {object|null} [config] The loaded boundary config. When provided,
|
|
229
429
|
* constraint and decision impact are computed.
|
|
430
|
+
* @param {object} [options] Optional data for governance integration.
|
|
431
|
+
* @param {object[]|null} [options.findings] Pre-computed findings from the
|
|
432
|
+
* check pipeline. When null, findings impact is reported as not evaluated.
|
|
433
|
+
* @param {object[]|null} [options.debt] Pre-computed debt entries from the
|
|
434
|
+
* debt ledger. When null, debt impact is reported as not evaluated.
|
|
230
435
|
* @returns {ImpactStatement}
|
|
231
436
|
* @throws {import("../errors.mjs").UsageError} When the project is not in the graph.
|
|
232
437
|
*/
|
|
233
|
-
export function composeImpactStatement(projectName, commandContext, config = null) {
|
|
438
|
+
export function composeImpactStatement(projectName, commandContext, config = null, options = {}) {
|
|
234
439
|
const { root, graph } = commandContext;
|
|
440
|
+
const { findings: availableFindings = null, debt: availableDebt = null } = options;
|
|
235
441
|
|
|
236
442
|
// Step 1: Reverse reachability (existing primitive)
|
|
237
443
|
const impact = computeImpact(projectName, graph);
|
|
@@ -248,7 +454,7 @@ export function composeImpactStatement(projectName, commandContext, config = nul
|
|
|
248
454
|
);
|
|
249
455
|
}
|
|
250
456
|
|
|
251
|
-
// Step 3: Decision impact
|
|
457
|
+
// Step 3: Decision impact (with evidence)
|
|
252
458
|
let decisionImpact = null;
|
|
253
459
|
if (constraintImpact) {
|
|
254
460
|
decisionImpact = buildDecisionImpact(root, constraintImpact, config);
|
|
@@ -263,7 +469,15 @@ export function composeImpactStatement(projectName, commandContext, config = nul
|
|
|
263
469
|
resolvedDecisions,
|
|
264
470
|
);
|
|
265
471
|
|
|
266
|
-
// Step 5:
|
|
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);
|
|
479
|
+
|
|
480
|
+
// Step 7: Assemble the statement with evidence and coverage notes
|
|
267
481
|
const notes = [];
|
|
268
482
|
|
|
269
483
|
if (config && config.depConstraints) {
|
|
@@ -274,10 +488,19 @@ export function composeImpactStatement(projectName, commandContext, config = nul
|
|
|
274
488
|
);
|
|
275
489
|
}
|
|
276
490
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
"
|
|
280
|
-
|
|
491
|
+
if (!findingsImpact.evaluated) {
|
|
492
|
+
notes.push(
|
|
493
|
+
"finding impact not evaluated — no findings data provided to impact statement. " +
|
|
494
|
+
"Pass findings data for complete governance evaluation.",
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (!debtImpact.evaluated) {
|
|
499
|
+
notes.push(
|
|
500
|
+
"debt impact not evaluated — no debt data provided to impact statement. " +
|
|
501
|
+
"Pass debt data for complete governance evaluation.",
|
|
502
|
+
);
|
|
503
|
+
}
|
|
281
504
|
|
|
282
505
|
const statement = {
|
|
283
506
|
project: impact.project,
|
|
@@ -287,6 +510,9 @@ export function composeImpactStatement(projectName, commandContext, config = nul
|
|
|
287
510
|
dependents: impact.dependents,
|
|
288
511
|
},
|
|
289
512
|
evolutionAlignment,
|
|
513
|
+
findingsImpact,
|
|
514
|
+
debtImpact,
|
|
515
|
+
boundaryImpact,
|
|
290
516
|
complete: true,
|
|
291
517
|
notes,
|
|
292
518
|
};
|
|
@@ -36,6 +36,7 @@ import { computeImpactConstraints } from "./edge-constraints.mjs";
|
|
|
36
36
|
import { readAdrContext } from "./adr.mjs";
|
|
37
37
|
import { hasAuthority, resolveDecisionRef, stripAdrPrefix } from "../governance/adr-registry.mjs";
|
|
38
38
|
import { isComboDepConstraint } from "../rules/tags.mjs";
|
|
39
|
+
import { execSync } from "node:child_process";
|
|
39
40
|
|
|
40
41
|
// ---------------------------------------------------------------------------
|
|
41
42
|
// Scenario types
|
|
@@ -57,26 +58,41 @@ export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "depende
|
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
60
|
* @typedef {object} ScenarioInput
|
|
60
|
-
* @property {string} [base] Optional git revision for attribution.
|
|
61
|
+
* @property {string} [base] Optional git revision for attribution. When not
|
|
62
|
+
* provided, resolved from `git rev-parse HEAD`.
|
|
61
63
|
* @property {DependencyChange[]} changes The hypothetical changes to evaluate.
|
|
62
64
|
*/
|
|
63
65
|
|
|
64
66
|
// ---------------------------------------------------------------------------
|
|
65
67
|
// Output types
|
|
66
68
|
// ---------------------------------------------------------------------------
|
|
67
|
-
|
|
68
69
|
/**
|
|
69
70
|
* @typedef {object} ScenarioEvaluation
|
|
70
71
|
* @property {boolean} virtual Always true — a scenario is never authoritative.
|
|
71
72
|
* @property {boolean} notAuthoritative Always true — mirrors `virtual`.
|
|
72
73
|
* @property {string} project The target project being evaluated.
|
|
73
74
|
* @property {object} base The base graph information.
|
|
75
|
+
* @property {string} base.revision The git revision or snapshot identity.
|
|
76
|
+
* @property {boolean} base.attributed Whether the base is a real, verifiable revision.
|
|
77
|
+
* @property {string} base.provenance How the base was determined (user-provided, auto-resolved, or unverifiable).
|
|
74
78
|
* @property {string[]} changes The change descriptions that were applied.
|
|
75
79
|
* @property {string[]|undefined} refused Changes that could not be applied, if any.
|
|
76
80
|
* @property {object} current The current impact for the target project.
|
|
77
81
|
* @property {object} scenario The would-be impact after applying the changes.
|
|
78
82
|
* @property {object} delta What would change.
|
|
79
|
-
* @property {
|
|
83
|
+
* @property {object} [evidenceChain] The provenance chain: base → changes → re-evaluated → delta.
|
|
84
|
+
* @property {string} evidenceChain.baseRevision The revision the scenario started from.
|
|
85
|
+
* @property {string[]} evidenceChain.appliedChanges The changes applied to the base.
|
|
86
|
+
* @property {string} evidenceChain.currentState The state before applying changes ("current").
|
|
87
|
+
* @property {string} evidenceChain.scenarioState The state after applying changes ("scenario").
|
|
88
|
+
* @property {object} evidenceChain.delta The computed differences.
|
|
89
|
+
* @property {object} [governanceImpact] Governance re-evaluation results.
|
|
90
|
+
* @property {boolean} governanceImpact.findingsReEvaluated Whether findings were re-evaluated.
|
|
91
|
+
* @property {boolean} governanceImpact.debtReEvaluated Whether debt was re-evaluated.
|
|
92
|
+
* @property {boolean} governanceImpact.governanceComplete Whether all governance data was provided.
|
|
93
|
+
* @property {number} governanceImpact.scenarioFindingsCount Number of findings in the scenario state.
|
|
94
|
+
* @property {number} governanceImpact.scenarioDebtCount Number of debt entries in the scenario state.
|
|
95
|
+
* @property {boolean} complete Whether the evaluation could be fully completed.
|
|
80
96
|
* @property {string[]} notes Caveats about the evaluation.
|
|
81
97
|
*/
|
|
82
98
|
|
|
@@ -311,6 +327,51 @@ function computeDelta(current, scenario) {
|
|
|
311
327
|
decisionsChanged,
|
|
312
328
|
};
|
|
313
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* Resolves the base revision for a scenario evaluation.
|
|
332
|
+
*
|
|
333
|
+
* When the user provides a `base` string, it is used as-is and marked as
|
|
334
|
+
* attributed. When no base is provided, we attempt to resolve from
|
|
335
|
+
* `git rev-parse HEAD`. If that fails, we report the gap rather than
|
|
336
|
+
* fabricating a revision.
|
|
337
|
+
*
|
|
338
|
+
* @param {string} root The workspace root.
|
|
339
|
+
* @param {string|undefined} userBase The user-provided base (optional).
|
|
340
|
+
* @returns {{revision: string, attributed: boolean, provenance: string}}
|
|
341
|
+
*/
|
|
342
|
+
function resolveBaseRevision(root, userBase) {
|
|
343
|
+
if (typeof userBase === "string" && userBase.length > 0) {
|
|
344
|
+
return {
|
|
345
|
+
revision: userBase,
|
|
346
|
+
attributed: true,
|
|
347
|
+
provenance: "user-provided",
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Attempt to resolve from git
|
|
352
|
+
try {
|
|
353
|
+
const revision = execSync("git rev-parse HEAD", {
|
|
354
|
+
cwd: root,
|
|
355
|
+
encoding: "utf8",
|
|
356
|
+
timeout: 5000,
|
|
357
|
+
}).trim();
|
|
358
|
+
if (revision && revision.length === 40) {
|
|
359
|
+
return {
|
|
360
|
+
revision,
|
|
361
|
+
attributed: true,
|
|
362
|
+
provenance: "auto-resolved: git rev-parse HEAD",
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
// Fall through to unverifiable
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
revision: "(unattributed workspace)",
|
|
371
|
+
attributed: false,
|
|
372
|
+
provenance: "unverifiable — git rev-parse HEAD failed or not a git repository",
|
|
373
|
+
};
|
|
374
|
+
}
|
|
314
375
|
|
|
315
376
|
/**
|
|
316
377
|
* Evaluates a scenario against the current workspace.
|
|
@@ -319,12 +380,27 @@ function computeDelta(current, scenario) {
|
|
|
319
380
|
* @param {object} commandContext The resolved command context.
|
|
320
381
|
* @param {ScenarioInput} scenarioInput The scenario description.
|
|
321
382
|
* @param {object|null} [config] The loaded boundary config.
|
|
383
|
+
* @param {object} [options] Optional data for governance integration.
|
|
384
|
+
* @param {object[]|null} [options.findings] Pre-computed findings for
|
|
385
|
+
* governance re-evaluation on the hypothetical graph.
|
|
386
|
+
* @param {object[]|null} [options.debt] Pre-computed debt entries for
|
|
387
|
+
* governance re-evaluation.
|
|
322
388
|
* @returns {ScenarioEvaluation}
|
|
323
389
|
*/
|
|
324
|
-
export function evaluateScenario(
|
|
390
|
+
export function evaluateScenario(
|
|
391
|
+
projectName,
|
|
392
|
+
commandContext,
|
|
393
|
+
scenarioInput,
|
|
394
|
+
config = null,
|
|
395
|
+
options = {},
|
|
396
|
+
) {
|
|
325
397
|
const { root, graph } = commandContext;
|
|
398
|
+
const { findings: availableFindings = null, debt: availableDebt = null } = options;
|
|
399
|
+
|
|
400
|
+
// Step 1: Resolve base revision (real attribution)
|
|
401
|
+
const base = resolveBaseRevision(root, scenarioInput.base);
|
|
326
402
|
|
|
327
|
-
// Step
|
|
403
|
+
// Step 2: Compute current impact
|
|
328
404
|
const currentImpact = computeImpact(projectName, graph);
|
|
329
405
|
|
|
330
406
|
let currentConstraintImpact = null;
|
|
@@ -338,10 +414,10 @@ export function evaluateScenario(projectName, commandContext, scenarioInput, con
|
|
|
338
414
|
);
|
|
339
415
|
}
|
|
340
416
|
|
|
341
|
-
// Step
|
|
417
|
+
// Step 3: Apply scenario changes to the graph
|
|
342
418
|
const { graph: scenarioGraph, applied, refused } = applyChanges(graph, scenarioInput.changes);
|
|
343
419
|
|
|
344
|
-
// Step
|
|
420
|
+
// Step 4: Compute scenario impact
|
|
345
421
|
const scenarioImpact = computeImpact(projectName, scenarioGraph);
|
|
346
422
|
|
|
347
423
|
let scenarioConstraintImpact = null;
|
|
@@ -355,7 +431,7 @@ export function evaluateScenario(projectName, commandContext, scenarioInput, con
|
|
|
355
431
|
);
|
|
356
432
|
}
|
|
357
433
|
|
|
358
|
-
// Step
|
|
434
|
+
// Step 5: Build decision impact for both sides
|
|
359
435
|
const currentDecisionImpact = buildScenarioDecisionImpact(root, currentConstraintImpact, config);
|
|
360
436
|
const scenarioDecisionImpact = buildScenarioDecisionImpact(
|
|
361
437
|
root,
|
|
@@ -363,7 +439,7 @@ export function evaluateScenario(projectName, commandContext, scenarioInput, con
|
|
|
363
439
|
config,
|
|
364
440
|
);
|
|
365
441
|
|
|
366
|
-
// Step
|
|
442
|
+
// Step 6: Build evolution alignment for both sides
|
|
367
443
|
const currentResolved = currentDecisionImpact
|
|
368
444
|
? currentDecisionImpact.decisions.map((d) => d.id)
|
|
369
445
|
: [];
|
|
@@ -384,7 +460,7 @@ export function evaluateScenario(projectName, commandContext, scenarioInput, con
|
|
|
384
460
|
scenarioResolved,
|
|
385
461
|
);
|
|
386
462
|
|
|
387
|
-
// Step
|
|
463
|
+
// Step 7: Compute delta
|
|
388
464
|
const currentState = {
|
|
389
465
|
impact: currentImpact,
|
|
390
466
|
constraintImpact: currentConstraintImpact,
|
|
@@ -397,7 +473,44 @@ export function evaluateScenario(projectName, commandContext, scenarioInput, con
|
|
|
397
473
|
};
|
|
398
474
|
const delta = computeDelta(currentState, scenarioState);
|
|
399
475
|
|
|
400
|
-
// Step
|
|
476
|
+
// Step 8: Build evidence chain
|
|
477
|
+
const evidenceChain = {
|
|
478
|
+
baseRevision: base.revision,
|
|
479
|
+
appliedChanges: applied,
|
|
480
|
+
currentState: "current",
|
|
481
|
+
scenarioState: "scenario",
|
|
482
|
+
delta: {
|
|
483
|
+
dependentsAdded: delta.dependentsAdded,
|
|
484
|
+
dependentsRemoved: delta.dependentsRemoved,
|
|
485
|
+
constraintsChanged: delta.constraintsChanged,
|
|
486
|
+
decisionsChanged: delta.decisionsChanged,
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
// Step 9: Evaluate governance impact on scenario (hypothetical re-evaluation)
|
|
491
|
+
const scenarioAffectedProjects = [projectName, ...scenarioImpact.dependents];
|
|
492
|
+
let scenarioFindings = null;
|
|
493
|
+
let scenarioDebt = null;
|
|
494
|
+
if (availableFindings) {
|
|
495
|
+
// Re-filter findings for the hypothetical graph's affected projects
|
|
496
|
+
const affectedSet = new Set(scenarioAffectedProjects);
|
|
497
|
+
scenarioFindings = availableFindings.filter((f) => {
|
|
498
|
+
const source = f.source ?? f.project ?? "";
|
|
499
|
+
const target = f.target ?? "";
|
|
500
|
+
return affectedSet.has(source) || affectedSet.has(target);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
if (availableDebt) {
|
|
504
|
+
const affectedSet = new Set(scenarioAffectedProjects);
|
|
505
|
+
scenarioDebt = availableDebt.filter((d) => {
|
|
506
|
+
if (d.kind === "drift" || d.kind === "unresolved") {
|
|
507
|
+
return affectedSet.has(d.source ?? "");
|
|
508
|
+
}
|
|
509
|
+
return false;
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Step 10: Build notes — coverage and completeness
|
|
401
514
|
const notes = [
|
|
402
515
|
"virtual evaluation — not authoritative",
|
|
403
516
|
"this scenario has not been committed; run `check` for the real verdict",
|
|
@@ -408,31 +521,36 @@ export function evaluateScenario(projectName, commandContext, scenarioInput, con
|
|
|
408
521
|
"A project with no violations here may still violate other rules.",
|
|
409
522
|
);
|
|
410
523
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
"
|
|
414
|
-
|
|
524
|
+
if (!availableFindings) {
|
|
525
|
+
notes.push(
|
|
526
|
+
"finding impact not re-evaluated — no findings data provided to scenario. " +
|
|
527
|
+
"Pass findings data for complete governance re-evaluation.",
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
if (!availableDebt) {
|
|
531
|
+
notes.push(
|
|
532
|
+
"debt impact not re-evaluated — no debt data provided to scenario. " +
|
|
533
|
+
"Pass debt data for complete governance re-evaluation.",
|
|
534
|
+
);
|
|
535
|
+
}
|
|
415
536
|
if (refused.length > 0) {
|
|
416
537
|
notes.push(`changes that could not be applied: ${refused.join("; ")}`);
|
|
417
538
|
}
|
|
418
|
-
// Step
|
|
419
|
-
//
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
// Complete means all changes were applied AND all required evaluations completed
|
|
539
|
+
// Step 11: Assemble — determine provenance and completeness semantics
|
|
540
|
+
// `complete` reflects whether the core evaluation (graph + constraints)
|
|
541
|
+
// completed. `governanceComplete` tracks whether governance dimensions
|
|
542
|
+
// were fully evaluated.
|
|
543
|
+
const governanceComplete = availableFindings !== null && availableDebt !== null;
|
|
424
544
|
const isComplete = refused.length === 0;
|
|
425
545
|
|
|
426
546
|
return {
|
|
427
547
|
virtual: true,
|
|
428
548
|
notAuthoritative: true,
|
|
429
549
|
project: projectName,
|
|
430
|
-
base
|
|
431
|
-
revision: baseRevision,
|
|
432
|
-
attributed: isAttributed,
|
|
433
|
-
},
|
|
550
|
+
base,
|
|
434
551
|
changes: applied,
|
|
435
552
|
refused: refused.length > 0 ? refused : undefined,
|
|
553
|
+
evidenceChain,
|
|
436
554
|
current: {
|
|
437
555
|
impact: {
|
|
438
556
|
project: currentImpact.project,
|
|
@@ -454,6 +572,15 @@ export function evaluateScenario(projectName, commandContext, scenarioInput, con
|
|
|
454
572
|
constraintImpact: scenarioConstraintImpact,
|
|
455
573
|
decisionImpact: scenarioDecisionImpact,
|
|
456
574
|
evolutionAlignment: scenarioEvolution,
|
|
575
|
+
...(scenarioFindings !== null ? { findings: scenarioFindings } : {}),
|
|
576
|
+
...(scenarioDebt !== null ? { debt: scenarioDebt } : {}),
|
|
577
|
+
},
|
|
578
|
+
governanceImpact: {
|
|
579
|
+
findingsReEvaluated: availableFindings !== null,
|
|
580
|
+
debtReEvaluated: availableDebt !== null,
|
|
581
|
+
governanceComplete,
|
|
582
|
+
scenarioFindingsCount: scenarioFindings?.length ?? 0,
|
|
583
|
+
scenarioDebtCount: scenarioDebt?.length ?? 0,
|
|
457
584
|
},
|
|
458
585
|
delta,
|
|
459
586
|
complete: isComplete,
|
|
@@ -87,6 +87,8 @@ export function scenarioCommand(projectName, scenarioJson, commandContext, confi
|
|
|
87
87
|
refused: scenario.refused,
|
|
88
88
|
current: scenario.current,
|
|
89
89
|
scenario: scenario.scenario,
|
|
90
|
+
governanceImpact: scenario.governanceImpact,
|
|
91
|
+
evidenceChain: scenario.evidenceChain,
|
|
90
92
|
delta: scenario.delta,
|
|
91
93
|
notes: scenario.notes,
|
|
92
94
|
};
|