@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
@@ -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
  }