@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.
Files changed (41) hide show
  1. package/cli.mjs +156 -66
  2. package/package.json +1 -1
  3. package/src/analysis/contract.md +32 -5
  4. package/src/analysis/source-util.mjs +107 -0
  5. package/src/analysis/typescript.mjs +86 -5
  6. package/src/commands/change.mjs +59 -28
  7. package/src/commands/check.mjs +65 -26
  8. package/src/commands/completeness.mjs +708 -0
  9. package/src/commands/context-command.mjs +13 -5
  10. package/src/commands/context.mjs +31 -4
  11. package/src/commands/coverage-verdict.mjs +184 -0
  12. package/src/commands/debt.mjs +18 -15
  13. package/src/commands/delta-classify.mjs +13 -18
  14. package/src/commands/delta.mjs +95 -33
  15. package/src/commands/diff.mjs +31 -24
  16. package/src/commands/discover.mjs +30 -10
  17. package/src/commands/drift.mjs +21 -21
  18. package/src/commands/edge-constraints.mjs +47 -1
  19. package/src/commands/evaluation-primitives.mjs +691 -0
  20. package/src/commands/evolution.mjs +27 -10
  21. package/src/commands/explain.mjs +14 -13
  22. package/src/commands/fitness.mjs +20 -19
  23. package/src/commands/graph.mjs +14 -5
  24. package/src/commands/health.mjs +12 -5
  25. package/src/commands/history.mjs +29 -15
  26. package/src/commands/impact-statement.mjs +31 -409
  27. package/src/commands/impact.mjs +18 -18
  28. package/src/commands/plan-context-command.mjs +10 -5
  29. package/src/commands/provenance-command.mjs +33 -2
  30. package/src/commands/reconcile.mjs +14 -17
  31. package/src/commands/scenario-evaluation.mjs +363 -198
  32. package/src/commands/scenario.mjs +32 -21
  33. package/src/commands/waivers.mjs +36 -28
  34. package/src/governance/evolution-event.mjs +62 -9
  35. package/src/governance/provenance-graph.mjs +479 -0
  36. package/src/intent/intent-manifest.json +83 -39
  37. package/src/report/json.mjs +32 -5
  38. package/src/report/provenance-text.mjs +30 -7
  39. package/src/report/text.mjs +82 -12
  40. package/src/verdict.mjs +78 -36
  41. 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
+ }