@ecoma-io/archkeep 0.20.1 → 0.22.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/cli.mjs +156 -66
- package/package.json +1 -1
- package/src/analysis/contract.md +32 -5
- package/src/analysis/source-util.mjs +107 -0
- package/src/analysis/typescript.mjs +86 -5
- package/src/commands/change.mjs +59 -28
- package/src/commands/check.mjs +65 -26
- package/src/commands/completeness.mjs +708 -0
- package/src/commands/context-command.mjs +13 -5
- package/src/commands/context.mjs +31 -4
- package/src/commands/coverage-verdict.mjs +184 -0
- package/src/commands/debt.mjs +18 -15
- package/src/commands/delta-classify.mjs +13 -18
- package/src/commands/delta.mjs +95 -33
- package/src/commands/diff.mjs +31 -24
- package/src/commands/discover.mjs +30 -10
- package/src/commands/drift.mjs +21 -21
- package/src/commands/edge-constraints.mjs +47 -1
- package/src/commands/evaluation-primitives.mjs +691 -0
- package/src/commands/evolution.mjs +27 -10
- package/src/commands/explain.mjs +14 -13
- package/src/commands/fitness.mjs +20 -19
- package/src/commands/graph.mjs +14 -5
- package/src/commands/health.mjs +12 -5
- package/src/commands/history.mjs +29 -15
- package/src/commands/impact-statement.mjs +31 -409
- package/src/commands/impact.mjs +18 -18
- package/src/commands/plan-context-command.mjs +10 -5
- package/src/commands/provenance-command.mjs +33 -2
- package/src/commands/reconcile.mjs +14 -17
- package/src/commands/scenario-evaluation.mjs +363 -198
- package/src/commands/scenario.mjs +32 -21
- package/src/commands/waivers.mjs +36 -28
- package/src/governance/evolution-event.mjs +62 -9
- package/src/governance/provenance-graph.mjs +479 -0
- package/src/intent/intent-manifest.json +83 -39
- package/src/report/json.mjs +32 -5
- package/src/report/provenance-text.mjs +30 -7
- package/src/report/text.mjs +82 -12
- package/src/verdict.mjs +78 -36
- package/src/workspace.mjs +126 -2
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evaluation primitives shared between Impact Statement and Scenario Evaluation.
|
|
3
|
+
*
|
|
4
|
+
* Both the impact statement (`./impact-statement.mjs`) and scenario evaluation
|
|
5
|
+
* (`./scenario-evaluation.mjs`) compose deterministic evaluation primitives
|
|
6
|
+
* into a single statement about what a change touches. These are the shared
|
|
7
|
+
* primitives that live in one place so two callers cannot drift.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
import { readAdrContext } from "./adr.mjs";
|
|
12
|
+
import { edgeEvolutionIdentity } from "../governance/evolution-event.mjs";
|
|
13
|
+
import { hasAuthority, resolveDecisionRef, stripAdrPrefix } from "../governance/adr-registry.mjs";
|
|
14
|
+
import { isComboDepConstraint } from "../rules/tags.mjs";
|
|
15
|
+
import { computeDecisionProvenance } from "../governance/provenance-graph.mjs";
|
|
16
|
+
import { resolveFileAttribution } from "./provenance.mjs";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
buildCompleteness,
|
|
20
|
+
buildEvidenceComplete,
|
|
21
|
+
buildGovernanceCompleteness,
|
|
22
|
+
evaluationStatus,
|
|
23
|
+
EVALUATION_STATUS,
|
|
24
|
+
EVALUATION_CONTRACT_TYPES,
|
|
25
|
+
computeDomainCoverage,
|
|
26
|
+
REQUIRED_DOMAINS,
|
|
27
|
+
} from "./completeness.mjs";
|
|
28
|
+
import { computeImpact } from "./impact.mjs";
|
|
29
|
+
import { computeImpactConstraints } from "./edge-constraints.mjs";
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Decision resolution
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a decisionRef to its record details.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} ref The decision reference (e.g. `adr:0001` or `fitness:cyclic`).
|
|
38
|
+
* @param {Map<string, object>} byId ADR records by id.
|
|
39
|
+
* @param {Set<string>} knownFitness Known fitness function ids.
|
|
40
|
+
* @returns {{resolution: string, record?: object}}
|
|
41
|
+
*/
|
|
42
|
+
function resolveDecision(ref, byId, knownFitness) {
|
|
43
|
+
const resolution = resolveDecisionRef(byId, knownFitness, ref);
|
|
44
|
+
if (resolution === "adr") {
|
|
45
|
+
const record = byId.get(stripAdrPrefix(ref));
|
|
46
|
+
return { resolution, record };
|
|
47
|
+
}
|
|
48
|
+
if (resolution === "fitness") {
|
|
49
|
+
// A fitness ref resolves but has no ADR record entry — it's a
|
|
50
|
+
// rule/fitness id, not an ADR. We report the resolution but have
|
|
51
|
+
// no record details for it.
|
|
52
|
+
return { resolution };
|
|
53
|
+
}
|
|
54
|
+
return { resolution };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Decision Impact
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Builds the decision impact section: which recorded decisions bind the
|
|
63
|
+
* affected constraint rows.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} root Workspace root path.
|
|
66
|
+
* @param {object[]} constraintImpact Per-dependent constraint analysis.
|
|
67
|
+
* @param {object} config The loaded boundary config (with `depConstraints`).
|
|
68
|
+
* @returns {{decisions: object[], unresolvedDecisionRefs: string[]}|null}
|
|
69
|
+
* null when the ADR registry is unreadable.
|
|
70
|
+
*/
|
|
71
|
+
export function buildDecisionImpact(root, constraintImpact, config) {
|
|
72
|
+
// Collect unique decisionRefs ONLY from constraint rows that are actually
|
|
73
|
+
// AFFECTED by the change — rows that govern edges from impacted dependents.
|
|
74
|
+
// A decisionRef in the config is not enough: the decision must be causally
|
|
75
|
+
// bound to a governance entity the change touches.
|
|
76
|
+
const seenRefs = new Set();
|
|
77
|
+
const affectedRefs = [];
|
|
78
|
+
|
|
79
|
+
// Build evidence map: decisionRef -> { constraintRows, dependentProjects }
|
|
80
|
+
/** @type {Map<string, {constraintRows: number[], dependentProjects: string[]}>} */
|
|
81
|
+
const evidenceByRef = new Map();
|
|
82
|
+
|
|
83
|
+
if (constraintImpact && config && config.depConstraints) {
|
|
84
|
+
// Use identity matching: constraintImpact.constraintRows are the actual
|
|
85
|
+
// config row objects returned by findConstraintsFor — check by reference,
|
|
86
|
+
// not by string label, for exact causal binding.
|
|
87
|
+
for (const entry of constraintImpact) {
|
|
88
|
+
const activeRows = new Set(entry.constraintRows);
|
|
89
|
+
const sourceProject = entry.project;
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < config.depConstraints.length; i++) {
|
|
92
|
+
const row = config.depConstraints[i];
|
|
93
|
+
if (!row.decisionRef) continue;
|
|
94
|
+
if (!activeRows.has(row)) continue;
|
|
95
|
+
|
|
96
|
+
if (!evidenceByRef.has(row.decisionRef)) {
|
|
97
|
+
evidenceByRef.set(row.decisionRef, {
|
|
98
|
+
constraintRows: [],
|
|
99
|
+
dependentProjects: [],
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const evidence = evidenceByRef.get(row.decisionRef);
|
|
103
|
+
if (!evidence.constraintRows.includes(i)) {
|
|
104
|
+
evidence.constraintRows.push(i);
|
|
105
|
+
}
|
|
106
|
+
if (!evidence.dependentProjects.includes(sourceProject)) {
|
|
107
|
+
evidence.dependentProjects.push(sourceProject);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!seenRefs.has(row.decisionRef)) {
|
|
111
|
+
seenRefs.add(row.decisionRef);
|
|
112
|
+
affectedRefs.push(row.decisionRef);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (affectedRefs.length === 0) {
|
|
119
|
+
return { decisions: [], unresolvedDecisionRefs: [] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Try to read the ADR registry — if it fails, all refs are unresolved
|
|
123
|
+
let adrContext;
|
|
124
|
+
try {
|
|
125
|
+
adrContext = readAdrContext(root);
|
|
126
|
+
} catch {
|
|
127
|
+
return {
|
|
128
|
+
decisions: [],
|
|
129
|
+
unresolvedDecisionRefs: [...affectedRefs],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const { records, byId, knownFitness } = adrContext;
|
|
133
|
+
const decisionProvenance = computeDecisionProvenance(records, (file) =>
|
|
134
|
+
resolveFileAttribution(root, file),
|
|
135
|
+
);
|
|
136
|
+
const unresolvedDecisionRefs = [];
|
|
137
|
+
const decisions = [];
|
|
138
|
+
|
|
139
|
+
for (const ref of affectedRefs) {
|
|
140
|
+
const resolved = resolveDecision(ref, byId, knownFitness);
|
|
141
|
+
const evidence = evidenceByRef.get(ref);
|
|
142
|
+
|
|
143
|
+
if (resolved.resolution === "unknown") {
|
|
144
|
+
unresolvedDecisionRefs.push(ref);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (resolved.resolution === "fitness") {
|
|
149
|
+
// Fitness refs are not ADR records — report them as resolved
|
|
150
|
+
// but with no record-level details
|
|
151
|
+
decisions.push({
|
|
152
|
+
id: ref,
|
|
153
|
+
kind: "fitness",
|
|
154
|
+
resolution: "known",
|
|
155
|
+
evidence,
|
|
156
|
+
});
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
// ADR record
|
|
160
|
+
const record = resolved.record;
|
|
161
|
+
const prov = decisionProvenance.get(record.id) ?? { attested: false, attribution: null };
|
|
162
|
+
decisions.push({
|
|
163
|
+
id: record.id,
|
|
164
|
+
kind: "adr",
|
|
165
|
+
status: record.status,
|
|
166
|
+
hasAuthority: hasAuthority(record.status),
|
|
167
|
+
supersedes: record.supersedes.length > 0 ? record.supersedes : undefined,
|
|
168
|
+
supersededBy: (record.supersededBy ?? []).length > 0 ? record.supersededBy : undefined,
|
|
169
|
+
provenance: {
|
|
170
|
+
attested: prov.attested,
|
|
171
|
+
origin: prov.attribution,
|
|
172
|
+
},
|
|
173
|
+
evidence,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
decisions: decisions.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
|
|
179
|
+
unresolvedDecisionRefs: [...new Set(unresolvedDecisionRefs)].sort(),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Evaluation helpers (shared between Impact Statement and Scenario Evaluation)
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Evaluates findings impact: which findings affect the impacted projects.
|
|
188
|
+
*
|
|
189
|
+
* @param {string[]} affectedProjects The projects affected by the change.
|
|
190
|
+
* @param {object[]|null} availableFindings Pre-computed findings, or null when
|
|
191
|
+
* not available.
|
|
192
|
+
* @returns {{evaluated: boolean, findings: object[], count: number}}
|
|
193
|
+
*/
|
|
194
|
+
export function evaluateFindingsImpact(affectedProjects, availableFindings) {
|
|
195
|
+
if (!availableFindings || availableFindings.length === 0) {
|
|
196
|
+
return { evaluated: false, findings: [], count: 0 };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const entries = availableFindings.filter((f) =>
|
|
200
|
+
affectedProjects.includes(f.project ?? f.target ?? ""),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
evaluated: true,
|
|
205
|
+
findings: entries,
|
|
206
|
+
count: entries.length,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Evaluates debt impact: which debt entries affect the impacted projects.
|
|
212
|
+
*
|
|
213
|
+
* @param {string[]} affectedProjects The projects affected by the change.
|
|
214
|
+
* @param {object[]|null} availableDebt Pre-computed debt entries, or null when
|
|
215
|
+
* not available.
|
|
216
|
+
* @param {Function|null} resolveProject Optional function to resolve a debt
|
|
217
|
+
* entry's associated project.
|
|
218
|
+
* @returns {{evaluated: boolean, debt: object[], count: number}}
|
|
219
|
+
*/
|
|
220
|
+
export function evaluateDebtImpact(affectedProjects, availableDebt, resolveProject = null) {
|
|
221
|
+
if (!availableDebt || availableDebt.length === 0) {
|
|
222
|
+
return { evaluated: false, debt: [], count: 0 };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const entries = availableDebt.filter((d) => {
|
|
226
|
+
const project = resolveProject ? resolveProject(d) : (d.project ?? d.id ?? "");
|
|
227
|
+
return affectedProjects.includes(project);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
evaluated: true,
|
|
232
|
+
debt: entries,
|
|
233
|
+
count: entries.length,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Evaluates boundary impact: which boundary tags/layers are crossed by the
|
|
239
|
+
* affected edges.
|
|
240
|
+
*
|
|
241
|
+
* @param {object} graph The project graph.
|
|
242
|
+
* @param {object[]|null} constraintImpact Per-dependent constraint analysis.
|
|
243
|
+
* @param {string} targetProject The target of the impact analysis.
|
|
244
|
+
* @returns {{boundaries: object[], evaluated: boolean}}
|
|
245
|
+
*/
|
|
246
|
+
export function evaluateBoundaryImpact(graph, constraintImpact, targetProject) {
|
|
247
|
+
if (!constraintImpact || constraintImpact.length === 0) {
|
|
248
|
+
return { boundaries: [], evaluated: false };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const targetNode = graph.nodes[targetProject];
|
|
252
|
+
const targetTags = targetNode?.data?.tags ?? [];
|
|
253
|
+
const boundaries = [];
|
|
254
|
+
|
|
255
|
+
for (const entry of constraintImpact) {
|
|
256
|
+
const sourceTags = graph.nodes[entry.project]?.data?.tags ?? [];
|
|
257
|
+
|
|
258
|
+
for (const edge of entry.edges) {
|
|
259
|
+
// Determine if this edge crosses a layer boundary
|
|
260
|
+
const sourceLayer = sourceTags.find((t) => t.startsWith("layer:"));
|
|
261
|
+
const targetLayer = targetTags.find((t) => t.startsWith("layer:"));
|
|
262
|
+
const crossesLayer = sourceLayer && targetLayer && sourceLayer !== targetLayer;
|
|
263
|
+
|
|
264
|
+
// Determine if this edge crosses a scope boundary
|
|
265
|
+
const sourceScope = sourceTags.find((t) => t.startsWith("scope:"));
|
|
266
|
+
const targetScope = targetTags.find((t) => t.startsWith("scope:"));
|
|
267
|
+
const crossesScope = sourceScope && targetScope && sourceScope !== targetScope;
|
|
268
|
+
|
|
269
|
+
// Determine if any constraint row governs this edge
|
|
270
|
+
const violated = entry.violations?.length > 0;
|
|
271
|
+
const governingRowCount = entry.constraintRows?.length ?? 0;
|
|
272
|
+
|
|
273
|
+
boundaries.push({
|
|
274
|
+
source: entry.project,
|
|
275
|
+
target: edge.target,
|
|
276
|
+
type: edge.type,
|
|
277
|
+
crossesLayer,
|
|
278
|
+
crossesScope,
|
|
279
|
+
violated,
|
|
280
|
+
governingConstraintRows: governingRowCount,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return { boundaries, evaluated: true };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// Canonical Architecture Evaluation
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The provenance coverage of a decision set: the fraction of rows that
|
|
294
|
+
* resolved to a known record with authority.
|
|
295
|
+
*
|
|
296
|
+
* An ADR row counts when its record carries decision authority
|
|
297
|
+
* (`hasAuthority` — `accepted`/`active`); a fitness row counts when it
|
|
298
|
+
* resolved to an id the loaded policy binds (`resolution === "known"`).
|
|
299
|
+
* Refs that resolve to nothing are reported by `unresolvedDecisionRefs`
|
|
300
|
+
* and never become rows; a record without authority emits a row the gate
|
|
301
|
+
* fails on loudly rather than counting. One derivation, one home —
|
|
302
|
+
* `deriveEvidenceGates` and the scenario face both read it, so the two
|
|
303
|
+
* callers cannot disagree about what a covered decision is.
|
|
304
|
+
*
|
|
305
|
+
* @param {object[]|null|undefined} decisions Decision rows from
|
|
306
|
+
* `buildDecisionImpact`.
|
|
307
|
+
* @returns {number} A ratio in [0, 1]; 0 when no decision binds an
|
|
308
|
+
* affected row.
|
|
309
|
+
*/
|
|
310
|
+
export function decisionProvenanceCoverage(decisions) {
|
|
311
|
+
const rows = decisions ?? [];
|
|
312
|
+
if (rows.length === 0) return 0;
|
|
313
|
+
const covered = rows.filter((row) =>
|
|
314
|
+
row.kind === "adr" ? row.hasAuthority === true : row.resolution === "known",
|
|
315
|
+
).length;
|
|
316
|
+
return covered / rows.length;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Derives evidence gates from evaluation outputs for the canonical evaluator.
|
|
321
|
+
*
|
|
322
|
+
* Each gate is computed from actual evaluation data rather than being
|
|
323
|
+
* caller-supplied. Gates not applicable to canonical evaluation
|
|
324
|
+
* (mutationCoverage, surfaceParity, baseIdentityValid) are set to 0/false
|
|
325
|
+
* and excluded via EVALUATION_CONTRACT_TYPES.CANONICAL.
|
|
326
|
+
*
|
|
327
|
+
* @param {object} evaluation The full evaluation result.
|
|
328
|
+
* @param {object} evaluation.completeness The completeness result.
|
|
329
|
+
* @param {object} evaluation.impact The structural impact.
|
|
330
|
+
* @param {object|null} evaluation.constraintImpact Constraint impact.
|
|
331
|
+
* @param {object|null} evaluation.decisionImpact Decision impact.
|
|
332
|
+
* @param {object} evaluation.boundaryImpact Boundary impact.
|
|
333
|
+
* @param {object} evaluation.findingsImpact Findings impact.
|
|
334
|
+
* @param {object} evaluation.debtImpact Debt impact.
|
|
335
|
+
* @param {object} evaluation.evolutionAlignment Evolution alignment.
|
|
336
|
+
* @returns {object} Evidence gate values for buildEvidenceComplete.
|
|
337
|
+
*/
|
|
338
|
+
export function deriveEvidenceGates(evaluation) {
|
|
339
|
+
const { completeness, constraintImpact, decisionImpact } = evaluation;
|
|
340
|
+
|
|
341
|
+
// domainCoverage: ratio of evaluated required domains
|
|
342
|
+
/** @type {{ [domain: string]: string }} */
|
|
343
|
+
const domainStatuses = {};
|
|
344
|
+
for (const domain of REQUIRED_DOMAINS) {
|
|
345
|
+
const dom = completeness.domains[domain];
|
|
346
|
+
domainStatuses[domain] = dom ? dom.status : EVALUATION_STATUS.NOT_EVALUATED;
|
|
347
|
+
}
|
|
348
|
+
const dc = computeDomainCoverage(domainStatuses);
|
|
349
|
+
const domainCoverage = dc.coverage;
|
|
350
|
+
|
|
351
|
+
// claimEvidenceCoverage: structural always produces claims with evidence.
|
|
352
|
+
// Constraint/decision claims exist when config is present.
|
|
353
|
+
const structuralClaimEvidence = 1; // structural always produces evidence
|
|
354
|
+
const constraintClaimEvidence = constraintImpact !== null ? 1 : 0;
|
|
355
|
+
const decisionClaimEvidence = decisionImpact !== null ? 1 : 0;
|
|
356
|
+
const totalClaims = 3; // structural + constraint + decision
|
|
357
|
+
const evidencedClaims = structuralClaimEvidence + constraintClaimEvidence + decisionClaimEvidence;
|
|
358
|
+
const claimEvidenceCoverage = totalClaims > 0 ? evidencedClaims / totalClaims : 0;
|
|
359
|
+
|
|
360
|
+
// causalCoverage: constraint consequences with complete causal chains
|
|
361
|
+
// When constraint impact is present, all constraint edges are traced.
|
|
362
|
+
// When absent, causal coverage is 0 (no constraints to trace).
|
|
363
|
+
const causalCoverage = constraintImpact !== null ? 1 : 0;
|
|
364
|
+
|
|
365
|
+
// provenanceCoverage: the fraction of decision rows that resolved to a
|
|
366
|
+
// known record with authority — derived from facts the rows carry, never
|
|
367
|
+
// from a property the row builder never wrote.
|
|
368
|
+
const provenanceCoverage = decisionProvenanceCoverage(decisionImpact?.decisions);
|
|
369
|
+
|
|
370
|
+
// mutationCoverage: not applicable for canonical evaluation
|
|
371
|
+
// surfaceParity: not applicable for canonical evaluation
|
|
372
|
+
|
|
373
|
+
// hiddenGapCount: NOT_EVALUATED domains without a note
|
|
374
|
+
let hiddenGapCount = 0;
|
|
375
|
+
for (const [, domain] of Object.entries(completeness.domains)) {
|
|
376
|
+
if (domain.status === EVALUATION_STATUS.NOT_EVALUATED && !domain.note) {
|
|
377
|
+
hiddenGapCount++;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// falseCompleteCount: detect when domains pass but evidence gates fail
|
|
382
|
+
// This is computed by buildCompleteness from the evidenceComplete contract.
|
|
383
|
+
|
|
384
|
+
// baseIdentityValid: not applicable for canonical evaluation
|
|
385
|
+
|
|
386
|
+
// deterministic: canonical evaluator is deterministic by construction
|
|
387
|
+
const deterministic = true;
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
domainCoverage,
|
|
391
|
+
claimEvidenceCoverage,
|
|
392
|
+
causalCoverage,
|
|
393
|
+
provenanceCoverage,
|
|
394
|
+
mutationCoverage: 0,
|
|
395
|
+
surfaceParity: 0,
|
|
396
|
+
hiddenGapCount,
|
|
397
|
+
falseCompleteCount: 0,
|
|
398
|
+
baseIdentityValid: false,
|
|
399
|
+
deterministic,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Evaluates the complete architecture state for a target project.
|
|
404
|
+
*
|
|
405
|
+
* This is the canonical evaluation function that composes all evaluation
|
|
406
|
+
* dimensions (structural, constraint, boundary, decision, governance) into a
|
|
407
|
+
* single result.
|
|
408
|
+
*
|
|
409
|
+
* @param {object} params
|
|
410
|
+
* @param {object} params.graph The project graph.
|
|
411
|
+
* @param {object|null} params.config The loaded boundary config.
|
|
412
|
+
* @param {string} params.projectName The target project.
|
|
413
|
+
* @param {string} [params.root] The ADR root directory path. When null (default),
|
|
414
|
+
* decision impact cannot resolve decision refs and reports them as unresolved.
|
|
415
|
+
* @param {object[]|null} [params.findings] Pre-computed findings.
|
|
416
|
+
* @param {object[]|null} [params.debt] Pre-computed debt entries.
|
|
417
|
+
* @returns {object} The complete evaluation result with all domains and
|
|
418
|
+
* completeness.
|
|
419
|
+
*/
|
|
420
|
+
export function evaluateArchitectureState({
|
|
421
|
+
graph,
|
|
422
|
+
config,
|
|
423
|
+
projectName,
|
|
424
|
+
root = null,
|
|
425
|
+
findings = null,
|
|
426
|
+
debt = null,
|
|
427
|
+
}) {
|
|
428
|
+
// Step 1: Reverse reachability
|
|
429
|
+
const impact = computeImpact(projectName, graph);
|
|
430
|
+
|
|
431
|
+
// Step 2: Edge and constraint impact
|
|
432
|
+
let constraintImpact = null;
|
|
433
|
+
if (config && config.depConstraints) {
|
|
434
|
+
constraintImpact = computeImpactConstraints(
|
|
435
|
+
projectName,
|
|
436
|
+
impact.dependents,
|
|
437
|
+
graph.nodes,
|
|
438
|
+
graph.dependencies,
|
|
439
|
+
config.depConstraints,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Step 3: Decision impact
|
|
444
|
+
let decisionImpact = null;
|
|
445
|
+
if (constraintImpact) {
|
|
446
|
+
decisionImpact = buildDecisionImpact(root, constraintImpact, config);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Step 4: Evolution alignment
|
|
450
|
+
const resolvedDecisions = decisionImpact ? decisionImpact.decisions.map((d) => d.id) : [];
|
|
451
|
+
const evolutionAlignment = buildEvolutionAlignment(
|
|
452
|
+
projectName,
|
|
453
|
+
impact,
|
|
454
|
+
constraintImpact,
|
|
455
|
+
resolvedDecisions,
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
// Step 5: Boundary impact
|
|
459
|
+
const boundaryImpact = evaluateBoundaryImpact(graph, constraintImpact, projectName);
|
|
460
|
+
|
|
461
|
+
// Step 6: Findings and Debt impact
|
|
462
|
+
const affectedProjects = [projectName, ...impact.dependents];
|
|
463
|
+
const findingsImpact = evaluateFindingsImpact(affectedProjects, findings);
|
|
464
|
+
const debtImpact = evaluateDebtImpact(affectedProjects, debt);
|
|
465
|
+
|
|
466
|
+
// Step 7: Build completeness with all 8 domains (structural, constraint, boundary,
|
|
467
|
+
// decision, findings, debt, governance, evidence)
|
|
468
|
+
const hasConfig = config !== null;
|
|
469
|
+
|
|
470
|
+
const structuralStatus = evaluationStatus({ evaluated: true });
|
|
471
|
+
const constraintStatus = evaluationStatus({
|
|
472
|
+
evaluated: hasConfig && config.depConstraints !== undefined,
|
|
473
|
+
notEvaluated: !hasConfig || config.depConstraints === undefined,
|
|
474
|
+
});
|
|
475
|
+
const boundaryStatus = evaluationStatus({
|
|
476
|
+
evaluated: hasConfig,
|
|
477
|
+
notEvaluated: !hasConfig,
|
|
478
|
+
});
|
|
479
|
+
const decisionStatus = evaluationStatus({
|
|
480
|
+
evaluated: hasConfig,
|
|
481
|
+
notEvaluated: !hasConfig,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const governanceResult = buildGovernanceCompleteness({
|
|
485
|
+
findingsStatus: findingsImpact.evaluated
|
|
486
|
+
? EVALUATION_STATUS.EVALUATED
|
|
487
|
+
: EVALUATION_STATUS.NOT_EVALUATED,
|
|
488
|
+
debtStatus: debtImpact.evaluated
|
|
489
|
+
? EVALUATION_STATUS.EVALUATED
|
|
490
|
+
: EVALUATION_STATUS.NOT_EVALUATED,
|
|
491
|
+
findingsCount: findingsImpact.count,
|
|
492
|
+
debtCount: debtImpact.count,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Build evidence domain: evaluated when any evaluation produced evidence
|
|
496
|
+
// In the canonical evaluator, evidence is always produced (structural,
|
|
497
|
+
// constraint, decision all produce traceable output). The evidence domain
|
|
498
|
+
// tracks whether we can verify that claims have supporting evidence.
|
|
499
|
+
const evidenceEvaluated = true; // canonical evaluator always produces evidence
|
|
500
|
+
const evidenceStatus = evaluationStatus({ evaluated: evidenceEvaluated });
|
|
501
|
+
|
|
502
|
+
const completeness = buildCompleteness({
|
|
503
|
+
structural: {
|
|
504
|
+
status: structuralStatus,
|
|
505
|
+
evaluated: true,
|
|
506
|
+
partial: false,
|
|
507
|
+
notEvaluated: false,
|
|
508
|
+
unsupported: false,
|
|
509
|
+
refused: false,
|
|
510
|
+
note: "",
|
|
511
|
+
},
|
|
512
|
+
constraint: {
|
|
513
|
+
status: constraintStatus,
|
|
514
|
+
evaluated: hasConfig && config.depConstraints !== undefined,
|
|
515
|
+
partial: false,
|
|
516
|
+
notEvaluated: !hasConfig || config.depConstraints === undefined,
|
|
517
|
+
unsupported: false,
|
|
518
|
+
refused: false,
|
|
519
|
+
note: "",
|
|
520
|
+
},
|
|
521
|
+
boundary: {
|
|
522
|
+
status: boundaryStatus,
|
|
523
|
+
evaluated: hasConfig,
|
|
524
|
+
partial: false,
|
|
525
|
+
notEvaluated: !hasConfig,
|
|
526
|
+
unsupported: false,
|
|
527
|
+
refused: false,
|
|
528
|
+
note: "",
|
|
529
|
+
},
|
|
530
|
+
decision: {
|
|
531
|
+
status: decisionStatus,
|
|
532
|
+
evaluated: hasConfig,
|
|
533
|
+
partial: false,
|
|
534
|
+
notEvaluated: !hasConfig,
|
|
535
|
+
unsupported: false,
|
|
536
|
+
refused: false,
|
|
537
|
+
note: "",
|
|
538
|
+
},
|
|
539
|
+
findings: governanceResult.findings,
|
|
540
|
+
debt: governanceResult.debt,
|
|
541
|
+
governance: governanceResult.domain,
|
|
542
|
+
evidence: {
|
|
543
|
+
status: evidenceStatus,
|
|
544
|
+
evaluated: evidenceEvaluated,
|
|
545
|
+
partial: false,
|
|
546
|
+
notEvaluated: false,
|
|
547
|
+
unsupported: false,
|
|
548
|
+
refused: false,
|
|
549
|
+
note: "",
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// Derive evidence gates and build Evidence-Complete contract
|
|
554
|
+
const evaluationResult = {
|
|
555
|
+
completeness,
|
|
556
|
+
impact,
|
|
557
|
+
constraintImpact,
|
|
558
|
+
decisionImpact,
|
|
559
|
+
boundaryImpact,
|
|
560
|
+
findingsImpact,
|
|
561
|
+
debtImpact,
|
|
562
|
+
evolutionAlignment,
|
|
563
|
+
};
|
|
564
|
+
const evidenceGates = deriveEvidenceGates(evaluationResult);
|
|
565
|
+
const evidenceComplete = buildEvidenceComplete({
|
|
566
|
+
...evidenceGates,
|
|
567
|
+
contractType: EVALUATION_CONTRACT_TYPES.CANONICAL,
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// Rebuild completeness with evidenceComplete contract
|
|
571
|
+
const completenessWithEC = buildCompleteness({
|
|
572
|
+
structural: {
|
|
573
|
+
status: structuralStatus,
|
|
574
|
+
evaluated: true,
|
|
575
|
+
partial: false,
|
|
576
|
+
notEvaluated: false,
|
|
577
|
+
unsupported: false,
|
|
578
|
+
refused: false,
|
|
579
|
+
note: "",
|
|
580
|
+
},
|
|
581
|
+
constraint: {
|
|
582
|
+
status: constraintStatus,
|
|
583
|
+
evaluated: hasConfig && config.depConstraints !== undefined,
|
|
584
|
+
partial: false,
|
|
585
|
+
notEvaluated: !hasConfig || config.depConstraints === undefined,
|
|
586
|
+
unsupported: false,
|
|
587
|
+
refused: false,
|
|
588
|
+
note: "",
|
|
589
|
+
},
|
|
590
|
+
boundary: {
|
|
591
|
+
status: boundaryStatus,
|
|
592
|
+
evaluated: hasConfig,
|
|
593
|
+
partial: false,
|
|
594
|
+
notEvaluated: !hasConfig,
|
|
595
|
+
unsupported: false,
|
|
596
|
+
refused: false,
|
|
597
|
+
note: "",
|
|
598
|
+
},
|
|
599
|
+
decision: {
|
|
600
|
+
status: decisionStatus,
|
|
601
|
+
evaluated: hasConfig,
|
|
602
|
+
partial: false,
|
|
603
|
+
notEvaluated: !hasConfig,
|
|
604
|
+
unsupported: false,
|
|
605
|
+
refused: false,
|
|
606
|
+
note: "",
|
|
607
|
+
},
|
|
608
|
+
findings: governanceResult.findings,
|
|
609
|
+
debt: governanceResult.debt,
|
|
610
|
+
governance: governanceResult.domain,
|
|
611
|
+
evidence: {
|
|
612
|
+
status: evidenceStatus,
|
|
613
|
+
evaluated: evidenceEvaluated,
|
|
614
|
+
partial: false,
|
|
615
|
+
notEvaluated: false,
|
|
616
|
+
unsupported: false,
|
|
617
|
+
refused: false,
|
|
618
|
+
note: "",
|
|
619
|
+
},
|
|
620
|
+
evidenceComplete,
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
return {
|
|
624
|
+
project: projectName,
|
|
625
|
+
impact,
|
|
626
|
+
constraintImpact,
|
|
627
|
+
decisionImpact,
|
|
628
|
+
evolutionAlignment,
|
|
629
|
+
boundaryImpact,
|
|
630
|
+
findingsImpact,
|
|
631
|
+
debtImpact,
|
|
632
|
+
completeness: completenessWithEC,
|
|
633
|
+
affectedProjects,
|
|
634
|
+
evidenceComplete,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// ---------------------------------------------------------------------------
|
|
639
|
+
// Evolution Alignment
|
|
640
|
+
// ---------------------------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Builds the evolution alignment section: the `affected` shape matching
|
|
644
|
+
* `EvolutionEvent.affected` vocabulary.
|
|
645
|
+
*
|
|
646
|
+
* @param {string} projectName The target project.
|
|
647
|
+
* @param {{direct: string[], transitive: string[], dependents: string[]}} impact
|
|
648
|
+
* @param {object[]} [constraintImpact] Per-dependent constraint rows.
|
|
649
|
+
* @param {string[]} [resolvedDecisions] Decision IDs that bind affected rows.
|
|
650
|
+
* @returns {{projects: string[], boundaries: string[], constraints: string[],
|
|
651
|
+
* decisions: string[]}}
|
|
652
|
+
*/
|
|
653
|
+
export function buildEvolutionAlignment(projectName, impact, constraintImpact, resolvedDecisions) {
|
|
654
|
+
const affectedProjects = [projectName, ...impact.dependents];
|
|
655
|
+
const affectedConstraints = [];
|
|
656
|
+
const affectedBoundaries = [];
|
|
657
|
+
|
|
658
|
+
if (constraintImpact) {
|
|
659
|
+
for (const entry of constraintImpact) {
|
|
660
|
+
// Collect edge identities for each affected boundary
|
|
661
|
+
for (const edge of entry.edges) {
|
|
662
|
+
// The canonical spelling — imported, not restated, so this surface
|
|
663
|
+
// cannot drift from `EvolutionEvent.affected`'s vocabulary.
|
|
664
|
+
const edgeId = edgeEvolutionIdentity({
|
|
665
|
+
source: entry.project,
|
|
666
|
+
target: edge.target,
|
|
667
|
+
type: edge.type,
|
|
668
|
+
});
|
|
669
|
+
if (!affectedBoundaries.includes(edgeId)) {
|
|
670
|
+
affectedBoundaries.push(edgeId);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// Collect constraint row labels
|
|
674
|
+
for (const row of entry.constraintRows) {
|
|
675
|
+
const label = isComboDepConstraint(row)
|
|
676
|
+
? `allSourceTags:${row.allSourceTags.join(",")}`
|
|
677
|
+
: `sourceTag:${row.sourceTag}`;
|
|
678
|
+
if (!affectedConstraints.includes(label)) {
|
|
679
|
+
affectedConstraints.push(label);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return {
|
|
686
|
+
projects: [...new Set(affectedProjects)].sort(),
|
|
687
|
+
boundaries: affectedBoundaries.sort(),
|
|
688
|
+
constraints: affectedConstraints.sort(),
|
|
689
|
+
decisions: resolvedDecisions ? [...new Set(resolvedDecisions)].sort() : [],
|
|
690
|
+
};
|
|
691
|
+
}
|