@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
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { resolveProvenance } from "./provenance.mjs";
13
13
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
14
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
14
+ import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
15
15
  import { evaluateScenario, parseScenarioInput } from "./scenario-evaluation.mjs";
16
16
  export { parseScenarioInput } from "./scenario-evaluation.mjs";
17
17
 
@@ -23,7 +23,10 @@ export { parseScenarioInput } from "./scenario-evaluation.mjs";
23
23
  * @param {string} scenarioJson The scenario description as JSON.
24
24
  * @param {object} commandContext From `resolveCommandContext`.
25
25
  * @param {object} [config] The loaded boundary config.
26
- * @returns {{status: string, scenario: object, coverage: object, report: {text: string, json: string}}}
26
+ * @returns {{status: "ok"|"no-verdict", scenario?: object, coverage: object,
27
+ * report: {text: string, json: string}}} `scenario` is absent under
28
+ * `status: "no-verdict"` — the coverage refusal (#608) withholds the
29
+ * evaluation, and the envelope's `coverage` block is the whole answer.
27
30
  */
28
31
  export function scenarioCommand(projectName, scenarioJson, commandContext, config = null) {
29
32
  const { root, provider, marker, graph, pluginGap } = commandContext;
@@ -44,31 +47,24 @@ export function scenarioCommand(projectName, scenarioJson, commandContext, confi
44
47
  // Parse the scenario input
45
48
  const scenarioInput = parseScenarioInput(scenarioJson);
46
49
 
47
- // Check coverage
48
- const notAnalyzed = commandContext.analysis.failures
49
- .filter(isWholeFileFailure)
50
- .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
51
-
52
- if (notAnalyzed.length > 0) {
53
- throw new Error(
54
- `archkeep: the graph has incomplete coverage — ${notAnalyzed.length} file` +
55
- `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so the scenario may ` +
56
- `under-represent the real architecture. Fix the unanalyzed files and re-run.`,
57
- );
50
+ // Check coverage — refused through the one structured contract
51
+ // `./coverage-verdict.mjs` builds (#608): the evaluation is withheld
52
+ // in-band, where a parser and `--output` can read it.
53
+ const completeness = coverageVerdict(commandContext);
54
+ if (!completeness.complete) {
55
+ return coverageRefusal({ command: "scenario", commandContext, what: "evaluating a scenario" });
58
56
  }
59
57
 
60
58
  // Evaluate
61
59
  const scenario = evaluateScenario(projectName, commandContext, scenarioInput, config);
62
60
 
63
61
  const coverage = {
64
- complete: scenario.complete,
62
+ complete: true,
65
63
  projects: Object.keys(graph.nodes).length,
66
64
  analyzedFiles: commandContext.analysis.analyzed,
67
65
  imports: commandContext.analysis.imports.length,
68
66
  notAnalyzed: [],
69
- blindSpots: commandContext.analysis.failures
70
- .filter((f) => !isWholeFileFailure(f))
71
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
67
+ blindSpots: completeness.blindSpots,
72
68
  notes: [
73
69
  "scenario evaluation is virtual and not authoritative — run `check` for the real verdict",
74
70
  "per-edge verdicts cover only depConstraints (3 of 15 violation types)",
@@ -81,6 +77,7 @@ export function scenarioCommand(projectName, scenarioJson, commandContext, confi
81
77
  virtual: scenario.virtual,
82
78
  notAuthoritative: scenario.notAuthoritative,
83
79
  complete: scenario.complete,
80
+ completeness: scenario.completeness,
84
81
  project: scenario.project,
85
82
  base: scenario.base,
86
83
  changes: scenario.changes,
@@ -102,7 +99,7 @@ export function scenarioCommand(projectName, scenarioJson, commandContext, confi
102
99
  result,
103
100
  });
104
101
 
105
- const text = formatScenarioReport(scenario);
102
+ const text = formatScenarioReport(scenario, coverage);
106
103
 
107
104
  return {
108
105
  status: "ok",
@@ -118,15 +115,29 @@ export function scenarioCommand(projectName, scenarioJson, commandContext, confi
118
115
  /**
119
116
  * Formats a scenario evaluation as terminal text.
120
117
  *
118
+ * The coverage line rides the same `coverageIncompleteReasons` clauses every
119
+ * other text face uses (`../verdict.mjs`), so a terminal reader is told how
120
+ * much of the tree the comparison inspected, in the one wording the JSON
121
+ * envelope's status also speaks (#609).
122
+ *
121
123
  * @param {object} scenario The scenario evaluation result.
124
+ * @param {object} coverage The run's coverage block.
122
125
  * @returns {string}
123
126
  */
124
- function formatScenarioReport(scenario) {
127
+ function formatScenarioReport(scenario, coverage) {
125
128
  const lines = [];
126
129
 
127
130
  lines.push(`Scenario evaluation for "${scenario.project}"`);
128
131
  lines.push(`${"=".repeat(50)}`);
129
132
  lines.push(`Virtual: ${scenario.virtual} | Not authoritative: ${scenario.notAuthoritative}`);
133
+ // The "how much was inspected" line, derived from the same coverage block
134
+ // the envelope carries — never re-counted here.
135
+ lines.push(
136
+ `Coverage: ${coverage.imports} import${coverage.imports === 1 ? "" : "s"} in ` +
137
+ `${coverage.analyzedFiles} file${coverage.analyzedFiles === 1 ? "" : "s"} across ` +
138
+ `${coverage.projects} project${coverage.projects === 1 ? "" : "s"}` +
139
+ (coverage.complete ? "" : " — coverage incomplete"),
140
+ );
130
141
  lines.push("");
131
142
 
132
143
  if (scenario.changes.length > 0) {
@@ -167,10 +178,10 @@ function formatScenarioReport(scenario) {
167
178
  if (delta.dependentsAdded.length === 0 && delta.dependentsRemoved.length === 0) {
168
179
  lines.push(" No change to dependent set");
169
180
  }
170
- if (delta.constraintsChanged) {
181
+ if (delta.constraintsChanged && delta.constraintsChanged.status === "changed") {
171
182
  lines.push(" Constraint impact: CHANGED");
172
183
  }
173
- if (delta.decisionsChanged) {
184
+ if (delta.decisionsChanged && delta.decisionsChanged.status === "changed") {
174
185
  lines.push(" Decision impact: CHANGED");
175
186
  }
176
187
  lines.push("");
@@ -37,10 +37,11 @@
37
37
  * `now` and the output is reproducible byte-for-byte. Defaults to the wall
38
38
  * clock, the same injection `evaluate` uses for waiver expiry.
39
39
  */
40
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
40
+ import { blindSpotRows } from "../analysis/source-util.mjs";
41
41
  import { suppressionCovers } from "../config.mjs";
42
42
  import { referenceTime } from "../governance/clock.mjs";
43
43
  import { isWaiver, remainingMs, waiverStatus } from "../governance/waiver.mjs";
44
+ import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
44
45
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
45
46
  import { formatWaiversReport } from "../report/waivers-text.mjs";
46
47
  import { partitionUnownedCoverage } from "./coverage-acceptance.mjs";
@@ -150,10 +151,13 @@ export function computeWaivers(suppressions, rawViolations, now = referenceTime(
150
151
  * clock, and — from `cli.mjs`'s `runWaivers` — the workspace-relative path
151
152
  * the run's law actually resolved from, so the `coverage.unowned` matching
152
153
  * below subtracts the same configuration files `check` subtracts.
153
- * @returns {Promise<{status: "ok", waivers: object, report: {text: string, json: string}}>}
154
- * @throws {Error} whenever the run's law is malformed, or the tree has
155
- * whole-file analysis failures exit-3 class, the same posture `check` takes
156
- * on a malformed config and `impact`/`drift` take on incomplete coverage.
154
+ * @returns {Promise<{status: "ok"|"no-verdict", waivers?: object, coverage: object,
155
+ * report: {text: string, json: string}}>}
156
+ * `status: "no-verdict"` carries no `waivers` payload the verdict was
157
+ * withheld, and the envelope's `coverage` block is the whole answer (#608).
158
+ * @throws {Error} whenever the run's law is malformed — exit-3 class, the same
159
+ * posture `check` takes on a malformed config. Incomplete coverage returns
160
+ * the structured no-verdict envelope instead of throwing (#608).
157
161
  */
158
162
  export async function waiversCommand(commandContext, boundaryConfig, io = {}) {
159
163
  const { root, provider, marker, analysis, graph } = commandContext;
@@ -180,25 +184,23 @@ export async function waiversCommand(commandContext, boundaryConfig, io = {}) {
180
184
  // A waiver surface over a tree it could not fully read is a lottery ticket,
181
185
  // not a surface: a file the analyzer never judged contributes no raw
182
186
  // violation, so every waiver that names it reads as stale and the report
183
- // says "covers nothing" about a finding the run never looked at. Refuse
184
- // loudly on whole-file failures, the same posture `impact`, `drift`, and
185
- // `history` take — "could not look" must never read as "looked and found
186
- // nothing" (`./impact.mjs`'s refusal names the same silence). A whole-file
187
- // failure whose file a `coverage.unowned` row accepts is withdrawn first,
188
- // exactly as `check` withdraws it (`./check.mjs`'s `acceptedUnclaimed`):
189
- // its state is a recorded acceptance this very report is about to name,
190
- // not a hole the run failed to look at.
191
- const notAnalyzed = analysis.failures
192
- .filter(isWholeFileFailure)
193
- .filter(({ sourceFile }) => !unownedCoverage.acceptedFiles.has(sourceFile))
194
- .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
195
-
196
- if (notAnalyzed.length > 0) {
197
- throw new Error(
198
- `archkeep: waivers has incomplete coverage — ${notAnalyzed.length} file` +
199
- `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so every waiver naming one ` +
200
- `would read as covering nothing it never saw. Fix the unanalyzed files and re-run.`,
201
- );
187
+ // says "covers nothing" about a finding the run never looked at. Refused
188
+ // through the one structured contract `./coverage-verdict.mjs` builds
189
+ // (#608) — "could not look" must never read as "looked and found nothing".
190
+ // A whole-file failure whose file a `coverage.unowned` row accepts is
191
+ // withdrawn first, exactly as `check` withdraws it (`./check.mjs`'s
192
+ // `acceptedUnclaimed`): its state is a recorded acceptance this very report
193
+ // is about to name, not a hole the run failed to look at.
194
+ const completeness = coverageVerdict(commandContext, {
195
+ acceptedFiles: unownedCoverage.acceptedFiles,
196
+ });
197
+ if (!completeness.complete) {
198
+ return coverageRefusal({
199
+ command: "waivers",
200
+ commandContext,
201
+ what: "measuring the waiver surface",
202
+ acceptedFiles: unownedCoverage.acceptedFiles,
203
+ });
202
204
  }
203
205
 
204
206
  // F07: a waiver surface measured against a graph that cannot see the
@@ -226,10 +228,13 @@ export async function waiversCommand(commandContext, boundaryConfig, io = {}) {
226
228
  projects: Object.keys(graph.nodes).length,
227
229
  analyzedFiles: analysis.analyzed,
228
230
  imports: analysis.imports.length,
229
- notAnalyzed,
230
- blindSpots: analysis.failures
231
- .filter((failure) => !isWholeFileFailure(failure))
232
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
231
+ // The withdrawn list — whole-file failures a `coverage.unowned` row
232
+ // accepts are already named in `result.unownedAcceptances` below, so
233
+ // repeating them here would double-count the same acceptance. On this
234
+ // path the list is empty by construction: anything unwithdrawn refused
235
+ // above.
236
+ notAnalyzed: completeness.notAnalyzed,
237
+ blindSpots: blindSpotRows(analysis.failures),
233
238
  // `remainingMs` reflects the wall clock at the moment of THIS run, not the
234
239
  // workspace — it is expected to differ between two runs of an unchanged
235
240
  // tree, by design (`../governance/clock.mjs`). Disclosed here, in-band,
@@ -282,6 +287,9 @@ export async function waiversCommand(commandContext, boundaryConfig, io = {}) {
282
287
  return {
283
288
  status: "ok",
284
289
  waivers: result,
290
+ // The same `coverage` block the envelope carries, so both return shapes —
291
+ // this one and the no-verdict refusal's (#608) — expose it under one key.
292
+ coverage,
285
293
  report: {
286
294
  text: formatWaiversReport(result),
287
295
  json: renderJson(envelope),
@@ -107,15 +107,68 @@ export function declarationDigest(intent) {
107
107
  });
108
108
  }
109
109
 
110
+ /**
111
+ * The identity string of one graph edge, in the canonical spelling
112
+ * `source>target:type` — the `(source, target, type)` identity design §1
113
+ * names. The ONE spelling the evolution events' `observed.edges` and
114
+ * `affected.boundaries` use: this module owns it, and `classifyEvolution`
115
+ * maps every edge it is handed through this function, so there is exactly
116
+ * one definition of "same edge" and no second spelling to drift.
117
+ *
118
+ * @param {{source: string, target: string, type: string}} edge
119
+ * @returns {string}
120
+ */
121
+ export function edgeEvolutionIdentity({ source, target, type }) {
122
+ return `${source}>${target}:${type}`;
123
+ }
124
+
125
+ /**
126
+ * The one accepted input shape for an `observed.edges` entry: the raw
127
+ * `{source, target, type}` triple. A caller handing over a ready-made string
128
+ * would be choosing a second spelling of "same edge", so a string is refused
129
+ * loudly rather than accepted as one shape more — the identity string is this
130
+ * module's output, never its input.
131
+ *
132
+ * @param {unknown} entry
133
+ * @returns {string}
134
+ */
135
+ function evolutionBoundary(entry) {
136
+ if (
137
+ typeof entry !== "object" ||
138
+ entry === null ||
139
+ !("source" in entry) ||
140
+ typeof entry.source !== "string" ||
141
+ entry.source === "" ||
142
+ !("target" in entry) ||
143
+ typeof entry.target !== "string" ||
144
+ entry.target === "" ||
145
+ !("type" in entry) ||
146
+ typeof entry.type !== "string"
147
+ ) {
148
+ throw new TypeError(
149
+ "classifyEvolution: observed.edges entries must be {source, target, type} triples — " +
150
+ "the identity string is classifyEvolution's own output spelling, never an input",
151
+ );
152
+ }
153
+ // The guard above has verified every property; the annotation only states
154
+ // what it proved.
155
+ return edgeEvolutionIdentity(
156
+ /** @type {{source: string, target: string, type: string}} */ (entry),
157
+ );
158
+ }
159
+
110
160
  /**
111
161
  * @typedef {object} EvolutionEvidence
112
162
  * @property {{projects?: {added: string[], removed: string[], changed: string[]},
113
- * edges?: {added: string[], removed: string[]},
163
+ * edges?: {added: {source: string, target: string, type: string}[],
164
+ * removed: {source: string, target: string, type: string}[]},
114
165
  * policyChanged?: boolean|null, policyOneSided?: boolean,
115
166
  * provenanceChanged?: boolean|null}} [observed]
116
- * The structural diff between base and head: project names and edge identity
117
- * strings (source,target,type) that were added, removed, or changed. Empty
118
- * by default. `policyChanged` — whether the policy fingerprint changed
167
+ * The structural diff between base and head: project names and raw edge
168
+ * triples that were added, removed, or changed. The triples are mapped
169
+ * through `edgeEvolutionIdentity` here — the identity spelling is this
170
+ * module's own, so `affected.boundaries` comes out as identity strings
171
+ * whichever shape the caller held. Empty by default. `policyChanged` — whether the policy fingerprint changed
119
172
  * between base and head; `null` is "could not be compared": exactly one side
120
173
  * records the policy (`policyOneSided: true`) or neither does
121
174
  * (both-absent). `true` is a disclosure, never a refusal. `policyOneSided`
@@ -197,9 +250,9 @@ export function declarationDigest(intent) {
197
250
  * absent (`null`) ⇒ NOT asserted, note added |
198
251
  *
199
252
  * The `affected` identities are derived from the same signals, never from a
200
- * second opinion: changed project names, changed edge identity strings, the
201
- * constraint/intent rows whose verdict was not `pass`/`matched`, and the ADR
202
- * ids whose lineage moved.
253
+ * second opinion: changed project names, the changed edges under the one
254
+ * identity spelling (`edgeEvolutionIdentity`), the constraint/intent rows
255
+ * whose verdict was not `pass`/`matched`, and the ADR ids whose lineage moved.
203
256
  *
204
257
  * @param {EvolutionEvidence} [input]
205
258
  * @returns {EvolutionClassification}
@@ -211,8 +264,8 @@ export function classifyEvolution(input = {}) {
211
264
  const addedProjects = projects.added ?? [];
212
265
  const removedProjects = projects.removed ?? [];
213
266
  const changedProjects = projects.changed ?? [];
214
- const addedEdges = edges.added ?? [];
215
- const removedEdges = edges.removed ?? [];
267
+ const addedEdges = (edges.added ?? []).map(evolutionBoundary);
268
+ const removedEdges = (edges.removed ?? []).map(evolutionBoundary);
216
269
  const structureChanged =
217
270
  addedProjects.length +
218
271
  removedProjects.length +