@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
@@ -80,6 +80,7 @@ import { debtChangeDiff, debtFactId, driftFactOf } from "../governance/debt-ledg
80
80
  import { computeAffectedDecisions } from "../governance/decision-lineage.mjs";
81
81
  import {
82
82
  classifyEvolution,
83
+ edgeEvolutionIdentity,
83
84
  eventDedupeKey,
84
85
  eventId,
85
86
  EVOLUTION_EVENT_SCHEMA_VERSION,
@@ -939,23 +940,24 @@ function buildEvolutionSummary(comparisons) {
939
940
  comparisons.flatMap((c) => c.observed.projects.changed.map((p) => p.name ?? p)),
940
941
  ),
941
942
  },
943
+ // The identity string is the ONE spelling `edgeEvolutionIdentity` owns —
944
+ // the same spelling `affected.boundaries` and every stored event carry.
945
+ // An edge without a complete triple has no identity to name, so it is
946
+ // dropped from the union and counted into `unnamedEdges` rather than
947
+ // leaking an object serialization into a field of identity strings.
942
948
  edges: {
943
949
  added: unique(
944
950
  comparisons.flatMap((c) =>
945
- c.observed.edges.added.map((e) =>
946
- e.source && e.target && e.type
947
- ? `${e.source}→${e.target}:${e.type}`
948
- : JSON.stringify(e),
949
- ),
951
+ c.observed.edges.added
952
+ .filter((e) => e.source && e.target && e.type)
953
+ .map((e) => edgeEvolutionIdentity(e)),
950
954
  ),
951
955
  ),
952
956
  removed: unique(
953
957
  comparisons.flatMap((c) =>
954
- c.observed.edges.removed.map((e) =>
955
- e.source && e.target && e.type
956
- ? `${e.source}→${e.target}:${e.type}`
957
- : JSON.stringify(e),
958
- ),
958
+ c.observed.edges.removed
959
+ .filter((e) => e.source && e.target && e.type)
960
+ .map((e) => edgeEvolutionIdentity(e)),
959
961
  ),
960
962
  ),
961
963
  },
@@ -983,6 +985,21 @@ function buildEvolutionSummary(comparisons) {
983
985
  };
984
986
 
985
987
  const notes = unique(comparisons.flatMap((c) => c.notes ?? []));
988
+ const unnamedEdges = comparisons.reduce(
989
+ (count, c) =>
990
+ count +
991
+ [...c.observed.edges.added, ...c.observed.edges.removed].filter(
992
+ (e) => !(e.source && e.target && e.type),
993
+ ).length,
994
+ 0,
995
+ );
996
+ if (unnamedEdges > 0) {
997
+ // The house shape for "we could not name it": a note naming the gap,
998
+ // never a silent drop dressed as a clean union.
999
+ notes.push(
1000
+ `${unnamedEdges} changed edge(s) carry no complete identity and are not named in observed.edges`,
1001
+ );
1002
+ }
986
1003
  return {
987
1004
  transitions: comparisons.length,
988
1005
  disposition,
@@ -79,7 +79,11 @@
79
79
  * was: the field, and its rendered lines, exist only when the comparison was
80
80
  * requested.
81
81
  */
82
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
82
+ import {
83
+ blindSpotRows,
84
+ isWholeFileFailure,
85
+ unresolvableLiteralCount,
86
+ } from "../analysis/source-util.mjs";
83
87
  import { UsageError } from "../errors.mjs";
84
88
  import { evaluate } from "../rules/index.mjs";
85
89
  import { findConstraintsFor } from "../rules/tags.mjs";
@@ -369,7 +373,13 @@ export function explainCommand(site, commandContext, config, options = {}) {
369
373
  .filter(isWholeFileFailure)
370
374
  .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
371
375
 
372
- const complete = notAnalyzed.length === 0;
376
+ // An unresolvable site was seen but never judged (#595): the graph is
377
+ // missing whatever edge that site would have drawn, and rules that judge
378
+ // the whole graph (circularity, lazy loading) would answer over a gap. The
379
+ // explanation still reports — status no-verdict — naming the site in
380
+ // `coverage.blindSpots`, the same contract `graph`/`discover` run.
381
+ const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
382
+ const complete = notAnalyzed.length === 0 && blindSpotCount === 0;
373
383
  const status = complete ? "ok" : "no-verdict";
374
384
 
375
385
  // Find the import record at this site.
@@ -422,14 +432,7 @@ export function explainCommand(site, commandContext, config, options = {}) {
422
432
  analyzedFiles: commandContext.analysis.analyzed,
423
433
  imports: commandContext.analysis.imports.length,
424
434
  notAnalyzed,
425
- blindSpots: commandContext.analysis.failures
426
- .filter((f) => !isWholeFileFailure(f))
427
- .map(({ sourceFile, line, column, reason }) => ({
428
- file: sourceFile,
429
- line,
430
- column,
431
- reason,
432
- })),
435
+ blindSpots: blindSpotRows(commandContext.analysis.failures),
433
436
  notes: [],
434
437
  };
435
438
 
@@ -576,9 +579,7 @@ export function explainCommand(site, commandContext, config, options = {}) {
576
579
  analyzedFiles: commandContext.analysis.analyzed,
577
580
  imports: commandContext.analysis.imports.length,
578
581
  notAnalyzed,
579
- blindSpots: commandContext.analysis.failures
580
- .filter((f) => !isWholeFileFailure(f))
581
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
582
+ blindSpots: blindSpotRows(commandContext.analysis.failures),
582
583
  notes: [],
583
584
  };
584
585
 
@@ -45,10 +45,11 @@
45
45
  * sorted; JSON rides `canonicalizeJson`. Two runs over an unchanged tree and
46
46
  * policy produce byte-identical text and JSON.
47
47
  */
48
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
48
+ import { blindSpotRows } from "../analysis/source-util.mjs";
49
49
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
50
50
  import { formatFitnessSection } from "../report/text.mjs";
51
51
  import { resolveProvenance } from "./provenance.mjs";
52
+ import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
52
53
  import { driftForCheck } from "./drift.mjs";
53
54
  import {
54
55
  evaluateFitness,
@@ -114,9 +115,13 @@ export function declaresFitness(config) {
114
115
  *
115
116
  * @param {object} commandContext From `resolveCommandContext`.
116
117
  * @param {{config?: object|null}} [io] The loaded policy, injectable for tests.
117
- * @returns {Promise<{status: "ok"|"findings"|"no-verdict", fitness: object, coverage: object,
118
- * report: {text: string, json: string}}>}
119
- * @throws {Error} on every condition the header lists, all exit-3 class.
118
+ * @returns {Promise<{status: "ok"|"findings"|"no-verdict", fitness?: object,
119
+ * coverage: object, report: {text: string, json: string}}>}
120
+ * `status: "no-verdict"` from the coverage refusal carries no `fitness`
121
+ * payload — the verdict was withheld, and the envelope's `coverage` block is
122
+ * the whole answer (#608).
123
+ * @throws {Error} on every condition the header lists except the coverage one,
124
+ * which returns instead of throwing.
120
125
  */
121
126
  export async function fitnessCommand(commandContext, io = {}) {
122
127
  const { root, provider, marker, analysis } = commandContext;
@@ -129,18 +134,16 @@ export async function fitnessCommand(commandContext, io = {}) {
129
134
  );
130
135
  }
131
136
 
132
- // A verdict over a tree it could not fully read is a guess. Same refusal
133
- // `drift`/`graph`/`diff` make for the same condition.
134
- const notAnalyzed = analysis.failures
135
- .filter(isWholeFileFailure)
136
- .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
137
- if (notAnalyzed.length > 0) {
138
- throw new Error(
139
- `archkeep: fitness has incomplete coverage — ${notAnalyzed.length} file` +
140
- `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so every coverage ` +
141
- `and graph claim would be ambiguous between "clean" and "never seen". Fix the ` +
142
- `unanalyzed files and re-run.`,
143
- );
137
+ // A verdict over a tree it could not fully read is a guess. Refused through
138
+ // the one structured contract `./coverage-verdict.mjs` builds (#608) this
139
+ // gate used to check whole-file failures only and then claim
140
+ // `coverage.complete: true` beside `blindSpots` that could carry an unjudged
141
+ // site, which the envelope law refuses as a programming error. The unified
142
+ // completeness returns the no-verdict envelope instead, for every axis the
143
+ // envelope law already withholds over.
144
+ const completeness = coverageVerdict(commandContext);
145
+ if (!completeness.complete) {
146
+ return coverageRefusal({ command: "fitness", commandContext, what: "judging fitness" });
144
147
  }
145
148
 
146
149
  // `drift-free` judges the SAME verdict-shaped intent `check`'s fold builds —
@@ -192,9 +195,7 @@ export async function fitnessCommand(commandContext, io = {}) {
192
195
  analyzedFiles: analysis.analyzed,
193
196
  imports: analysis.imports.length,
194
197
  notAnalyzed: [],
195
- blindSpots: analysis.failures
196
- .filter((failure) => !isWholeFileFailure(failure))
197
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
198
+ blindSpots: blindSpotRows(analysis.failures),
198
199
  notes: [],
199
200
  };
200
201
 
@@ -29,7 +29,11 @@
29
29
  */
30
30
  import { createHash } from "node:crypto";
31
31
 
32
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
32
+ import {
33
+ blindSpotRows,
34
+ isWholeFileFailure,
35
+ unresolvableLiteralCount,
36
+ } from "../analysis/source-util.mjs";
33
37
  import { canonicalizeJson } from "../canonical.mjs";
34
38
  import { DEFAULT_WORKSPACE_LAYOUT } from "../rules/specifiers.mjs";
35
39
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
@@ -222,7 +226,14 @@ export function graphCommand(commandContext, { config = null } = {}) {
222
226
  .filter(isWholeFileFailure)
223
227
  .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
224
228
 
225
- const complete = notAnalyzed.length === 0;
229
+ // An unresolvable import site was seen but never judged (#595): the edges
230
+ // out of it may be missing from this snapshot, so the snapshot must not
231
+ // claim `complete` over it. It still reports — status no-verdict, exit 3 —
232
+ // naming the site in `coverage.blindSpots`, the same contract `check` runs.
233
+ const blindSpots = blindSpotRows(commandContext.analysis.failures);
234
+ const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
235
+
236
+ const complete = notAnalyzed.length === 0 && blindSpotCount === 0;
226
237
  const status = complete ? "ok" : "no-verdict";
227
238
  const exitCode = complete ? 0 : 3;
228
239
 
@@ -245,9 +256,7 @@ export function graphCommand(commandContext, { config = null } = {}) {
245
256
  analyzedFiles: commandContext.analysis.analyzed,
246
257
  imports: commandContext.analysis.imports.length,
247
258
  notAnalyzed,
248
- blindSpots: commandContext.analysis.failures
249
- .filter((f) => !isWholeFileFailure(f))
250
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
259
+ blindSpots,
251
260
  notes: [],
252
261
  };
253
262
 
@@ -43,7 +43,11 @@
43
43
  * It does not print, and it does not decide the process's exit code —
44
44
  * `../../cli.mjs` owns those (`./README.md`).
45
45
  */
46
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
46
+ import {
47
+ blindSpotRows,
48
+ isWholeFileFailure,
49
+ unresolvableLiteralCount,
50
+ } from "../analysis/source-util.mjs";
47
51
  import { buildDependencies, buildProjects } from "./graph.mjs";
48
52
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
49
53
  import { formatHealthReport } from "../report/health-text.mjs";
@@ -108,7 +112,12 @@ export function healthCommand(commandContext, io = {}) {
108
112
  const edges = buildDependencies(graph.dependencies);
109
113
 
110
114
  // The run's coverage facts, the same shape every command's envelope carries.
111
- const fileComplete = analysis.failures.filter(isWholeFileFailure).length === 0;
115
+ // An unresolvable site is a fact the run saw but never judged (#595)
116
+ // metrics measured over it would read precision the run does not have,
117
+ // so it defeats file completeness the way a whole-file failure does.
118
+ const fileComplete =
119
+ analysis.failures.filter(isWholeFileFailure).length === 0 &&
120
+ unresolvableLiteralCount(analysis.failures) === 0;
112
121
  // The graph is complete only when the files are AND the graph actually sees
113
122
  // every polyglot edge — an Nx workspace with an unregistered plugin carries
114
123
  // a graph with no Go/Rust/Python edges, which `graph`/`impact` refuse and
@@ -123,9 +132,7 @@ export function healthCommand(commandContext, io = {}) {
123
132
  notAnalyzed: analysis.failures
124
133
  .filter(isWholeFileFailure)
125
134
  .map(({ sourceFile, reason }) => ({ file: sourceFile, reason })),
126
- blindSpots: analysis.failures
127
- .filter((f) => !isWholeFileFailure(f))
128
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
135
+ blindSpots: blindSpotRows(analysis.failures),
129
136
  notes: graphComplete
130
137
  ? []
131
138
  : [
@@ -74,12 +74,16 @@ import { createHash } from "node:crypto";
74
74
  import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
75
75
  import { basename, join, resolve } from "node:path";
76
76
 
77
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
77
+ import {
78
+ blindSpotRows,
79
+ isWholeFileFailure,
80
+ unresolvableLiteralCount,
81
+ } from "../analysis/source-util.mjs";
78
82
  import { containmentViolation } from "../containment.mjs";
79
83
  import { classifyEvolution } from "../governance/evolution-event.mjs";
80
84
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
81
85
  import { formatHistoryReport } from "../report/history-text.mjs";
82
- import { computeDiff, edgeIdentityKey, parseBaseline } from "./diff.mjs";
86
+ import { computeDiff, parseBaseline } from "./diff.mjs";
83
87
  import { buildDependencies, buildProjects } from "./graph.mjs";
84
88
  import { resolveProvenance } from "./provenance.mjs";
85
89
  import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
@@ -397,8 +401,12 @@ export function classifyTransition(from, to) {
397
401
  changed: diff.changedProjects.map((project) => project.name),
398
402
  },
399
403
  edges: {
400
- added: diff.addedEdges.map(edgeIdentityKey),
401
- removed: diff.removedEdges.map(edgeIdentityKey),
404
+ // The raw triples — `classifyEvolution` maps them through its own
405
+ // identity spelling (`edgeEvolutionIdentity`), so `affected.boundaries`
406
+ // carries the canonical strings every other event surface uses, not
407
+ // this module's diff-internal key spelling.
408
+ added: diff.addedEdges,
409
+ removed: diff.removedEdges,
402
410
  },
403
411
  policyChanged: meta.policyChanged,
404
412
  policyOneSided: meta.policyOneSided,
@@ -554,10 +562,23 @@ export function historyCommand(
554
562
  const notAnalyzed = commandContext.analysis.failures
555
563
  .filter(isWholeFileFailure)
556
564
  .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
557
- if (notAnalyzed.length > 0) {
565
+
566
+ const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
567
+ if (notAnalyzed.length > 0 || blindSpotCount > 0) {
558
568
  throw new Error(
559
- `archkeep: the head graph has incomplete coverage — ${notAnalyzed.length} file` +
560
- `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so a captured snapshot ` +
569
+ `archkeep: the head graph has incomplete coverage — ` +
570
+ [
571
+ notAnalyzed.length > 0
572
+ ? `${notAnalyzed.length} file${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed`
573
+ : null,
574
+ blindSpotCount > 0
575
+ ? `${blindSpotCount} import site${blindSpotCount === 1 ? "" : "s"} could not be resolved`
576
+ : null,
577
+ ]
578
+ .filter(Boolean)
579
+ .join(", ") +
580
+ `, so
581
+ a captured snapshot ` +
561
582
  `would under-represent the real architecture. Fix the unanalyzed files and re-run.`,
562
583
  );
563
584
  }
@@ -608,14 +629,7 @@ export function historyCommand(
608
629
  analyzedFiles: commandContext.analysis.analyzed,
609
630
  imports: commandContext.analysis.imports.length,
610
631
  notAnalyzed: [],
611
- blindSpots: commandContext.analysis.failures
612
- .filter((f) => !isWholeFileFailure(f))
613
- .map(({ sourceFile, line, column, reason }) => ({
614
- file: sourceFile,
615
- line,
616
- column,
617
- reason,
618
- })),
632
+ blindSpots: blindSpotRows(commandContext.analysis.failures),
619
633
  notes: [],
620
634
  },
621
635
  result: { ...head, policy: headPolicy ?? undefined },