@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
@@ -40,9 +40,9 @@
40
40
  */
41
41
  import { readFileSync } from "node:fs";
42
42
 
43
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
44
43
  import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
45
44
  import { computeRuleImpact } from "./edge-constraints.mjs";
45
+ import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
46
46
  import { SCHEMA_VERSION } from "../report/json.mjs";
47
47
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
48
48
  import { formatDiffReport } from "../report/diff-text.mjs";
@@ -349,11 +349,14 @@ export function computeDiff(baseline, head) {
349
349
  * When omitted, reads from the real filesystem. `config` is the loaded
350
350
  * boundary config; when provided, rule-impact analysis is computed alongside
351
351
  * the structural diff.
352
- * @returns {{status: "ok"|"no-verdict", diff: object, coverage: object,
353
- * report: {text: string, json: string}}}
354
- * @throws {Error} when the baseline cannot be read or is incomplete, when
355
- * the head is incomplete, or when an Nx workspace has polyglot manifests
356
- * but the plugin is not registered.
352
+ * @returns {{status: "ok"|"no-verdict", diff?: object, coverage: object,
353
+ * report: {text: string, json: string}}} `diff` is absent under
354
+ * `status: "no-verdict"` the coverage refusal (#608) withholds the
355
+ * comparison, and the envelope's `coverage` block is the whole answer.
356
+ * @throws {Error} when the baseline cannot be read or is incomplete, or when
357
+ * an Nx workspace has polyglot manifests but the plugin is not registered
358
+ * (the inline throw in the body). An incomplete head returns the
359
+ * structured no-verdict envelope instead of throwing (#608).
357
360
  */
358
361
  export function diffCommand(
359
362
  baselinePath,
@@ -379,19 +382,18 @@ export function diffCommand(
379
382
  // is a caller error, not a workspace fact, and it should name the file.
380
383
  const baseline = readBaseline(baselinePath);
381
384
 
382
- // Refuse an incomplete head same reasoning as the incomplete baseline
383
- // refusal: every "added" or "removed" entry would be ambiguous.
384
- const notAnalyzed = commandContext.analysis.failures
385
- .filter(isWholeFileFailure)
386
- .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
387
-
388
- if (notAnalyzed.length > 0) {
389
- throw new Error(
390
- `archkeep: the head graph has incomplete coverage — ${notAnalyzed.length} file` +
391
- `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so every "added" or ` +
392
- `"removed" entry in the diff would be ambiguous between a real change and a coverage ` +
393
- `gap. Fix the unanalyzed files and re-run.`,
394
- );
385
+ // Refuse an incomplete head through the one structured contract
386
+ // `./coverage-verdict.mjs` builds (#608): the diff is withheld in-band
387
+ // status "no-verdict", exit 3, a `coverage` block naming every file and
388
+ // site the run could not judge — where a parser and `--output` can read it.
389
+ // The reasoning is the incomplete-baseline refusal's: every "added" or
390
+ // "removed" entry would be ambiguous. That baseline refusal stays a throw —
391
+ // `parseBaseline` above throws it through the default reader — because a
392
+ // baseline nobody captured correctly is a caller error about a file, not a
393
+ // coverage fact about THIS tree.
394
+ const completeness = coverageVerdict(commandContext);
395
+ if (!completeness.complete) {
396
+ return coverageRefusal({ command: "diff", commandContext, what: "diffing the head graph" });
395
397
  }
396
398
 
397
399
  const head = buildHeadSnapshot(commandContext);
@@ -404,9 +406,7 @@ export function diffCommand(
404
406
  analyzedFiles: commandContext.analysis.analyzed,
405
407
  imports: commandContext.analysis.imports.length,
406
408
  notAnalyzed: [],
407
- blindSpots: commandContext.analysis.failures
408
- .filter((f) => !isWholeFileFailure(f))
409
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
409
+ blindSpots: completeness.blindSpots,
410
410
  notes: [],
411
411
  };
412
412
 
@@ -417,6 +417,12 @@ export function diffCommand(
417
417
  projects: baseline.projects.length,
418
418
  edges: baseline.dependencies.length,
419
419
  toolVersion: baseline.toolVersion,
420
+ // The provenance the baseline itself recorded — which commit, remote,
421
+ // and dirtiness the captured side names (#609). The head side's own
422
+ // provenance rides the envelope's `workspace.provenance`; without the
423
+ // baseline's, a consumer could not tell WHICH revision the empty or
424
+ // non-empty diff is measured against.
425
+ provenance: baseline.provenance ?? null,
420
426
  },
421
427
  head: { projects: head.projects.length, edges: head.dependencies.length },
422
428
  addedProjects: diff.addedProjects,
@@ -525,8 +531,9 @@ export function diffCommand(
525
531
  // no depConstraints violations were introduced or resolved on the changed
526
532
  // edges. Run `check` for the complete verdict.
527
533
  coverage.notes.push(
528
- "per-edge rule-impact covers only depConstraints (3 of 15 violation types). " +
529
- "A dependency with no rule-impact may still violate npm-ban, circular-dependency, " +
534
+ "per-edge rule-impact covers only depConstraints (3 of 15 violation types; standing " +
535
+ "edges adjacent to a tags-changed project are re-judged under both sides' tags). A " +
536
+ "dependency with no rule-impact may still violate npm-ban, circular-dependency, " +
530
537
  "lazy-load, or other rules that require import-site details. Run check for the " +
531
538
  "complete verdict.",
532
539
  );
@@ -40,7 +40,11 @@
40
40
  * byte-identical text and JSON — the same promise `graph`'s snapshots make,
41
41
  * which is what lets a consumer `diff` two proposals meaningfully.
42
42
  */
43
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
43
+ import {
44
+ blindSpotRows,
45
+ isWholeFileFailure,
46
+ unresolvableLiteralCount,
47
+ } from "../analysis/source-util.mjs";
44
48
  import { evaluateDiscovery } from "../governance/discovery-proposal.mjs";
45
49
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
46
50
  import { formatDiscoverReport } from "../report/discover-text.mjs";
@@ -131,6 +135,8 @@ export function discoverCommand(commandContext, { propose = false } = {}) {
131
135
  .filter(isWholeFileFailure)
132
136
  .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
133
137
 
138
+ const blindSpotCount = unresolvableLiteralCount(analysis.failures);
139
+
134
140
  const observed = buildObserved(commandContext);
135
141
 
136
142
  const proposal = propose ? evaluateDiscovery(observed) : null;
@@ -139,16 +145,32 @@ export function discoverCommand(commandContext, { propose = false } = {}) {
139
145
  // proposal's name: every candidate edge would be ambiguous between "gone"
140
146
  // and "never seen". Refuse loudly — the same reasoning `drift`'s refusal
141
147
  // gives — rather than print a proposal and a warning that it may be lying.
142
- if (propose && notAnalyzed.length > 0) {
148
+ // An unresolvable site is the same fabrication at site granularity (#595):
149
+ // the edge out of it may be missing, and a candidate built over a gap is
150
+ // still a guess.
151
+ if (propose && (notAnalyzed.length > 0 || blindSpotCount > 0)) {
143
152
  throw new Error(
144
- `archkeep: discover --propose has incomplete coverage — ${notAnalyzed.length} file` +
145
- `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so every candidate ` +
146
- `would be ambiguous between "gone" and "never seen". Fix the unanalyzed files and ` +
147
- `re-run.`,
153
+ `archkeep: discover --propose has incomplete coverage — ` +
154
+ [
155
+ notAnalyzed.length > 0
156
+ ? `${notAnalyzed.length} file${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed`
157
+ : null,
158
+ blindSpotCount > 0
159
+ ? `${blindSpotCount} import site${blindSpotCount === 1 ? "" : "s"} could not be resolved`
160
+ : null,
161
+ ]
162
+ .filter(Boolean)
163
+ .join(", ") +
164
+ `, so every candidate would be ambiguous between "gone" and "never seen". ` +
165
+ `Fix the unresolved files and sites and re-run.`,
148
166
  );
149
167
  }
150
168
 
151
- const complete = notAnalyzed.length === 0;
169
+ // An unresolvable site was seen but never judged (#595): the snapshot's
170
+ // edge list under-represents the tree wherever that site would have drawn
171
+ // one, so `complete` cannot be claimed over it. It still reports — status
172
+ // no-verdict, exit 3 — naming the site in `coverage.blindSpots`.
173
+ const complete = notAnalyzed.length === 0 && blindSpotCount === 0;
152
174
  const status = complete ? "ok" : "no-verdict";
153
175
  const exitCode = complete ? 0 : 3;
154
176
 
@@ -158,9 +180,7 @@ export function discoverCommand(commandContext, { propose = false } = {}) {
158
180
  analyzedFiles: analysis.analyzed,
159
181
  imports: analysis.imports.length,
160
182
  notAnalyzed,
161
- blindSpots: analysis.failures
162
- .filter((failure) => !isWholeFileFailure(failure))
163
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
183
+ blindSpots: blindSpotRows(analysis.failures),
164
184
  notes: [],
165
185
  };
166
186
 
@@ -31,9 +31,11 @@
31
31
  *
32
32
  * - the intent file cannot be read or parsed (strict JSON, validated) → throw
33
33
  * → exit 3;
34
- * - the observed side is incomplete (`notAnalyzed` non-empty) exit 3, the
35
- * same reasoning as `diff` — every "project missing" would be ambiguous
36
- * between "gone" and "never seen";
34
+ * - the observed side is incomplete the structured no-verdict refusal
35
+ * (`./coverage-verdict.mjs`), exit 3, the same reasoning as `diff` — every
36
+ * "project missing" would be ambiguous between "gone" and "never seen"
37
+ * but in the envelope, not on stderr: the same status/coverage contract
38
+ * `graph`/`context` return over the same condition (#608);
37
39
  * - an Nx workspace has polyglot manifests but the plugin is not registered →
38
40
  * exit 3, the same refusal `graph`/`diff` make;
39
41
  * - a boundary or row side matched no observed project → exit 3, the same
@@ -74,8 +76,9 @@
74
76
  * everywhere, never `localeCompare` — so two runs over an unchanged tree and
75
77
  * intent produce byte-identical text and JSON.
76
78
  */
77
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
79
+ import { blindSpotRows } from "../analysis/source-util.mjs";
78
80
  import { buildDependencies, buildProjects } from "./graph.mjs";
81
+ import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
79
82
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
80
83
  import { resolveProvenance } from "./provenance.mjs";
81
84
  import { judgeIntent } from "../architecture-intent/judge.mjs";
@@ -290,11 +293,13 @@ function intentRows(intent) {
290
293
  * row's `decisionRef`. `configError` carries a boundary-policy load failure
291
294
  * the caller chose not to throw at the load site — rethrown here, unchanged,
292
295
  * only if an intent row actually cites something.
293
- * @returns {Promise<{status: "ok", drift: object, coverage: object,
296
+ * @returns {Promise<{status: "ok"|"no-verdict", drift?: object, coverage: object,
294
297
  * report: {text: string, json: string}}>}
295
- * @throws {Error} on every condition the header lists, all exit-3 class, plus
296
- * a malformed ADR registry the same loud refusal `provenance` makes for
297
- * the identical read.
298
+ * `status: "no-verdict"` carries no `drift` payload the verdict was
299
+ * withheld, and the envelope's `coverage` block is the whole answer (#608).
300
+ * @throws {Error} on every condition the header lists except the coverage one,
301
+ * which returns instead of throwing, plus a malformed ADR registry — the
302
+ * same loud refusal `provenance` makes for the identical read.
298
303
  */
299
304
  export async function driftCommand(commandContext, io = {}) {
300
305
  const { root, provider, marker, analysis } = commandContext;
@@ -302,16 +307,13 @@ export async function driftCommand(commandContext, io = {}) {
302
307
  refuseIncompleteGraph(commandContext);
303
308
 
304
309
  // A drift verdict cannot be established over a tree it could not fully read.
305
- const notAnalyzed = analysis.failures
306
- .filter(isWholeFileFailure)
307
- .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
308
-
309
- if (notAnalyzed.length > 0) {
310
- throw new Error(
311
- `archkeep: drift has incomplete coverage ${notAnalyzed.length} file` +
312
- `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so every "project missing" ` +
313
- `would be ambiguous between "gone" and "never seen". Fix the unanalyzed files and re-run.`,
314
- );
310
+ // The refusal is the structured one the graph family speaks (#608): the
311
+ // verdict is withheld in-band — status "no-verdict", exit 3, a `coverage`
312
+ // block naming every file and site the run could not judge — where a parser
313
+ // and `--output` can read it, not on stderr where only a human can.
314
+ const completeness = coverageVerdict(commandContext);
315
+ if (!completeness.complete) {
316
+ return coverageRefusal({ command: "drift", commandContext, what: "judging drift" });
315
317
  }
316
318
 
317
319
  const intent = await (io.loadIntentOverride ?? loadIntent)(root, {
@@ -398,9 +400,7 @@ export async function driftCommand(commandContext, io = {}) {
398
400
  notAnalyzed: [],
399
401
  // Drift reads only the graph — provider failures are the same blind spots
400
402
  // every other command reports, and a blind spot never prevents a verdict.
401
- blindSpots: analysis.failures
402
- .filter((failure) => !isWholeFileFailure(failure))
403
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
403
+ blindSpots: blindSpotRows(analysis.failures),
404
404
  // Coverage notes (e.g. an `optional: true` allowed row the team has not
405
405
  // built yet) ride here so "optional and absent" never reads as "never
406
406
  // checked".
@@ -42,6 +42,7 @@
42
42
  * enough that merging them would blur the layer boundary the AGENTS.md guards.
43
43
  */
44
44
 
45
+ import { edgeEvolutionIdentity } from "../governance/evolution-event.mjs";
45
46
  import { renderMessage } from "../rules/messages.mjs";
46
47
  import { buildReachability } from "../rules/reachability.mjs";
47
48
  import {
@@ -212,7 +213,9 @@ export function declaredEdgeViolationsForCheck(graph, depConstraints) {
212
213
  * constraint table. The constraint table is the current one — it is what `check`
213
214
  * would judge from today, not what some past version judged from.
214
215
  *
215
- * @param {{addedEdges: object[], removedEdges: object[]}} diff From `computeDiff`.
216
+ * @param {{addedEdges: object[], removedEdges: object[],
217
+ * changedProjects?: {name: string, changes: {field: string, baseline?: unknown,
218
+ * head?: unknown}[]}[]}} diff From `computeDiff`.
216
219
  * @param {object} headNodes The head graph's `nodes` map (for tag lookups).
217
220
  * @param {object} headDependencies The head graph's `dependencies` map (for reachability).
218
221
  * @param {object[]} baselineProjects The baseline snapshot's project list (each
@@ -285,6 +288,49 @@ export function computeRuleImpact(
285
288
  }
286
289
  }
287
290
 
291
+ // Standing edges whose legality a tags-only change can flip (#600): the
292
+ // edge moved in neither direction, so the loops above never see it, but
293
+ // the tags its judgment reads did. An edge adjacent to a project whose
294
+ // tags changed is judged under BOTH sides' tags — violating under head
295
+ // where it was legal under baseline is an introduced violation, the
296
+ // inverse is a resolved one, and a violation under both is pre-existing
297
+ // (unchanged legality is `check`'s finding, not this diff's). Edges the
298
+ // loops above already judged are skipped by identity, so no edge is ever
299
+ // reported twice.
300
+ const tagChangedNames = new Set(
301
+ (diff.changedProjects ?? [])
302
+ .filter((project) => (project.changes ?? []).some((change) => change.field === "tags"))
303
+ .map((project) => project.name),
304
+ );
305
+ if (tagChangedNames.size > 0) {
306
+ const judged = new Set(
307
+ [...diff.addedEdges, ...diff.removedEdges].map((edge) => edgeEvolutionIdentity(edge)),
308
+ );
309
+ for (const edge of baselineDependencies) {
310
+ if (!(tagChangedNames.has(edge.source) || tagChangedNames.has(edge.target))) continue;
311
+ if (judged.has(edgeEvolutionIdentity(edge))) continue;
312
+ const headViolations = judgeEdge(
313
+ edge,
314
+ headNodes,
315
+ headDependencies,
316
+ depConstraints,
317
+ headReachability,
318
+ );
319
+ const baselineViolations = judgeEdge(
320
+ edge,
321
+ baselineNodes,
322
+ baselineDepsMap,
323
+ depConstraints,
324
+ baselineReachability,
325
+ );
326
+ if (headViolations.length > 0 && baselineViolations.length === 0) {
327
+ introduced.push(...headViolations);
328
+ } else if (baselineViolations.length > 0 && headViolations.length === 0) {
329
+ resolved.push(...baselineViolations);
330
+ }
331
+ }
332
+ }
333
+
288
334
  return { introduced, resolved };
289
335
  }
290
336