@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
@@ -71,16 +71,20 @@
71
71
  * change lives in `./change-intent.mjs`'s validation (`parseChangeIntent`),
72
72
  * the same loud lane as every other malformed declaration.
73
73
  *
74
- * Refusals (each a throw exit 3 upstream): a manifest that fails shape or
75
- * reference validation, an unreadable/malformed/foreign-schema baseline,
76
- * incomplete baseline coverage, a provider mismatch, incomplete head
77
- * coverage, an unregistered-plugin graph over polyglot manifests, and a run
78
- * with no boundary law (constraints and the law fingerprint need one).
74
+ * Refusals: incomplete head coverage returns the structured no-verdict
75
+ * envelope `./coverage-verdict.mjs` builds (#608) — status "no-verdict",
76
+ * exit 3, a `coverage` block naming every file and site the run could not
77
+ * judge where a parser and `--output` can read it; the rest are throws →
78
+ * exit 3 upstream: a manifest that fails shape or reference validation, an
79
+ * unreadable/malformed/foreign-schema baseline, incomplete baseline coverage,
80
+ * a provider mismatch, an unregistered-plugin graph over polyglot manifests,
81
+ * and a run with no boundary law (constraints and the law fingerprint need
82
+ * one).
79
83
  *
80
84
  * This module computes and returns; `../../cli.mjs`'s `runChange` owns argv,
81
85
  * output destination and the process exit code (`./README.md`).
82
86
  */
83
- import { classifyDelta } from "./delta-classify.mjs";
87
+ import { classifyDelta, edgeEvolutionIdentity } from "./delta-classify.mjs";
84
88
  import { computeDiff } from "./diff.mjs";
85
89
  import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
86
90
  import {
@@ -91,10 +95,12 @@ import {
91
95
  } from "./change-intent.mjs";
92
96
  import {
93
97
  evidenceGraphToProjectGraph,
94
- refuseUnjudgeableHead,
98
+ refusePluginGapHead,
95
99
  sourceProjectAttributor,
96
100
  } from "./delta.mjs";
97
101
  import { providerMismatch, readEvidenceSnapshot } from "./delta-snapshot.mjs";
102
+ import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
103
+ import { blindSpotRows } from "../analysis/source-util.mjs";
98
104
  import { cyclicProjects } from "../governance/fitness-rules.mjs";
99
105
  import { fitnessVerdict } from "../governance/verdict.mjs";
100
106
  import { buildDecision } from "../report/evidence.mjs";
@@ -154,25 +160,16 @@ function violationFindingId(entry) {
154
160
  return `${entry.messageId}:${entry.sourceProject ?? "-"}:${entry.target}`;
155
161
  }
156
162
 
157
- /**
158
- * The identity string an observed edge carries into the event's `observed`
159
- * and `affected` — `(source, target, type)`, the triple `./diff.mjs`'s
160
- * `edgeIdentityKey` owns, spelled for a human reader (`>` separator, optional
161
- * type suffix). Two spellings of one triple never diverge because the triple
162
- * itself is the input.
163
- *
164
- * @param {{source: string, target: string, type?: string}} edge
165
- * @returns {string}
166
- */
167
- function edgeIdentityString(edge) {
168
- return `${edge.source}>${edge.target}${edge.type === undefined || edge.type === "" ? "" : `:${edge.type}`}`;
169
- }
170
-
171
163
  /**
172
164
  * The structural-diff facts in the event's `observed` shape (design §1),
173
165
  * mapped from `computeDiff`'s output and the metadata comparison — the same
174
166
  * lists the reconciliation already consumed, never recomputed.
175
167
  *
168
+ * The edges' identity strings come from `edgeEvolutionIdentity`
169
+ * (`./delta-classify.mjs`) — the ONE spelling the evolution events'
170
+ * `observed.edges`/`affected.boundaries` use. A local spelling here would be
171
+ * a second definition of "same edge", and two definitions drift.
172
+ *
176
173
  * @param {{addedProjects: object[], removedProjects: object[],
177
174
  * changedProjects: object[], addedEdges: object[], removedEdges: object[]}} structural
178
175
  * @param {{policyChanged: boolean|null, policyOneSided: boolean,
@@ -189,8 +186,8 @@ function observedFrom(structural, meta) {
189
186
  changed: structural.changedProjects.map((project) => project.name),
190
187
  };
191
188
  const edges = {
192
- added: structural.addedEdges.map(edgeIdentityString),
193
- removed: structural.removedEdges.map(edgeIdentityString),
189
+ added: structural.addedEdges.map(edgeEvolutionIdentity),
190
+ removed: structural.removedEdges.map(edgeEvolutionIdentity),
194
191
  };
195
192
  return {
196
193
  architectureChanged:
@@ -490,8 +487,14 @@ function judgeDeclaredConstraints(intent, io) {
490
487
  * change event's `debt` diff judges the intent over this run's base and
491
488
  * head graphs and would be untestable without it.
492
489
  * @returns {Promise<{status: "ok"|"findings"|"no-verdict",
493
- * changeIntent: object, coverage: object, report: {text: string, json: string}}>}
494
- * @throws {Error} on every refusal the module header lists.
490
+ * changeIntent?: object, coverage: object, report: {text: string, json: string}}>}
491
+ * `status: "no-verdict"` from the coverage refusal (#608) carries no
492
+ * `changeIntent` payload — the reconciliation was withheld, and the
493
+ * envelope's `coverage` block plus its `decision.reason` are the whole
494
+ * answer.
495
+ * @throws {Error} on every refusal the module header lists. Incomplete head
496
+ * coverage returns the structured no-verdict envelope instead of throwing
497
+ * (#608); the unregistered-plugin graph keeps its throw.
495
498
  */
496
499
  export async function changeCommand(
497
500
  baselinePath,
@@ -530,7 +533,23 @@ export async function changeCommand(
530
533
  );
531
534
  }
532
535
 
533
- refuseUnjudgeableHead(commandContext, "reconcile a change intent");
536
+ // The plugin-gap refusal stays a throw; the coverage refusal returns the one
537
+ // structured envelope `./coverage-verdict.mjs` builds (#608) — status
538
+ // "no-verdict", exit 3, a `coverage` block naming every file and site the
539
+ // run could not judge — instead of the throw `refuseUnjudgeableHead` used to
540
+ // carry here. Reconciling a declaration over a half-read tree would answer
541
+ // "undeclared" about architecture the run never observed, and that refusal
542
+ // belongs in-band, where a parser and `--output` can read it.
543
+ refusePluginGapHead(commandContext, "reconcile a change intent");
544
+ const completeness = coverageVerdict(commandContext);
545
+ if (!completeness.complete) {
546
+ return coverageRefusal({
547
+ command: "change",
548
+ commandContext,
549
+ what: "reconciling a change intent",
550
+ decision: true,
551
+ });
552
+ }
534
553
 
535
554
  const intent = await (readIntent ? readIntent(intentPath) : readChangeIntent(intentPath));
536
555
 
@@ -718,7 +737,12 @@ export async function changeCommand(
718
737
  analyzedFiles: analysis.analyzed,
719
738
  imports: analysis.imports.length,
720
739
  notAnalyzed: [],
721
- blindSpots: [],
740
+ // The positioned failures the run SAW — including the dynamic and external
741
+ // sites that never withhold a verdict. This used to be hardcoded `[]`
742
+ // (#609): a declared limit named nowhere is a disclosure gap, and
743
+ // `blindSpotRows` is the one mapping every other command's coverage block
744
+ // carries.
745
+ blindSpots: blindSpotRows(analysis.failures),
722
746
  notes,
723
747
  };
724
748
 
@@ -745,7 +769,14 @@ export async function changeCommand(
745
769
  reason: entry.reason,
746
770
  }));
747
771
  const evolution = classifyEvolution({
748
- observed,
772
+ // The raw triples, not the mapped strings `observed` carries (that object
773
+ // is the event's stored record): `classifyEvolution` owns the identity
774
+ // spelling and takes the triples, so `affected.boundaries` is mapped
775
+ // inside it under the one spelling rather than trusted from the caller.
776
+ observed: {
777
+ ...observed,
778
+ edges: { added: structural.addedEdges, removed: structural.removedEdges },
779
+ },
749
780
  ...(classification === null
750
781
  ? {}
751
782
  : {
@@ -12,7 +12,12 @@
12
12
  import { statSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
 
15
- import { fileFailure, isWholeFileFailure } from "../analysis/source-util.mjs";
15
+ import {
16
+ blindSpotRows,
17
+ fileFailure,
18
+ isWholeFileFailure,
19
+ unresolvableLiteralCount,
20
+ } from "../analysis/source-util.mjs";
16
21
  import { tsconfigPathsFacts } from "../analysis/typescript.mjs";
17
22
  import { stripTrailingSlashes } from "../path-util.mjs";
18
23
  import { suppressionCovers } from "../config.mjs";
@@ -188,7 +193,7 @@ function declaredEdgeManifest({ provider, graph }, sourceProject) {
188
193
  * intentUnresolved: number, intentUnresolvedDecisionRefs: number, fitnessFail: number,
189
194
  * fitnessUnknown: number, customRuleFail: number, customRuleUnknown: number,
190
195
  * customRuleEvidence: {rule: string, bytes: Uint8Array}[], customRulesDeclared: boolean,
191
- * analyzed: number, unchecked: number, waived?: number}>}
196
+ * analyzed: number, unchecked: number, blindSpots: number, waived?: number}>}
192
197
  */
193
198
  export async function check(options, { cwd, readGraph, listFiles = listTrackedFiles }) {
194
199
  const commandContext = resolveCommandContext(
@@ -196,7 +201,7 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
196
201
  { readGraph, listFiles },
197
202
  );
198
203
  const { root, graph, workspace, tracked } = commandContext;
199
- const { imports, exemptedFiles } = commandContext.analysis;
204
+ const { imports, exemptedFiles, unsupportedLanguageFiles } = commandContext.analysis;
200
205
  const failures = [...commandContext.analysis.failures];
201
206
  const analyzed = commandContext.analysis.analyzed;
202
207
 
@@ -622,6 +627,18 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
622
627
  failures.filter(isWholeFileFailure).map((failure) => failure.sourceFile),
623
628
  ).size;
624
629
 
630
+ // Sites the run saw but never judged (#595): the file was analyzed, this
631
+ // site was not, and a pass over it would claim a verdict the run does not
632
+ // hold. Counted beside `unchecked` from one classifier
633
+ // (`unresolvableLiteralCount`) so the exit, the report and the envelope all
634
+ // agree from one number. The two classes that withholds nothing are
635
+ // excluded from the count: the declared dynamic limit — a non-literal
636
+ // `import()` argument, unknowable in principle — and the external
637
+ // bare-package site, whose resolvability depends on an installed dependency
638
+ // tree a workspace legitimately may not have (`isDynamicSiteFailure` and
639
+ // `isExternalSiteFailure` in source-util.mjs own the class line).
640
+ const blindSpotCount = unresolvableLiteralCount(failures);
641
+
625
642
  // A row of the boundary law that covers nothing is a boundary that stopped
626
643
  // being enforced, and — unlike a missing `reason`, which only a human can
627
644
  // judge — it is machine-detectable. Two tables can be dead, and both are
@@ -838,8 +855,38 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
838
855
  },
839
856
  ]
840
857
  : []),
858
+ // Files a project owns that no analyzer claims (#601): skipped before
859
+ // reading, so they land in no failure list — without this row the run
860
+ // would present the judged surface as the whole story. Disclosure, not
861
+ // failure: the row names them and their extensions, and leaves
862
+ // `complete` to the surface that WAS judged. Sorted, because the row's
863
+ // bytes must not vary with `git ls-files`' order (E-F10).
864
+ ...(unsupportedLanguageFiles.length > 0
865
+ ? [{ kind: "unsupported-language", files: [...unsupportedLanguageFiles].sort() }]
866
+ : []),
841
867
  ];
842
868
 
869
+ // One verdict computation for both faces: the JSON envelope spreads it and
870
+ // the text report renders its `reasons` beside the headline — a second call
871
+ // here would let the two faces disagree about a run neither re-derives from
872
+ // the other (`../verdict.mjs`'s header owns that argument).
873
+ const verdict = verdictFor({
874
+ violations: violations.length,
875
+ declaredEdgeFindings,
876
+ goWorkDrift,
877
+ tsconfigPathsDead,
878
+ intentFindings,
879
+ intentUnresolved,
880
+ intentUnresolvedDecisionRefs: intentUnresolvedDecisionRefRows.length,
881
+ unchecked,
882
+ analyzed,
883
+ blindSpots: blindSpotCount,
884
+ fitnessFail,
885
+ fitnessUnknown,
886
+ customRuleFail,
887
+ customRuleUnknown,
888
+ });
889
+
843
890
  const report =
844
891
  options.format === "json"
845
892
  ? renderJson(
@@ -861,36 +908,20 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
861
908
  // `decision` — the four-state verb of the same counts — so the
862
909
  // envelope's `decision.verdict` and its `status` are built from
863
910
  // exactly one computation and can never disagree.
864
- ...verdictFor({
865
- violations: violations.length,
866
- declaredEdgeFindings,
867
- goWorkDrift,
868
- tsconfigPathsDead,
869
- intentFindings,
870
- intentUnresolved,
871
- intentUnresolvedDecisionRefs: intentUnresolvedDecisionRefRows.length,
872
- unchecked,
873
- fitnessFail,
874
- fitnessUnknown,
875
- customRuleFail,
876
- customRuleUnknown,
877
- }),
911
+ ...verdict,
878
912
  coverage: {
879
- complete: unchecked === 0,
913
+ // Complete means the run judged everything in scope: no
914
+ // whole-file failure (unchecked), no unresolvable site (#595),
915
+ // and at least one file analyzed (#599 — a run that judged
916
+ // nothing has no verdict to claim).
917
+ complete: unchecked === 0 && blindSpotCount === 0 && analyzed > 0,
880
918
  projects: Object.keys(graph.nodes).length,
881
919
  analyzedFiles: analyzed,
882
920
  imports: imports.length,
883
921
  notAnalyzed: failures
884
922
  .filter(isWholeFileFailure)
885
923
  .map(({ sourceFile, reason }) => ({ file: sourceFile, reason })),
886
- blindSpots: failures
887
- .filter((failure) => !isWholeFileFailure(failure))
888
- .map(({ sourceFile, line, column, reason }) => ({
889
- file: sourceFile,
890
- line,
891
- column,
892
- reason,
893
- })),
924
+ blindSpots: blindSpotRows(failures),
894
925
  notes,
895
926
  coverageGaps,
896
927
  },
@@ -1020,6 +1051,10 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
1020
1051
  // inspected" fact does (`../report/text.mjs`'s `formatReport`).
1021
1052
  notes,
1022
1053
  coverageGaps,
1054
+ // `formatReport` (text) renders these beside the headline; the SARIF
1055
+ // face files the same facts as warning notifications. Both faces of
1056
+ // one run name the same clauses, in the same order.
1057
+ coverageIncomplete: verdict.reasons,
1023
1058
  // `formatReport` (text) reads this to annotate an unresolved
1024
1059
  // decisionRef inline; `formatSarif` files each one as a warning
1025
1060
  // notification. Both faces of one run name the same citations, in
@@ -1052,5 +1087,9 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
1052
1087
  customRulesDeclared: customRules !== null,
1053
1088
  analyzed,
1054
1089
  unchecked,
1090
+ // The site-level count `verdictFor` needs: `cli.mjs` passes this whole
1091
+ // return through `verdictFor` for the process's exit code, so a count the
1092
+ // envelope saw but the exit code did not would let the two disagree.
1093
+ blindSpots: blindSpotCount,
1055
1094
  };
1056
1095
  }
@@ -45,6 +45,72 @@ export const NOT_EVALUATED = EVALUATION_STATUS.NOT_EVALUATED;
45
45
  export const UNSUPPORTED = EVALUATION_STATUS.UNSUPPORTED;
46
46
  export const REFUSED = EVALUATION_STATUS.REFUSED;
47
47
 
48
+ // ---------------------------------------------------------------------------
49
+ // Evaluation contract types — which gates are required per evaluation type
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * The evaluation contract types that determine which Evidence-Complete gates
54
+ * are required for `overallComplete`.
55
+ *
56
+ * - `canonical`: Standard architecture evaluation (no mutations, no scenario).
57
+ * Gates NOT required: mutationCoverage, surfaceParity, baseIdentityValid.
58
+ * - `scenario`: Hypothetical scenario evaluation. ALL gates required.
59
+ *
60
+ * @type {Readonly<{CANONICAL: string, SCENARIO: string}>}
61
+ */
62
+ export const EVALUATION_CONTRACT_TYPES = Object.freeze({
63
+ CANONICAL: "canonical",
64
+ SCENARIO: "scenario",
65
+ });
66
+
67
+ /**
68
+ * Which Evidence-Complete gates are required for each contract type.
69
+ * A gate not listed here is still tracked and reported but does NOT block
70
+ * `overallComplete` — it is explicitly not applicable for that evaluation type.
71
+ *
72
+ * @type {Readonly<Object<string, ReadonlySet<string>>>}
73
+ */
74
+ export const REQUIRED_GATES_FOR_CONTRACT = Object.freeze({
75
+ [EVALUATION_CONTRACT_TYPES.CANONICAL]: Object.freeze(
76
+ new Set([
77
+ "domainCoverage",
78
+ "claimEvidenceCoverage",
79
+ "causalCoverage",
80
+ "provenanceCoverage",
81
+ "hiddenGapCount",
82
+ "falseCompleteCount",
83
+ "deterministic",
84
+ ]),
85
+ ),
86
+ [EVALUATION_CONTRACT_TYPES.SCENARIO]: Object.freeze(
87
+ new Set([
88
+ "domainCoverage",
89
+ "claimEvidenceCoverage",
90
+ "causalCoverage",
91
+ "provenanceCoverage",
92
+ "mutationCoverage",
93
+ "surfaceParity",
94
+ "hiddenGapCount",
95
+ "falseCompleteCount",
96
+ "baseIdentityValid",
97
+ "deterministic",
98
+ ]),
99
+ ),
100
+ });
101
+
102
+ /**
103
+ * Returns true when the given gate key is required for the given contract type.
104
+ *
105
+ * @param {string} gateKey The gate key (e.g. "domainCoverage").
106
+ * @param {string} [contractType] The evaluation contract type.
107
+ * Defaults to SCENARIO (most restrictive).
108
+ * @returns {boolean}
109
+ */
110
+ export function isGateRequired(gateKey, contractType = EVALUATION_CONTRACT_TYPES.SCENARIO) {
111
+ const required = REQUIRED_GATES_FOR_CONTRACT[contractType];
112
+ return required ? required.has(gateKey) : true;
113
+ }
48
114
  // ---------------------------------------------------------------------------
49
115
  // Evidence-Complete gate names — the canonical roster
50
116
  // ---------------------------------------------------------------------------
@@ -69,6 +135,7 @@ export const EVIDENCE_COMPLETE_GATES = Object.freeze([
69
135
 
70
136
  /**
71
137
  * @typedef {object} EvidenceCompleteContract
138
+ * @property {string} contractType The evaluation contract type (canonical | scenario).
72
139
  * @property {number} domainCoverage Ratio of evaluated required domains to required domains (0-1).
73
140
  * @property {number} claimEvidenceCoverage Ratio of claims with valid evidence to material claims (0-1).
74
141
  * @property {number} causalCoverage Ratio of consequences with complete causal chain to material consequences (0-1).
@@ -82,16 +149,16 @@ export const EVIDENCE_COMPLETE_GATES = Object.freeze([
82
149
  * @property {string} overallStatus Overall Evidence-Complete status: "complete" | "incomplete" | "not_evaluated".
83
150
  * @property {boolean} overallComplete True only when ALL required gates pass.
84
151
  * @property {object} gates Individual gate statuses, keyed by gate name.
85
- * @property {object} gates.domainCoverage Gate status.
86
- * @property {object} gates.claimEvidenceCoverage Gate status.
87
- * @property {object} gates.causalCoverage Gate status.
88
- * @property {object} gates.provenanceCoverage Gate status.
89
- * @property {object} gates.mutationCoverage Gate status.
90
- * @property {object} gates.surfaceParity Gate status.
91
- * @property {object} gates.hiddenGapCount Gate status.
92
- * @property {object} gates.falseCompleteCount Gate status.
93
- * @property {object} gates.baseIdentityValid Gate status.
94
- * @property {object} gates.deterministic Gate status.
152
+ * @property {object} gates.domainCoverage Gate status with {value, pass, required}.
153
+ * @property {object} gates.claimEvidenceCoverage Gate status with {value, pass, required}.
154
+ * @property {object} gates.causalCoverage Gate status with {value, pass, required}.
155
+ * @property {object} gates.provenanceCoverage Gate status with {value, pass, required}.
156
+ * @property {object} gates.mutationCoverage Gate status with {value, pass, required}.
157
+ * @property {object} gates.surfaceParity Gate status with {value, pass, required}.
158
+ * @property {object} gates.hiddenGapCount Gate status with {value, pass, required}.
159
+ * @property {object} gates.falseCompleteCount Gate status with {value, pass, required}.
160
+ * @property {object} gates.baseIdentityValid Gate status with {value, pass, required}.
161
+ * @property {object} gates.deterministic Gate status with {value, pass, required}.
95
162
  */
96
163
 
97
164
  // ---------------------------------------------------------------------------
@@ -306,8 +373,10 @@ export function buildCompleteness({
306
373
  const statuses = Object.values(domains).map((d) => d.status);
307
374
  const domainOverallComplete = statuses.every((s) => s === EVALUATION_STATUS.EVALUATED);
308
375
 
309
- // If an Evidence-Complete contract is provided, enforce it as a gate
310
- let ecComplete = true;
376
+ // If an Evidence-Complete contract is provided, enforce it as a gate.
377
+ // When no contract is provided, overallComplete MUST be false — the
378
+ // evaluation has not proven its evidence gates.
379
+ let ecComplete = false;
311
380
  let falseCompleteCount = 0;
312
381
  if (evidenceComplete) {
313
382
  ecComplete = evidenceComplete.overallComplete;
@@ -346,6 +415,10 @@ export function buildCompleteness({
346
415
  /**
347
416
  * Builds an Evidence-Complete contract from the individual gate values.
348
417
  *
418
+ * Only gates required for the given `contractType` are considered for
419
+ * `overallComplete`. Gates not required are still tracked and reported
420
+ * but do NOT block completeness.
421
+ *
349
422
  * @param {object} gates
350
423
  * @param {number} [gates.domainCoverage] Ratio (0-1).
351
424
  * @param {number} [gates.claimEvidenceCoverage] Ratio (0-1).
@@ -357,6 +430,8 @@ export function buildCompleteness({
357
430
  * @param {number} [gates.falseCompleteCount] Count (0 = pass).
358
431
  * @param {boolean} [gates.baseIdentityValid] Boolean (true = pass).
359
432
  * @param {boolean} [gates.deterministic] Boolean (true = pass).
433
+ * @param {string} [gates.contractType] Evaluation contract type for gate
434
+ * requirements (defaults to SCENARIO, the most restrictive).
360
435
  * @returns {EvidenceCompleteContract}
361
436
  */
362
437
  export function buildEvidenceComplete({
@@ -370,8 +445,9 @@ export function buildEvidenceComplete({
370
445
  falseCompleteCount = -1,
371
446
  baseIdentityValid = false,
372
447
  deterministic = false,
448
+ contractType = EVALUATION_CONTRACT_TYPES.SCENARIO,
373
449
  } = {}) {
374
- const gates = {
450
+ const rawGates = {
375
451
  domainCoverage: { value: domainCoverage, pass: domainCoverage === 1 },
376
452
  claimEvidenceCoverage: { value: claimEvidenceCoverage, pass: claimEvidenceCoverage === 1 },
377
453
  causalCoverage: { value: causalCoverage, pass: causalCoverage === 1 },
@@ -384,9 +460,21 @@ export function buildEvidenceComplete({
384
460
  deterministic: { value: deterministic, pass: deterministic === true },
385
461
  };
386
462
 
387
- const allPass = Object.values(gates).every((g) => g.pass);
463
+ // Only required gates block overallComplete
464
+ const allRequiredPass = Object.keys(rawGates)
465
+ .filter((key) => isGateRequired(key, contractType))
466
+ .every((key) => rawGates[key].pass);
467
+ // Annotate each gate with whether it is required for this contract type
468
+ /** @type {any} */
469
+ const gates = Object.fromEntries(
470
+ Object.entries(rawGates).map(([key, gate]) => [
471
+ key,
472
+ { ...gate, required: isGateRequired(key, contractType) },
473
+ ]),
474
+ );
388
475
 
389
476
  return {
477
+ contractType,
390
478
  domainCoverage,
391
479
  claimEvidenceCoverage,
392
480
  causalCoverage,
@@ -397,8 +485,8 @@ export function buildEvidenceComplete({
397
485
  falseCompleteCount,
398
486
  baseIdentityValid,
399
487
  deterministic,
400
- overallStatus: allPass ? "complete" : "incomplete",
401
- overallComplete: allPass,
488
+ overallStatus: allRequiredPass ? "complete" : "incomplete",
489
+ overallComplete: allRequiredPass,
402
490
  gates,
403
491
  };
404
492
  }
@@ -414,16 +502,22 @@ export function buildEvidenceComplete({
414
502
  export function assertEvidenceComplete(ec) {
415
503
  if (ec.overallComplete) return;
416
504
 
505
+ const contractType = ec.contractType || EVALUATION_CONTRACT_TYPES.SCENARIO;
417
506
  const failures = [];
418
507
  for (const gate of EVIDENCE_COMPLETE_GATES) {
419
508
  const g = ec.gates[gate.key];
509
+ // Skip non-required gates for this contract type
510
+ if (g.required === false) continue;
420
511
  if (!g.pass) {
421
512
  failures.push(`${gate.label}: ${JSON.stringify(g.value)} (expected pass)`);
422
513
  }
423
514
  }
424
515
 
516
+ if (failures.length === 0) return;
517
+
425
518
  throw new Error(
426
519
  `Evidence-Complete contract not satisfied.\n` +
520
+ ` Contract type: ${contractType}\n` +
427
521
  ` Overall: ${ec.overallStatus}\n` +
428
522
  ` Failed gates:\n ${failures.join("\n ")}`,
429
523
  );
@@ -571,15 +665,28 @@ export function buildScenarioCompleteness({
571
665
  evidenceComplete,
572
666
  });
573
667
 
574
- // Recompute overall with scenario domains included
668
+ // Recompute overall: base domains + EC gate + scenario domains.
669
+ // baseResult.overallComplete already includes the evidenceComplete gate,
670
+ // so reusing it prevents the silent-complete defect.
671
+ const overallComplete =
672
+ baseResult.overallComplete &&
673
+ changesDomain.status === EVALUATION_STATUS.EVALUATED &&
674
+ baseDomain.status === EVALUATION_STATUS.EVALUATED &&
675
+ mutationDomain.status === EVALUATION_STATUS.EVALUATED;
575
676
  const allStatuses = [
576
677
  ...Object.values(baseResult.domains).map((d) => d.status),
577
678
  changesDomain.status,
578
679
  baseDomain.status,
579
680
  mutationDomain.status,
580
681
  ];
581
- const overallComplete = allStatuses.every((s) => s === EVALUATION_STATUS.EVALUATED);
582
- const overallStatus = worstStatus(...allStatuses);
682
+ // Include EC gate status in overall status: when evidenceComplete is
683
+ // provided and fails, overall status must reflect that.
684
+ const ecStatus = evidenceComplete
685
+ ? evidenceComplete.overallComplete
686
+ ? EVALUATION_STATUS.EVALUATED
687
+ : EVALUATION_STATUS.NOT_EVALUATED
688
+ : EVALUATION_STATUS.NOT_EVALUATED;
689
+ const overallStatus = worstStatus(...allStatuses, ecStatus);
583
690
 
584
691
  return {
585
692
  domains: baseResult.domains,
@@ -32,7 +32,11 @@
32
32
  * than explaining constraints from a graph whose edges silently under-represent
33
33
  * the real architecture.
34
34
  */
35
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
35
+ import {
36
+ blindSpotRows,
37
+ isWholeFileFailure,
38
+ unresolvableLiteralCount,
39
+ } from "../analysis/source-util.mjs";
36
40
  import { UsageError } from "../errors.mjs";
37
41
  import { judgeEdge } from "./edge-constraints.mjs";
38
42
  import { findConstraintsFor } from "../rules/tags.mjs";
@@ -150,7 +154,13 @@ export function contextCommand(projectName, commandContext, config) {
150
154
  .filter(isWholeFileFailure)
151
155
  .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
152
156
 
153
- const complete = notAnalyzed.length === 0;
157
+ // The same completeness `check` claims (#595, #599): unjudged sites and
158
+ // a zero-analyzed run defeat it here exactly as they do there, so a
159
+ // context report cannot look complete over a tree the run could not
160
+ // fully read.
161
+ const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
162
+ const complete =
163
+ notAnalyzed.length === 0 && blindSpotCount === 0 && commandContext.analysis.analyzed > 0;
154
164
  const status = complete ? "ok" : "no-verdict";
155
165
  const exitCode = complete ? 0 : 3;
156
166
 
@@ -160,9 +170,7 @@ export function contextCommand(projectName, commandContext, config) {
160
170
  analyzedFiles: commandContext.analysis.analyzed,
161
171
  imports: commandContext.analysis.imports.length,
162
172
  notAnalyzed,
163
- blindSpots: commandContext.analysis.failures
164
- .filter((f) => !isWholeFileFailure(f))
165
- .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
173
+ blindSpots: blindSpotRows(commandContext.analysis.failures),
166
174
  notes: [
167
175
  "per-edge violations cover only depConstraints (3 of 15 violation types). " +
168
176
  "A dependency with no violations here may still violate npm-ban, circular-dependency, " +
@@ -214,11 +214,14 @@ export const WORKSPACE_MARKERS = [
214
214
  * `createWorkspace` returns.
215
215
  * @property {string[]} tracked Every tracked file, from `listFiles(root)`.
216
216
  * @property {{imports: object[], failures: object[], analyzed: number,
217
- * analyzedFiles: string[], exemptedFiles: string[]}} analysis The
217
+ * analyzedFiles: string[], exemptedFiles: string[],
218
+ * unsupportedLanguageFiles: string[]}} analysis The
218
219
  * whole-tree-then-scoped (native) or scoped-then-analyzed (nx/moon) result —
219
220
  * see the branches below for why the order is not the same on all three.
220
221
  * `exemptedFiles` is always `[]` on nx/moon: `coverage.exempt` is a
221
- * native-only `archkeep.json` key.
222
+ * native-only `archkeep.json` key. `unsupportedLanguageFiles` is scoped to
223
+ * the selected files on every branch (#601): a gap row names only files the
224
+ * selected scope could have judged.
222
225
  * @property {{boundaryConfig: string|object, tsConfig: object|undefined,
223
226
  * boundaryConfigDeclared: boolean, profiles?: string, inline?: boolean}} options
224
227
  * What this workspace names its boundary law, its shared tsconfig, and —
@@ -549,6 +552,7 @@ export function resolveCommandContext(
549
552
  let failures;
550
553
  let analyzed;
551
554
  let analyzedFiles;
555
+ let unsupportedLanguageFiles;
552
556
  let pluginGap;
553
557
  let unownedGap;
554
558
  let unclaimedGap;
@@ -664,6 +668,12 @@ export function resolveCommandContext(
664
668
  ...jvmIndexFailures(workspace),
665
669
  ]);
666
670
  analyzedFiles = wholeTreeAnalysis.analyzedFiles.filter((file) => selectedFiles.has(file));
671
+ // Scoped exactly like `analyzedFiles` above (#601): a gap row must name
672
+ // only files the selected scope could have judged, and leaving this
673
+ // undefined here would crash `check`'s gap assembly instead of reporting.
674
+ unsupportedLanguageFiles = wholeTreeAnalysis.unsupportedLanguageFiles.filter((file) =>
675
+ selectedFiles.has(file),
676
+ );
667
677
  analyzed = analyzedFiles.length;
668
678
  // Unaffected by `paths`: an exempted file is by definition unowned by any
669
679
  // project, so it was never a candidate for `owned`/`selected` in the
@@ -787,6 +797,13 @@ export function resolveCommandContext(
787
797
  ]);
788
798
  unclaimedGap = { files: unclaimedFiles };
789
799
  analyzedFiles = wholeTreeAnalysis.analyzedFiles.filter((file) => selectedFiles.has(file));
800
+ // Scoped exactly like the native branch's own filter above (#601): a gap
801
+ // row must name only files the selected scope could have judged, and
802
+ // leaving this undefined here would crash `check`'s gap assembly instead
803
+ // of reporting.
804
+ unsupportedLanguageFiles = wholeTreeAnalysis.unsupportedLanguageFiles.filter((file) =>
805
+ selectedFiles.has(file),
806
+ );
790
807
  analyzed = analyzedFiles.length;
791
808
  // `coverage.exempt` is a native-only key (`../providers/native/coverage.mjs`'s
792
809
  // header: "Nx has no equivalent question") — Moon carries no such list.
@@ -835,7 +852,10 @@ export function resolveCommandContext(
835
852
  paths,
836
853
  { root, cwd, tracked },
837
854
  );
838
- ({ imports, failures, analyzed, analyzedFiles } = analyzeWorkspace(workspace, selected));
855
+ ({ imports, failures, analyzed, analyzedFiles, unsupportedLanguageFiles } = analyzeWorkspace(
856
+ workspace,
857
+ selected,
858
+ ));
839
859
  // Unclaimed analyzable files — `unclaimedFileFailures` above — join
840
860
  // unconditionally, the same workspace-wide posture native's own
841
861
  // `discovered.failures` has, so a scoped `check <path>` cannot hide an
@@ -876,7 +896,14 @@ export function resolveCommandContext(
876
896
  graph,
877
897
  workspace,
878
898
  tracked,
879
- analysis: { imports, failures, analyzed, analyzedFiles, exemptedFiles },
899
+ analysis: {
900
+ imports,
901
+ failures,
902
+ analyzed,
903
+ analyzedFiles,
904
+ exemptedFiles,
905
+ unsupportedLanguageFiles,
906
+ },
880
907
  options,
881
908
  pluginGap,
882
909
  unownedGap,