@ecoma-io/archkeep 0.18.0 → 0.19.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.
@@ -72,6 +72,46 @@ export function buildObserved(commandContext) {
72
72
  return { projects, edges };
73
73
  }
74
74
 
75
+ /**
76
+ * Convert a discovery proposal into an `architecture-intent.json`-compatible
77
+ * object. The conversion preserves the proposal's structural intent:
78
+ *
79
+ * - **Components** (directory groupings of 2+ projects) → `boundaries` entries
80
+ * with `directory:` selectors.
81
+ * - **`noDependency` rules** → `forbidden` entries — cross-component
82
+ * dependencies that should not exist.
83
+ *
84
+ * Confidence markers and evidence are dropped: the user is expected to review
85
+ * the output before using it with `drift` or `reconcile`.
86
+ *
87
+ * @param {object} proposal The proposal from `evaluateDiscovery`.
88
+ * @returns {{version: string, boundaries: Array<{name: string, match: string[]}>, forbidden?: Array<{source: string, target: string}>}}
89
+ */
90
+ export function proposalToIntent(proposal) {
91
+ const boundaries = (proposal.components?.items ?? []).map((component) => ({
92
+ name: component.name,
93
+ match: [`directory:${component.commonDirectory}`],
94
+ }));
95
+
96
+ // `noDependency` rules map to `forbidden` intent rows. The rules array
97
+ // includes both `noDependency` and `boundary` kinds; only the former
98
+ // carries source/target project pairs.
99
+ const forbidden = (proposal.rules?.items ?? [])
100
+ .filter((rule) => rule.kind === "noDependency")
101
+ .map((rule) => ({
102
+ source: rule.source,
103
+ target: rule.target,
104
+ }));
105
+
106
+ return {
107
+ version: "1",
108
+ // Auto-generated header comment is not possible in strict JSON; the
109
+ // user is expected to review before using with drift/reconcile.
110
+ boundaries,
111
+ ...(forbidden.length > 0 ? { forbidden } : {}),
112
+ };
113
+ }
114
+
75
115
  /**
76
116
  * Runs the `discover` command: observes the workspace, optionally proposes the
77
117
  * candidate architecture over it, and returns the report.
@@ -97,6 +97,7 @@ import {
97
97
  resolveDecisionRef,
98
98
  stripAdrPrefix,
99
99
  } from "../governance/adr-registry.mjs";
100
+ import { isAbsolute, relative, resolve, sep } from "node:path";
100
101
 
101
102
  /**
102
103
  * Parses a `file:line:column` site string into its components.
@@ -354,6 +355,16 @@ export function explainCommand(site, commandContext, config, options = {}) {
354
355
 
355
356
  const parsed = parseSite(site);
356
357
 
358
+ // Normalize the site's sourceFile to a workspace-relative path so it
359
+ // matches the analysis record's sourceFile field (contract.md: workspace-relative).
360
+ // Handles: absolute paths, cwd-relative paths, and backslash separators.
361
+ const rawFile = parsed.sourceFile;
362
+ const normalizedFile = isAbsolute(rawFile)
363
+ ? relative(root, rawFile)
364
+ : relative(root, resolve(root, rawFile));
365
+ // Normalize backslash separators (Windows paths) to forward slashes.
366
+ parsed.sourceFile = sep === "\\" ? normalizedFile.replaceAll("\\", "/") : normalizedFile;
367
+
357
368
  const notAnalyzed = commandContext.analysis.failures
358
369
  .filter(isWholeFileFailure)
359
370
  .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
@@ -0,0 +1,308 @@
1
+ /**
2
+ * The Impact Statement: a composed, authoritative, deterministic enumeration of
3
+ * every governed entity a change to one project touches — projects, edges,
4
+ * constraints, and recorded Decisions — each tied to the reproducible evidence
5
+ * that supports the claim, and with every gap reported rather than hidden.
6
+ *
7
+ * This is a composition layer: it calls existing deterministic primitives
8
+ * (`computeImpact`, `computeImpactConstraints`, `readAdrContext`,
9
+ * `resolveDecisionRef`) and assembles their outputs into one statement. It
10
+ * does NOT invent evidence, run new analysis, or add a second authority.
11
+ *
12
+ * ## What it composes
13
+ *
14
+ * - **Reverse reachability** — `computeImpact`: direct and transitive
15
+ * dependents of the target project.
16
+ * - **Edge and boundary impact** — `computeImpactConstraints`: which
17
+ * constraint rows govern each dependent's edge and whether it currently
18
+ * violates them.
19
+ * - **Decision impact** — which recorded decisions bind the affected
20
+ * constraint rows, resolved through the ADR registry. A `decisionRef` that
21
+ * does not resolve is reported in `unresolvedDecisionRefs`, never silently
22
+ * dropped.
23
+ * - **Evolution alignment** — the `affected` shape matching
24
+ * `EvolutionEvent.affected` vocabulary: `projects`, `boundaries`,
25
+ * `constraints`, `decisions`.
26
+ *
27
+ * ## Determinism
28
+ *
29
+ * The statement is deterministic: two runs over an unchanged tree produce
30
+ * byte-identical output. Every claim traces to a reproducible evidence source
31
+ * (the graph, the constraint table, the ADR registry).
32
+ *
33
+ * ## Failure states
34
+ *
35
+ * - An unreadable ADR registry: all decision refs are reported as unresolved
36
+ * (listed in `unresolvedDecisionRefs`), never silently evaluated.
37
+ * - An unknown decision ref: reported in `unresolvedDecisionRefs`, never
38
+ * silently dropped.
39
+ *
40
+ * @module
41
+ */
42
+ import { readAdrContext } from "./adr.mjs";
43
+ import { computeImpactConstraints } from "./edge-constraints.mjs";
44
+ import { computeImpact } from "./impact.mjs";
45
+ import { hasAuthority, resolveDecisionRef, stripAdrPrefix } from "../governance/adr-registry.mjs";
46
+ import { isComboDepConstraint } from "../rules/tags.mjs";
47
+
48
+ /**
49
+ * @typedef {object} ImpactStatement
50
+ * @property {string} project The target project name.
51
+ * @property {{direct: string[], transitive: string[], dependents: string[]}} impact
52
+ * Reverse reachability: which projects depend on the target.
53
+ * @property {object[]} [constraintImpact] Per-dependent edge constraint
54
+ * analysis. Present only when a boundary config was provided.
55
+ * @property {{decisions: object[], unresolvedDecisionRefs: string[]}} [decisionImpact]
56
+ * Which recorded decisions bind the affected constraint rows. Present only
57
+ * when a boundary config with `depConstraints` was provided.
58
+ * @property {{projects: string[], boundaries: string[], constraints: string[],
59
+ * decisions: string[]}} [evolutionAlignment] The `affected` shape matching
60
+ * `EvolutionEvent.affected` vocabulary.
61
+ * @property {boolean} complete Whether the statement could be fully composed.
62
+ * @property {string[]} notes Caveats about statement completeness.
63
+ */
64
+
65
+ /**
66
+ * Resolve a decisionRef to its record details.
67
+ *
68
+ * @param {string} ref The decision reference (bare, `adr:`, or `rule:`/`fitness:`-prefixed).
69
+ * @param {Map<string, object>} byId The ADR registry index.
70
+ * @param {Set<string>} knownFitness Declared fitness names.
71
+ * @returns {{resolution: "adr"|"fitness"|"unknown", record?: object}}
72
+ */
73
+ function resolveDecision(ref, byId, knownFitness) {
74
+ const resolution = resolveDecisionRef(byId, knownFitness, ref);
75
+ if (resolution === "adr") {
76
+ const record = byId.get(stripAdrPrefix(ref));
77
+ return { resolution, record };
78
+ }
79
+ if (resolution === "fitness") {
80
+ // A fitness ref resolves but has no ADR record entry — it's a
81
+ // rule/fitness id, not an ADR. We report the resolution but have
82
+ // no record details for it.
83
+ return { resolution };
84
+ }
85
+ return { resolution };
86
+ }
87
+
88
+ /**
89
+ * Builds the decision impact section: which recorded decisions bind the
90
+ * affected constraint rows.
91
+ *
92
+ * @param {string} root Workspace root path.
93
+ * @param {object[]} constraintImpact Per-dependent constraint analysis.
94
+ * @param {object} config The loaded boundary config (with `depConstraints`).
95
+ * @returns {{decisions: object[], unresolvedDecisionRefs: string[]}|null}
96
+ * null when the ADR registry is unreadable.
97
+ */
98
+ function buildDecisionImpact(root, constraintImpact, config) {
99
+ // Collect unique decisionRefs ONLY from constraint rows that are actually
100
+ // AFFECTED by the change — rows that govern edges from impacted dependents.
101
+ // A decisionRef in the config is not enough: the decision must be causally
102
+ // bound to a governance entity the change touches.
103
+ const seenRefs = new Set();
104
+ const affectedRefs = [];
105
+
106
+ if (constraintImpact && config && config.depConstraints) {
107
+ // Use identity matching: constraintImpact.constraintRows are the actual
108
+ // config row objects returned by findConstraintsFor — check by reference,
109
+ // not by string label, for exact causal binding.
110
+ const activeRows = new Set(constraintImpact.flatMap((entry) => entry.constraintRows));
111
+
112
+ for (const row of config.depConstraints) {
113
+ if (!row.decisionRef) continue;
114
+ if (activeRows.has(row) && !seenRefs.has(row.decisionRef)) {
115
+ seenRefs.add(row.decisionRef);
116
+ affectedRefs.push(row.decisionRef);
117
+ }
118
+ }
119
+ }
120
+
121
+ if (affectedRefs.length === 0) {
122
+ return { decisions: [], unresolvedDecisionRefs: [] };
123
+ }
124
+
125
+ // Try to read the ADR registry — if it fails, all refs are unresolved
126
+ let adrContext;
127
+ try {
128
+ adrContext = readAdrContext(root);
129
+ } catch {
130
+ return {
131
+ decisions: [],
132
+ unresolvedDecisionRefs: [...affectedRefs],
133
+ };
134
+ }
135
+
136
+ const { byId, knownFitness } = adrContext;
137
+ const unresolvedDecisionRefs = [];
138
+ const decisions = [];
139
+
140
+ for (const ref of affectedRefs) {
141
+ const resolved = resolveDecision(ref, byId, knownFitness);
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
+ });
156
+ continue;
157
+ }
158
+
159
+ // ADR record
160
+ const record = resolved.record;
161
+ decisions.push({
162
+ id: record.id,
163
+ kind: "adr",
164
+ status: record.status,
165
+ hasAuthority: hasAuthority(record.status),
166
+ supersedes: record.supersedes.length > 0 ? record.supersedes : undefined,
167
+ supersededBy: (record.supersededBy ?? []).length > 0 ? record.supersededBy : undefined,
168
+ });
169
+ }
170
+
171
+ return {
172
+ decisions: decisions.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
173
+ unresolvedDecisionRefs: [...new Set(unresolvedDecisionRefs)].sort(),
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Builds the evolution alignment section: the `affected` shape matching
179
+ * `EvolutionEvent.affected` vocabulary.
180
+ *
181
+ *
182
+ * @param {{direct: string[], transitive: string[], dependents: string[]}} impact
183
+ * @param {object[]} [constraintImpact] Per-dependent constraint rows.
184
+ * @param {string[]} [resolvedDecisions] Decision IDs that bind affected rows.
185
+ * @returns {{projects: string[], boundaries: string[], constraints: string[],
186
+ * decisions: string[]}}
187
+ */
188
+ function buildEvolutionAlignment(projectName, impact, constraintImpact, resolvedDecisions) {
189
+ const affectedProjects = [projectName, ...impact.dependents];
190
+ const affectedConstraints = [];
191
+ const affectedBoundaries = [];
192
+
193
+ if (constraintImpact) {
194
+ for (const entry of constraintImpact) {
195
+ // Collect edge identities for each affected boundary
196
+ for (const edge of entry.edges) {
197
+ const edgeId = `${entry.project}>${edge.target}:${edge.type}`;
198
+ if (!affectedBoundaries.includes(edgeId)) {
199
+ affectedBoundaries.push(edgeId);
200
+ }
201
+ }
202
+ // Collect constraint row labels
203
+ for (const row of entry.constraintRows) {
204
+ const label = isComboDepConstraint(row)
205
+ ? `allSourceTags:${row.allSourceTags.join(",")}`
206
+ : `sourceTag:${row.sourceTag}`;
207
+ if (!affectedConstraints.includes(label)) {
208
+ affectedConstraints.push(label);
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ return {
215
+ projects: [...new Set(affectedProjects)].sort(),
216
+ boundaries: affectedBoundaries.sort(),
217
+ constraints: affectedConstraints.sort(),
218
+ decisions: resolvedDecisions ? [...new Set(resolvedDecisions)].sort() : [],
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Composes the full Impact Statement for a project.
224
+ *
225
+ * @param {string} projectName The target project.
226
+ * @param {object} commandContext The resolved command context (graph, analysis,
227
+ * root, provider, etc.).
228
+ * @param {object|null} [config] The loaded boundary config. When provided,
229
+ * constraint and decision impact are computed.
230
+ * @returns {ImpactStatement}
231
+ * @throws {import("../errors.mjs").UsageError} When the project is not in the graph.
232
+ */
233
+ export function composeImpactStatement(projectName, commandContext, config = null) {
234
+ const { root, graph } = commandContext;
235
+
236
+ // Step 1: Reverse reachability (existing primitive)
237
+ const impact = computeImpact(projectName, graph);
238
+
239
+ // Step 2: Edge and constraint impact (existing primitive)
240
+ let constraintImpact = null;
241
+ if (config && config.depConstraints) {
242
+ constraintImpact = computeImpactConstraints(
243
+ projectName,
244
+ impact.dependents,
245
+ graph.nodes,
246
+ graph.dependencies,
247
+ config.depConstraints,
248
+ );
249
+ }
250
+
251
+ // Step 3: Decision impact
252
+ let decisionImpact = null;
253
+ if (constraintImpact) {
254
+ decisionImpact = buildDecisionImpact(root, constraintImpact, config);
255
+ }
256
+
257
+ // Step 4: Evolution alignment
258
+ const resolvedDecisions = decisionImpact ? decisionImpact.decisions.map((d) => d.id) : [];
259
+ const evolutionAlignment = buildEvolutionAlignment(
260
+ projectName,
261
+ impact,
262
+ constraintImpact,
263
+ resolvedDecisions,
264
+ );
265
+
266
+ // Step 5: Assemble the statement with coverage notes
267
+ const notes = [];
268
+
269
+ if (config && config.depConstraints) {
270
+ notes.push(
271
+ "constraint impact covers only depConstraints (3 of 15 violation types). " +
272
+ "A project with no violations here may still violate other rules " +
273
+ "that require import-site details. Run `check` for the complete verdict.",
274
+ );
275
+ }
276
+
277
+ notes.push(
278
+ "finding and debt impact are not yet evaluated. " +
279
+ "The impact statement covers dependency structure and constraint violations only.",
280
+ );
281
+
282
+ const statement = {
283
+ project: impact.project,
284
+ impact: {
285
+ direct: impact.direct,
286
+ transitive: impact.transitive,
287
+ dependents: impact.dependents,
288
+ },
289
+ evolutionAlignment,
290
+ complete: true,
291
+ notes,
292
+ };
293
+
294
+ if (constraintImpact) {
295
+ statement.constraintImpact = constraintImpact;
296
+ }
297
+
298
+ if (decisionImpact) {
299
+ statement.decisionImpact = decisionImpact;
300
+ if (decisionImpact.unresolvedDecisionRefs.length > 0) {
301
+ statement.notes.push(
302
+ `unresolved decision references: ${decisionImpact.unresolvedDecisionRefs.join(", ")}`,
303
+ );
304
+ }
305
+ }
306
+
307
+ return statement;
308
+ }
@@ -41,6 +41,7 @@ import { computeImpactConstraints } from "./edge-constraints.mjs";
41
41
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
42
42
  import { formatImpactReport } from "../report/impact-text.mjs";
43
43
  import { resolveProvenance } from "./provenance.mjs";
44
+ import { composeImpactStatement } from "./impact-statement.mjs";
44
45
 
45
46
  /**
46
47
  * Computes the impact set: every project that transitively depends on
@@ -204,6 +205,14 @@ export function impactCommand(projectName, commandContext, config = null) {
204
205
  config.depConstraints,
205
206
  );
206
207
  }
208
+ result.impactStatement = composeImpactStatement(projectName, commandContext, config);
209
+
210
+ // Full impact statement: when a boundary config is available, compose the
211
+ // enriched statement that includes decision impact and evolution alignment
212
+ // in addition to the reverse reachability and constraint impact above.
213
+ if (config) {
214
+ result.impactStatement = composeImpactStatement(projectName, commandContext, config);
215
+ }
207
216
 
208
217
  const envelope = jsonEnvelope({
209
218
  command: "impact",
@@ -84,7 +84,9 @@ function loadCatalog(catalogPath, cwd) {
84
84
 
85
85
  if (!existsSync(resolvedPath)) {
86
86
  throw new Error(
87
- `catalog not found at ${catalogPath} — install @ecoma-io/archkeep-rules or use --catalog to point to a catalog.json file`,
87
+ `catalog not found at ${catalogPath} — install @ecoma-io/archkeep-rules ` +
88
+ `(\`npm install -D @ecoma-io/archkeep-rules\`) or use --catalog to point to a ` +
89
+ `catalog.json file`,
88
90
  );
89
91
  }
90
92