@ecoma-io/archkeep 0.20.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,499 @@
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 { hasAuthority, resolveDecisionRef, stripAdrPrefix } from "../governance/adr-registry.mjs";
13
+ import { isComboDepConstraint } from "../rules/tags.mjs";
14
+ import { computeDecisionProvenance } from "../governance/provenance-graph.mjs";
15
+ import { resolveFileAttribution } from "./provenance.mjs";
16
+
17
+ import {
18
+ buildCompleteness,
19
+ buildGovernanceCompleteness,
20
+ evaluationStatus,
21
+ EVALUATION_STATUS,
22
+ } from "./completeness.mjs";
23
+ import { computeImpact } from "./impact.mjs";
24
+ import { computeImpactConstraints } from "./edge-constraints.mjs";
25
+ // ---------------------------------------------------------------------------
26
+ // Decision resolution
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Resolve a decisionRef to its record details.
31
+ *
32
+ * @param {string} ref The decision reference (e.g. `adr:0001` or `fitness:cyclic`).
33
+ * @param {Map<string, object>} byId ADR records by id.
34
+ * @param {Set<string>} knownFitness Known fitness function ids.
35
+ * @returns {{resolution: string, record?: object}}
36
+ */
37
+ function resolveDecision(ref, byId, knownFitness) {
38
+ const resolution = resolveDecisionRef(byId, knownFitness, ref);
39
+ if (resolution === "adr") {
40
+ const record = byId.get(stripAdrPrefix(ref));
41
+ return { resolution, record };
42
+ }
43
+ if (resolution === "fitness") {
44
+ // A fitness ref resolves but has no ADR record entry — it's a
45
+ // rule/fitness id, not an ADR. We report the resolution but have
46
+ // no record details for it.
47
+ return { resolution };
48
+ }
49
+ return { resolution };
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Decision Impact
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Builds the decision impact section: which recorded decisions bind the
58
+ * affected constraint rows.
59
+ *
60
+ * @param {string} root Workspace root path.
61
+ * @param {object[]} constraintImpact Per-dependent constraint analysis.
62
+ * @param {object} config The loaded boundary config (with `depConstraints`).
63
+ * @returns {{decisions: object[], unresolvedDecisionRefs: string[]}|null}
64
+ * null when the ADR registry is unreadable.
65
+ */
66
+ export function buildDecisionImpact(root, constraintImpact, config) {
67
+ // Collect unique decisionRefs ONLY from constraint rows that are actually
68
+ // AFFECTED by the change — rows that govern edges from impacted dependents.
69
+ // A decisionRef in the config is not enough: the decision must be causally
70
+ // bound to a governance entity the change touches.
71
+ const seenRefs = new Set();
72
+ const affectedRefs = [];
73
+
74
+ // Build evidence map: decisionRef -> { constraintRows, dependentProjects }
75
+ /** @type {Map<string, {constraintRows: number[], dependentProjects: string[]}>} */
76
+ const evidenceByRef = new Map();
77
+
78
+ if (constraintImpact && config && config.depConstraints) {
79
+ // Use identity matching: constraintImpact.constraintRows are the actual
80
+ // config row objects returned by findConstraintsFor — check by reference,
81
+ // not by string label, for exact causal binding.
82
+ for (const entry of constraintImpact) {
83
+ const activeRows = new Set(entry.constraintRows);
84
+ const sourceProject = entry.project;
85
+
86
+ for (let i = 0; i < config.depConstraints.length; i++) {
87
+ const row = config.depConstraints[i];
88
+ if (!row.decisionRef) continue;
89
+ if (!activeRows.has(row)) continue;
90
+
91
+ if (!evidenceByRef.has(row.decisionRef)) {
92
+ evidenceByRef.set(row.decisionRef, {
93
+ constraintRows: [],
94
+ dependentProjects: [],
95
+ });
96
+ }
97
+ const evidence = evidenceByRef.get(row.decisionRef);
98
+ if (!evidence.constraintRows.includes(i)) {
99
+ evidence.constraintRows.push(i);
100
+ }
101
+ if (!evidence.dependentProjects.includes(sourceProject)) {
102
+ evidence.dependentProjects.push(sourceProject);
103
+ }
104
+
105
+ if (!seenRefs.has(row.decisionRef)) {
106
+ seenRefs.add(row.decisionRef);
107
+ affectedRefs.push(row.decisionRef);
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ if (affectedRefs.length === 0) {
114
+ return { decisions: [], unresolvedDecisionRefs: [] };
115
+ }
116
+
117
+ // Try to read the ADR registry — if it fails, all refs are unresolved
118
+ let adrContext;
119
+ try {
120
+ adrContext = readAdrContext(root);
121
+ } catch {
122
+ return {
123
+ decisions: [],
124
+ unresolvedDecisionRefs: [...affectedRefs],
125
+ };
126
+ }
127
+ const { records, byId, knownFitness } = adrContext;
128
+ const decisionProvenance = computeDecisionProvenance(records, (file) =>
129
+ resolveFileAttribution(root, file),
130
+ );
131
+ const unresolvedDecisionRefs = [];
132
+ const decisions = [];
133
+
134
+ for (const ref of affectedRefs) {
135
+ const resolved = resolveDecision(ref, byId, knownFitness);
136
+ const evidence = evidenceByRef.get(ref);
137
+
138
+ if (resolved.resolution === "unknown") {
139
+ unresolvedDecisionRefs.push(ref);
140
+ continue;
141
+ }
142
+
143
+ if (resolved.resolution === "fitness") {
144
+ // Fitness refs are not ADR records — report them as resolved
145
+ // but with no record-level details
146
+ decisions.push({
147
+ id: ref,
148
+ kind: "fitness",
149
+ resolution: "known",
150
+ evidence,
151
+ });
152
+ continue;
153
+ }
154
+ // ADR record
155
+ const record = resolved.record;
156
+ const prov = decisionProvenance.get(record.id) ?? { attested: false, attribution: null };
157
+ decisions.push({
158
+ id: record.id,
159
+ kind: "adr",
160
+ status: record.status,
161
+ hasAuthority: hasAuthority(record.status),
162
+ supersedes: record.supersedes.length > 0 ? record.supersedes : undefined,
163
+ supersededBy: (record.supersededBy ?? []).length > 0 ? record.supersededBy : undefined,
164
+ provenance: {
165
+ attested: prov.attested,
166
+ origin: prov.attribution,
167
+ },
168
+ evidence,
169
+ });
170
+ }
171
+
172
+ return {
173
+ decisions: decisions.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
174
+ unresolvedDecisionRefs: [...new Set(unresolvedDecisionRefs)].sort(),
175
+ };
176
+ }
177
+ // ---------------------------------------------------------------------------
178
+ // Evaluation helpers (shared between Impact Statement and Scenario Evaluation)
179
+ // ---------------------------------------------------------------------------
180
+
181
+ /**
182
+ * Evaluates findings impact: which findings affect the impacted projects.
183
+ *
184
+ * @param {string[]} affectedProjects The projects affected by the change.
185
+ * @param {object[]|null} availableFindings Pre-computed findings, or null when
186
+ * not available.
187
+ * @returns {{evaluated: boolean, findings: object[], count: number}}
188
+ */
189
+ export function evaluateFindingsImpact(affectedProjects, availableFindings) {
190
+ if (!availableFindings || availableFindings.length === 0) {
191
+ return { evaluated: false, findings: [], count: 0 };
192
+ }
193
+
194
+ const entries = availableFindings.filter((f) =>
195
+ affectedProjects.includes(f.project ?? f.target ?? ""),
196
+ );
197
+
198
+ return {
199
+ evaluated: true,
200
+ findings: entries,
201
+ count: entries.length,
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Evaluates debt impact: which debt entries affect the impacted projects.
207
+ *
208
+ * @param {string[]} affectedProjects The projects affected by the change.
209
+ * @param {object[]|null} availableDebt Pre-computed debt entries, or null when
210
+ * not available.
211
+ * @param {Function|null} resolveProject Optional function to resolve a debt
212
+ * entry's associated project.
213
+ * @returns {{evaluated: boolean, debt: object[], count: number}}
214
+ */
215
+ export function evaluateDebtImpact(affectedProjects, availableDebt, resolveProject = null) {
216
+ if (!availableDebt || availableDebt.length === 0) {
217
+ return { evaluated: false, debt: [], count: 0 };
218
+ }
219
+
220
+ const entries = availableDebt.filter((d) => {
221
+ const project = resolveProject ? resolveProject(d) : (d.project ?? d.id ?? "");
222
+ return affectedProjects.includes(project);
223
+ });
224
+
225
+ return {
226
+ evaluated: true,
227
+ debt: entries,
228
+ count: entries.length,
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Evaluates boundary impact: which boundary tags/layers are crossed by the
234
+ * affected edges.
235
+ *
236
+ * @param {object} graph The project graph.
237
+ * @param {object[]|null} constraintImpact Per-dependent constraint analysis.
238
+ * @param {string} targetProject The target of the impact analysis.
239
+ * @returns {{boundaries: object[], evaluated: boolean}}
240
+ */
241
+ export function evaluateBoundaryImpact(graph, constraintImpact, targetProject) {
242
+ if (!constraintImpact || constraintImpact.length === 0) {
243
+ return { boundaries: [], evaluated: false };
244
+ }
245
+
246
+ const targetNode = graph.nodes[targetProject];
247
+ const targetTags = targetNode?.data?.tags ?? [];
248
+ const boundaries = [];
249
+
250
+ for (const entry of constraintImpact) {
251
+ const sourceTags = graph.nodes[entry.project]?.data?.tags ?? [];
252
+
253
+ for (const edge of entry.edges) {
254
+ // Determine if this edge crosses a layer boundary
255
+ const sourceLayer = sourceTags.find((t) => t.startsWith("layer:"));
256
+ const targetLayer = targetTags.find((t) => t.startsWith("layer:"));
257
+ const crossesLayer = sourceLayer && targetLayer && sourceLayer !== targetLayer;
258
+
259
+ // Determine if this edge crosses a scope boundary
260
+ const sourceScope = sourceTags.find((t) => t.startsWith("scope:"));
261
+ const targetScope = targetTags.find((t) => t.startsWith("scope:"));
262
+ const crossesScope = sourceScope && targetScope && sourceScope !== targetScope;
263
+
264
+ // Determine if any constraint row governs this edge
265
+ const violated = entry.violations?.length > 0;
266
+ const governingRowCount = entry.constraintRows?.length ?? 0;
267
+
268
+ boundaries.push({
269
+ source: entry.project,
270
+ target: edge.target,
271
+ type: edge.type,
272
+ crossesLayer,
273
+ crossesScope,
274
+ violated,
275
+ governingConstraintRows: governingRowCount,
276
+ });
277
+ }
278
+ }
279
+
280
+ return { boundaries, evaluated: true };
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Canonical Architecture Evaluation
285
+ // ---------------------------------------------------------------------------
286
+
287
+ /**
288
+ * Evaluates the complete architecture state for a target project.
289
+ *
290
+ * This is the canonical evaluation function that composes all evaluation
291
+ * dimensions (structural, constraint, boundary, decision, governance) into a
292
+ * single result.
293
+ *
294
+ * @param {object} params
295
+ * @param {object} params.graph The project graph.
296
+ * @param {object|null} params.config The loaded boundary config.
297
+ * @param {string} params.projectName The target project.
298
+ * @param {string} [params.root] The ADR root directory path. When null (default),
299
+ * decision impact cannot resolve decision refs and reports them as unresolved.
300
+ * @param {object[]|null} [params.findings] Pre-computed findings.
301
+ * @param {object[]|null} [params.debt] Pre-computed debt entries.
302
+ * @returns {object} The complete evaluation result with all domains and
303
+ * completeness.
304
+ */
305
+ export function evaluateArchitectureState({
306
+ graph,
307
+ config,
308
+ projectName,
309
+ root = null,
310
+ findings = null,
311
+ debt = null,
312
+ }) {
313
+ // Step 1: Reverse reachability
314
+ const impact = computeImpact(projectName, graph);
315
+
316
+ // Step 2: Edge and constraint impact
317
+ let constraintImpact = null;
318
+ if (config && config.depConstraints) {
319
+ constraintImpact = computeImpactConstraints(
320
+ projectName,
321
+ impact.dependents,
322
+ graph.nodes,
323
+ graph.dependencies,
324
+ config.depConstraints,
325
+ );
326
+ }
327
+
328
+ // Step 3: Decision impact
329
+ let decisionImpact = null;
330
+ if (constraintImpact) {
331
+ decisionImpact = buildDecisionImpact(root, constraintImpact, config);
332
+ }
333
+
334
+ // Step 4: Evolution alignment
335
+ const resolvedDecisions = decisionImpact ? decisionImpact.decisions.map((d) => d.id) : [];
336
+ const evolutionAlignment = buildEvolutionAlignment(
337
+ projectName,
338
+ impact,
339
+ constraintImpact,
340
+ resolvedDecisions,
341
+ );
342
+
343
+ // Step 5: Boundary impact
344
+ const boundaryImpact = evaluateBoundaryImpact(graph, constraintImpact, projectName);
345
+
346
+ // Step 6: Findings and Debt impact
347
+ const affectedProjects = [projectName, ...impact.dependents];
348
+ const findingsImpact = evaluateFindingsImpact(affectedProjects, findings);
349
+ const debtImpact = evaluateDebtImpact(affectedProjects, debt);
350
+
351
+ // Step 7: Build completeness with all 8 domains (structural, constraint, boundary,
352
+ // decision, findings, debt, governance, evidence)
353
+ const hasConfig = config !== null;
354
+
355
+ const structuralStatus = evaluationStatus({ evaluated: true });
356
+ const constraintStatus = evaluationStatus({
357
+ evaluated: hasConfig && config.depConstraints !== undefined,
358
+ notEvaluated: !hasConfig || config.depConstraints === undefined,
359
+ });
360
+ const boundaryStatus = evaluationStatus({
361
+ evaluated: hasConfig,
362
+ notEvaluated: !hasConfig,
363
+ });
364
+ const decisionStatus = evaluationStatus({
365
+ evaluated: hasConfig,
366
+ notEvaluated: !hasConfig,
367
+ });
368
+
369
+ const governanceResult = buildGovernanceCompleteness({
370
+ findingsStatus: findingsImpact.evaluated
371
+ ? EVALUATION_STATUS.EVALUATED
372
+ : EVALUATION_STATUS.NOT_EVALUATED,
373
+ debtStatus: debtImpact.evaluated
374
+ ? EVALUATION_STATUS.EVALUATED
375
+ : EVALUATION_STATUS.NOT_EVALUATED,
376
+ findingsCount: findingsImpact.count,
377
+ debtCount: debtImpact.count,
378
+ });
379
+
380
+ // Build evidence domain: evaluated when any evaluation produced evidence
381
+ // In the canonical evaluator, evidence is always produced (structural,
382
+ // constraint, decision all produce traceable output). The evidence domain
383
+ // tracks whether we can verify that claims have supporting evidence.
384
+ const evidenceEvaluated = true; // canonical evaluator always produces evidence
385
+ const evidenceStatus = evaluationStatus({ evaluated: evidenceEvaluated });
386
+
387
+ const completeness = buildCompleteness({
388
+ structural: {
389
+ status: structuralStatus,
390
+ evaluated: true,
391
+ partial: false,
392
+ notEvaluated: false,
393
+ unsupported: false,
394
+ refused: false,
395
+ note: "",
396
+ },
397
+ constraint: {
398
+ status: constraintStatus,
399
+ evaluated: hasConfig && config.depConstraints !== undefined,
400
+ partial: false,
401
+ notEvaluated: !hasConfig || config.depConstraints === undefined,
402
+ unsupported: false,
403
+ refused: false,
404
+ note: "",
405
+ },
406
+ boundary: {
407
+ status: boundaryStatus,
408
+ evaluated: hasConfig,
409
+ partial: false,
410
+ notEvaluated: !hasConfig,
411
+ unsupported: false,
412
+ refused: false,
413
+ note: "",
414
+ },
415
+ decision: {
416
+ status: decisionStatus,
417
+ evaluated: hasConfig,
418
+ partial: false,
419
+ notEvaluated: !hasConfig,
420
+ unsupported: false,
421
+ refused: false,
422
+ note: "",
423
+ },
424
+ findings: governanceResult.findings,
425
+ debt: governanceResult.debt,
426
+ governance: governanceResult.domain,
427
+ evidence: {
428
+ status: evidenceStatus,
429
+ evaluated: evidenceEvaluated,
430
+ partial: false,
431
+ notEvaluated: false,
432
+ unsupported: false,
433
+ refused: false,
434
+ note: "",
435
+ },
436
+ });
437
+
438
+ return {
439
+ project: projectName,
440
+ impact,
441
+ constraintImpact,
442
+ decisionImpact,
443
+ evolutionAlignment,
444
+ boundaryImpact,
445
+ findingsImpact,
446
+ debtImpact,
447
+ completeness,
448
+ affectedProjects,
449
+ };
450
+ }
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // Evolution Alignment
454
+ // ---------------------------------------------------------------------------
455
+
456
+ /**
457
+ * Builds the evolution alignment section: the `affected` shape matching
458
+ * `EvolutionEvent.affected` vocabulary.
459
+ *
460
+ * @param {string} projectName The target project.
461
+ * @param {{direct: string[], transitive: string[], dependents: string[]}} impact
462
+ * @param {object[]} [constraintImpact] Per-dependent constraint rows.
463
+ * @param {string[]} [resolvedDecisions] Decision IDs that bind affected rows.
464
+ * @returns {{projects: string[], boundaries: string[], constraints: string[],
465
+ * decisions: string[]}}
466
+ */
467
+ export function buildEvolutionAlignment(projectName, impact, constraintImpact, resolvedDecisions) {
468
+ const affectedProjects = [projectName, ...impact.dependents];
469
+ const affectedConstraints = [];
470
+ const affectedBoundaries = [];
471
+
472
+ if (constraintImpact) {
473
+ for (const entry of constraintImpact) {
474
+ // Collect edge identities for each affected boundary
475
+ for (const edge of entry.edges) {
476
+ const edgeId = `${entry.project}>${edge.target}:${edge.type}`;
477
+ if (!affectedBoundaries.includes(edgeId)) {
478
+ affectedBoundaries.push(edgeId);
479
+ }
480
+ }
481
+ // Collect constraint row labels
482
+ for (const row of entry.constraintRows) {
483
+ const label = isComboDepConstraint(row)
484
+ ? `allSourceTags:${row.allSourceTags.join(",")}`
485
+ : `sourceTag:${row.sourceTag}`;
486
+ if (!affectedConstraints.includes(label)) {
487
+ affectedConstraints.push(label);
488
+ }
489
+ }
490
+ }
491
+ }
492
+
493
+ return {
494
+ projects: [...new Set(affectedProjects)].sort(),
495
+ boundaries: affectedBoundaries.sort(),
496
+ constraints: affectedConstraints.sort(),
497
+ decisions: resolvedDecisions ? [...new Set(resolvedDecisions)].sort() : [],
498
+ };
499
+ }