@ecoma-io/archkeep 0.21.0 → 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 (37) 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 +126 -19
  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 +194 -2
  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.mjs +17 -18
  27. package/src/commands/plan-context-command.mjs +10 -5
  28. package/src/commands/reconcile.mjs +14 -17
  29. package/src/commands/scenario-evaluation.mjs +93 -16
  30. package/src/commands/scenario.mjs +28 -18
  31. package/src/commands/waivers.mjs +36 -28
  32. package/src/governance/evolution-event.mjs +62 -9
  33. package/src/intent/intent-manifest.json +83 -39
  34. package/src/report/json.mjs +32 -5
  35. package/src/report/text.mjs +82 -12
  36. package/src/verdict.mjs +78 -36
  37. package/src/workspace.mjs +126 -2
@@ -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
 
@@ -9,6 +9,7 @@
9
9
  * @module
10
10
  */
11
11
  import { readAdrContext } from "./adr.mjs";
12
+ import { edgeEvolutionIdentity } from "../governance/evolution-event.mjs";
12
13
  import { hasAuthority, resolveDecisionRef, stripAdrPrefix } from "../governance/adr-registry.mjs";
13
14
  import { isComboDepConstraint } from "../rules/tags.mjs";
14
15
  import { computeDecisionProvenance } from "../governance/provenance-graph.mjs";
@@ -16,9 +17,13 @@ import { resolveFileAttribution } from "./provenance.mjs";
16
17
 
17
18
  import {
18
19
  buildCompleteness,
20
+ buildEvidenceComplete,
19
21
  buildGovernanceCompleteness,
20
22
  evaluationStatus,
21
23
  EVALUATION_STATUS,
24
+ EVALUATION_CONTRACT_TYPES,
25
+ computeDomainCoverage,
26
+ REQUIRED_DOMAINS,
22
27
  } from "./completeness.mjs";
23
28
  import { computeImpact } from "./impact.mjs";
24
29
  import { computeImpactConstraints } from "./edge-constraints.mjs";
@@ -284,6 +289,116 @@ export function evaluateBoundaryImpact(graph, constraintImpact, targetProject) {
284
289
  // Canonical Architecture Evaluation
285
290
  // ---------------------------------------------------------------------------
286
291
 
292
+ /**
293
+ * The provenance coverage of a decision set: the fraction of rows that
294
+ * resolved to a known record with authority.
295
+ *
296
+ * An ADR row counts when its record carries decision authority
297
+ * (`hasAuthority` — `accepted`/`active`); a fitness row counts when it
298
+ * resolved to an id the loaded policy binds (`resolution === "known"`).
299
+ * Refs that resolve to nothing are reported by `unresolvedDecisionRefs`
300
+ * and never become rows; a record without authority emits a row the gate
301
+ * fails on loudly rather than counting. One derivation, one home —
302
+ * `deriveEvidenceGates` and the scenario face both read it, so the two
303
+ * callers cannot disagree about what a covered decision is.
304
+ *
305
+ * @param {object[]|null|undefined} decisions Decision rows from
306
+ * `buildDecisionImpact`.
307
+ * @returns {number} A ratio in [0, 1]; 0 when no decision binds an
308
+ * affected row.
309
+ */
310
+ export function decisionProvenanceCoverage(decisions) {
311
+ const rows = decisions ?? [];
312
+ if (rows.length === 0) return 0;
313
+ const covered = rows.filter((row) =>
314
+ row.kind === "adr" ? row.hasAuthority === true : row.resolution === "known",
315
+ ).length;
316
+ return covered / rows.length;
317
+ }
318
+
319
+ /**
320
+ * Derives evidence gates from evaluation outputs for the canonical evaluator.
321
+ *
322
+ * Each gate is computed from actual evaluation data rather than being
323
+ * caller-supplied. Gates not applicable to canonical evaluation
324
+ * (mutationCoverage, surfaceParity, baseIdentityValid) are set to 0/false
325
+ * and excluded via EVALUATION_CONTRACT_TYPES.CANONICAL.
326
+ *
327
+ * @param {object} evaluation The full evaluation result.
328
+ * @param {object} evaluation.completeness The completeness result.
329
+ * @param {object} evaluation.impact The structural impact.
330
+ * @param {object|null} evaluation.constraintImpact Constraint impact.
331
+ * @param {object|null} evaluation.decisionImpact Decision impact.
332
+ * @param {object} evaluation.boundaryImpact Boundary impact.
333
+ * @param {object} evaluation.findingsImpact Findings impact.
334
+ * @param {object} evaluation.debtImpact Debt impact.
335
+ * @param {object} evaluation.evolutionAlignment Evolution alignment.
336
+ * @returns {object} Evidence gate values for buildEvidenceComplete.
337
+ */
338
+ export function deriveEvidenceGates(evaluation) {
339
+ const { completeness, constraintImpact, decisionImpact } = evaluation;
340
+
341
+ // domainCoverage: ratio of evaluated required domains
342
+ /** @type {{ [domain: string]: string }} */
343
+ const domainStatuses = {};
344
+ for (const domain of REQUIRED_DOMAINS) {
345
+ const dom = completeness.domains[domain];
346
+ domainStatuses[domain] = dom ? dom.status : EVALUATION_STATUS.NOT_EVALUATED;
347
+ }
348
+ const dc = computeDomainCoverage(domainStatuses);
349
+ const domainCoverage = dc.coverage;
350
+
351
+ // claimEvidenceCoverage: structural always produces claims with evidence.
352
+ // Constraint/decision claims exist when config is present.
353
+ const structuralClaimEvidence = 1; // structural always produces evidence
354
+ const constraintClaimEvidence = constraintImpact !== null ? 1 : 0;
355
+ const decisionClaimEvidence = decisionImpact !== null ? 1 : 0;
356
+ const totalClaims = 3; // structural + constraint + decision
357
+ const evidencedClaims = structuralClaimEvidence + constraintClaimEvidence + decisionClaimEvidence;
358
+ const claimEvidenceCoverage = totalClaims > 0 ? evidencedClaims / totalClaims : 0;
359
+
360
+ // causalCoverage: constraint consequences with complete causal chains
361
+ // When constraint impact is present, all constraint edges are traced.
362
+ // When absent, causal coverage is 0 (no constraints to trace).
363
+ const causalCoverage = constraintImpact !== null ? 1 : 0;
364
+
365
+ // provenanceCoverage: the fraction of decision rows that resolved to a
366
+ // known record with authority — derived from facts the rows carry, never
367
+ // from a property the row builder never wrote.
368
+ const provenanceCoverage = decisionProvenanceCoverage(decisionImpact?.decisions);
369
+
370
+ // mutationCoverage: not applicable for canonical evaluation
371
+ // surfaceParity: not applicable for canonical evaluation
372
+
373
+ // hiddenGapCount: NOT_EVALUATED domains without a note
374
+ let hiddenGapCount = 0;
375
+ for (const [, domain] of Object.entries(completeness.domains)) {
376
+ if (domain.status === EVALUATION_STATUS.NOT_EVALUATED && !domain.note) {
377
+ hiddenGapCount++;
378
+ }
379
+ }
380
+
381
+ // falseCompleteCount: detect when domains pass but evidence gates fail
382
+ // This is computed by buildCompleteness from the evidenceComplete contract.
383
+
384
+ // baseIdentityValid: not applicable for canonical evaluation
385
+
386
+ // deterministic: canonical evaluator is deterministic by construction
387
+ const deterministic = true;
388
+
389
+ return {
390
+ domainCoverage,
391
+ claimEvidenceCoverage,
392
+ causalCoverage,
393
+ provenanceCoverage,
394
+ mutationCoverage: 0,
395
+ surfaceParity: 0,
396
+ hiddenGapCount,
397
+ falseCompleteCount: 0,
398
+ baseIdentityValid: false,
399
+ deterministic,
400
+ };
401
+ }
287
402
  /**
288
403
  * Evaluates the complete architecture state for a target project.
289
404
  *
@@ -435,6 +550,76 @@ export function evaluateArchitectureState({
435
550
  },
436
551
  });
437
552
 
553
+ // Derive evidence gates and build Evidence-Complete contract
554
+ const evaluationResult = {
555
+ completeness,
556
+ impact,
557
+ constraintImpact,
558
+ decisionImpact,
559
+ boundaryImpact,
560
+ findingsImpact,
561
+ debtImpact,
562
+ evolutionAlignment,
563
+ };
564
+ const evidenceGates = deriveEvidenceGates(evaluationResult);
565
+ const evidenceComplete = buildEvidenceComplete({
566
+ ...evidenceGates,
567
+ contractType: EVALUATION_CONTRACT_TYPES.CANONICAL,
568
+ });
569
+
570
+ // Rebuild completeness with evidenceComplete contract
571
+ const completenessWithEC = buildCompleteness({
572
+ structural: {
573
+ status: structuralStatus,
574
+ evaluated: true,
575
+ partial: false,
576
+ notEvaluated: false,
577
+ unsupported: false,
578
+ refused: false,
579
+ note: "",
580
+ },
581
+ constraint: {
582
+ status: constraintStatus,
583
+ evaluated: hasConfig && config.depConstraints !== undefined,
584
+ partial: false,
585
+ notEvaluated: !hasConfig || config.depConstraints === undefined,
586
+ unsupported: false,
587
+ refused: false,
588
+ note: "",
589
+ },
590
+ boundary: {
591
+ status: boundaryStatus,
592
+ evaluated: hasConfig,
593
+ partial: false,
594
+ notEvaluated: !hasConfig,
595
+ unsupported: false,
596
+ refused: false,
597
+ note: "",
598
+ },
599
+ decision: {
600
+ status: decisionStatus,
601
+ evaluated: hasConfig,
602
+ partial: false,
603
+ notEvaluated: !hasConfig,
604
+ unsupported: false,
605
+ refused: false,
606
+ note: "",
607
+ },
608
+ findings: governanceResult.findings,
609
+ debt: governanceResult.debt,
610
+ governance: governanceResult.domain,
611
+ evidence: {
612
+ status: evidenceStatus,
613
+ evaluated: evidenceEvaluated,
614
+ partial: false,
615
+ notEvaluated: false,
616
+ unsupported: false,
617
+ refused: false,
618
+ note: "",
619
+ },
620
+ evidenceComplete,
621
+ });
622
+
438
623
  return {
439
624
  project: projectName,
440
625
  impact,
@@ -444,8 +629,9 @@ export function evaluateArchitectureState({
444
629
  boundaryImpact,
445
630
  findingsImpact,
446
631
  debtImpact,
447
- completeness,
632
+ completeness: completenessWithEC,
448
633
  affectedProjects,
634
+ evidenceComplete,
449
635
  };
450
636
  }
451
637
 
@@ -473,7 +659,13 @@ export function buildEvolutionAlignment(projectName, impact, constraintImpact, r
473
659
  for (const entry of constraintImpact) {
474
660
  // Collect edge identities for each affected boundary
475
661
  for (const edge of entry.edges) {
476
- const edgeId = `${entry.project}>${edge.target}:${edge.type}`;
662
+ // The canonical spelling — imported, not restated, so this surface
663
+ // cannot drift from `EvolutionEvent.affected`'s vocabulary.
664
+ const edgeId = edgeEvolutionIdentity({
665
+ source: entry.project,
666
+ target: edge.target,
667
+ type: edge.type,
668
+ });
477
669
  if (!affectedBoundaries.includes(edgeId)) {
478
670
  affectedBoundaries.push(edgeId);
479
671
  }
@@ -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