@ecoma-io/archkeep 0.23.0 → 0.24.1

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 (83) hide show
  1. package/package.json +1 -1
  2. package/src/analysis/csharp.mjs +3 -1
  3. package/src/analysis/dotnet/csproj.mjs +5 -1
  4. package/src/analysis/dotnet/namespaces.mjs +1 -0
  5. package/src/analysis/go.mjs +6 -0
  6. package/src/analysis/java.mjs +2 -0
  7. package/src/analysis/jvm/gradle.mjs +3 -1
  8. package/src/analysis/jvm/maven.mjs +6 -1
  9. package/src/analysis/jvm/packages.mjs +1 -0
  10. package/src/analysis/jvm/resolve.mjs +4 -2
  11. package/src/analysis/kotlin.mjs +2 -0
  12. package/src/analysis/markdown.mjs +341 -0
  13. package/src/analysis/python.mjs +8 -0
  14. package/src/analysis/rust.mjs +5 -1
  15. package/src/analysis/source-util.mjs +6 -5
  16. package/src/analysis/typescript.mjs +148 -0
  17. package/src/architecture-intent/model.mjs +13 -8
  18. package/src/architecture-intent/selectors.mjs +2 -1
  19. package/src/commands/change-intent.mjs +10 -9
  20. package/src/commands/change.mjs +17 -1
  21. package/src/commands/check.mjs +177 -17
  22. package/src/commands/completeness.mjs +7 -6
  23. package/src/commands/context-command.mjs +12 -20
  24. package/src/commands/coverage-acceptance.mjs +46 -0
  25. package/src/commands/coverage-verdict.mjs +12 -2
  26. package/src/commands/custom-rules.mjs +1 -0
  27. package/src/commands/delta-classify.mjs +3 -0
  28. package/src/commands/delta-snapshot.mjs +3 -6
  29. package/src/commands/delta.mjs +35 -21
  30. package/src/commands/drift.mjs +1 -1
  31. package/src/commands/evaluation-primitives.mjs +4 -4
  32. package/src/commands/evolution.mjs +2 -0
  33. package/src/commands/explain.mjs +19 -20
  34. package/src/commands/graph.mjs +44 -15
  35. package/src/commands/health.mjs +4 -0
  36. package/src/commands/history.mjs +2 -0
  37. package/src/commands/plan-context-command.mjs +9 -2
  38. package/src/commands/policy.mjs +8 -5
  39. package/src/commands/provenance.mjs +8 -2
  40. package/src/commands/scenario-evaluation.mjs +1 -1
  41. package/src/commands/trajectory.mjs +2 -1
  42. package/src/config.mjs +170 -2
  43. package/src/custom-rules/host.mjs +3 -3
  44. package/src/errors.mjs +23 -1
  45. package/src/eslint-config.mjs +3 -5
  46. package/src/fixtures/evolution-lifecycle/workspace.mjs +15 -4
  47. package/src/go-work.mjs +1 -1
  48. package/src/governance/adr-registry.mjs +6 -2
  49. package/src/governance/debt-ledger.mjs +1 -1
  50. package/src/governance/decision-fitness.mjs +2 -0
  51. package/src/governance/decision-graph.mjs +1 -0
  52. package/src/governance/discovery-proposal.mjs +8 -2
  53. package/src/governance/evolution-event.mjs +42 -0
  54. package/src/governance/evolution-store.mjs +3 -2
  55. package/src/governance/fitness-registry.mjs +2 -3
  56. package/src/governance/preset-fingerprints.json +14 -14
  57. package/src/governance/profile-registry.mjs +22 -3
  58. package/src/governance/provenance-record.mjs +4 -1
  59. package/src/governance/reconcile-score.mjs +4 -0
  60. package/src/governance/row-schema.mjs +1 -3
  61. package/src/governance/verdict.mjs +1 -0
  62. package/src/governance/waiver.mjs +1 -0
  63. package/src/intent/intent-manifest.json +6 -6
  64. package/src/intent/mask-non-code.mjs +1 -0
  65. package/src/lsp/diagnostics.mjs +3 -2
  66. package/src/lsp/protocol.mjs +2 -1
  67. package/src/lsp/server.mjs +3 -0
  68. package/src/lsp/workspace-index.mjs +3 -1
  69. package/src/providers/moon.mjs +5 -5
  70. package/src/providers/native/differential.fixtures.mjs +29 -11
  71. package/src/providers/native/index.mjs +2 -1
  72. package/src/providers/native/model.mjs +4 -0
  73. package/src/report/envelope-shape.mjs +2 -0
  74. package/src/report/sarif.mjs +21 -8
  75. package/src/report/snapshot-text.mjs +3 -3
  76. package/src/report/text.mjs +10 -2
  77. package/src/rules/index.mjs +30 -0
  78. package/src/rules/match.mjs +7 -5
  79. package/src/rules/specifiers.mjs +2 -0
  80. package/src/rules/tags.mjs +3 -2
  81. package/src/rules/topology.mjs +6 -1
  82. package/src/verdict.mjs +33 -2
  83. package/src/workspace.mjs +1 -0
@@ -94,6 +94,7 @@ function formatEdge(violation) {
94
94
  * @returns {string}
95
95
  */
96
96
  export function formatViolation(violation, unresolvedDecisionRefs) {
97
+ // used by its own test
97
98
  const message = violation.message
98
99
  .split("\n")
99
100
  .map((line) => (line === "" ? "" : `${CONTINUED}${line}`))
@@ -159,6 +160,7 @@ const formatFailure = (failure) =>
159
160
  * @returns {string}
160
161
  */
161
162
  export function formatFailures(failures) {
163
+ // used by its own test
162
164
  if (failures.length === 0) return "";
163
165
  const unchecked = failures.filter(isWholeFileFailure);
164
166
  const blind = failures.filter((failure) => !isWholeFileFailure(failure));
@@ -236,6 +238,7 @@ export function formatFailures(failures) {
236
238
  * @returns {string} Empty exactly when there is no go.work verdict to render.
237
239
  */
238
240
  export function formatGoWork(goWork) {
241
+ // used by its own test
239
242
  if (goWork == null) return "";
240
243
  const { findings, moduleProjects } = goWork;
241
244
  const modules = `${moduleProjects} Go module project${moduleProjects === 1 ? "" : "s"}`;
@@ -291,6 +294,7 @@ export function formatGoWork(goWork) {
291
294
  * @returns {string} Empty exactly when there is no declared-edge verdict to render.
292
295
  */
293
296
  export function formatDeclaredEdges(declaredEdges, unresolvedDecisionRefs) {
297
+ // used by its own test
294
298
  if (declaredEdges == null) return "";
295
299
  const { findings, judged, declaration = "implicitDependencies" } = declaredEdges;
296
300
  const label = `${judged} implicit edge${judged === 1 ? "" : "s"} judged`;
@@ -345,6 +349,7 @@ export function formatDeclaredEdges(declaredEdges, unresolvedDecisionRefs) {
345
349
  * @returns {string} Empty exactly when there is no paths verdict to render.
346
350
  */
347
351
  export function formatTsconfigPaths(tsconfigPaths) {
352
+ // used by its own test
348
353
  if (tsconfigPaths == null) return "";
349
354
  const { tsConfig, findings, aliases, unjudged } = tsconfigPaths;
350
355
  const judged =
@@ -393,7 +398,7 @@ export function formatTsconfigPaths(tsconfigPaths) {
393
398
  * unresolvedDecisionRefs?: {kind: string, decisionRef: string}[]}|null|undefined} intent
394
399
  * @returns {string} Empty exactly when there is no intent verdict to render.
395
400
  */
396
- export function formatIntentSection(intent) {
401
+ function formatIntentSection(intent) {
397
402
  if (intent == null) return "";
398
403
  const { verdict, findings, unresolved, boundaries, unresolvedDecisionRefs = [] } = intent;
399
404
  const count = boundaries.length;
@@ -542,6 +547,7 @@ function customFindingSite(finding) {
542
547
  * @returns {string} Empty exactly when the policy declared no custom rules.
543
548
  */
544
549
  export function formatCustomRulesSection(customRules) {
550
+ // used by its own test
545
551
  if (customRules == null || customRules.decisions.length === 0) return "";
546
552
  const { decisions, overall } = customRules;
547
553
  const entries = decisions.map((decision) => {
@@ -596,6 +602,7 @@ export function formatCustomRulesSection(customRules) {
596
602
  * @returns {string} Empty exactly when there is no coverage gap to render.
597
603
  */
598
604
  export function formatCoverageGaps(coverageGaps) {
605
+ // used by its own test
599
606
  if (coverageGaps.length === 0) return "";
600
607
  return coverageGaps.map(formatCoverageGap).join("\n");
601
608
  }
@@ -792,6 +799,7 @@ function formatUntrackedFilesGap(gap) {
792
799
  * @returns {string} Empty exactly when no policy identity was supplied.
793
800
  */
794
801
  export function formatPolicy(policy) {
802
+ // used by its own test
795
803
  if (policy == null) return "";
796
804
  const { profile, source, fingerprint } = policy;
797
805
  const law = profile === null ? source : `profile "${profile}" from ${source}`;
@@ -812,7 +820,7 @@ export function formatPolicy(policy) {
812
820
  * @param {Set<string>} [unresolvedDecisionRefs] Forwarded to `formatViolation`.
813
821
  * @returns {string}
814
822
  */
815
- export function formatAcceptedViolations(waived, unresolvedDecisionRefs) {
823
+ function formatAcceptedViolations(waived, unresolvedDecisionRefs) {
816
824
  const rows = waived.map((violation) => {
817
825
  const waiver = violation.waivedBy;
818
826
  return [
@@ -815,6 +815,36 @@ function annotatedByTable(suppressions, violation, now) {
815
815
  return violation;
816
816
  }
817
817
 
818
+ /**
819
+ * The suppression table applied to verdicts that did not come from
820
+ * `evaluateRun`'s site walk — suppressing rows remove, active waivers mark
821
+ * `waivedBy`, expired ones re-assert with `evidence`, exactly as the site walk
822
+ * applies them, because a verdict must ride the same table whichever walk
823
+ * produced it.
824
+ *
825
+ * The one caller is `./commands/check.mjs`'s markdown document track: its
826
+ * edges are judged per edge through `./edge-constraints.mjs`'s `judgeEdge` —
827
+ * they have no import site for `candidateGroupsFor` to walk, so there is no
828
+ * group chain, only a flat list — but a suppression row that names a document
829
+ * must silence the same verdict here it would silence on an import site, and
830
+ * a waiver over one must annotate it the same way. Unlike the site walk there
831
+ * is no second candidate group to fall through to: an edge's verdicts are all
832
+ * reported together, so a row that removes some leaves the rest standing
833
+ * rather than promoting anything.
834
+ *
835
+ * @param {object[]} suppressions The validated `boundarySuppressions` table.
836
+ * @param {object[]} violations The unfiltered verdicts, in walk order.
837
+ * @param {string} [now] Reference instant for waiver expiry; defaults to the
838
+ * shared governance clock, as `evaluateRun` does.
839
+ * @returns {object[]} The survivors, in input order, annotations applied.
840
+ */
841
+ export function applySuppressionTable(suppressions, violations, now = referenceTime()) {
842
+ if (suppressions.length === 0) return violations;
843
+ return violations
844
+ .filter((violation) => !removedByTable(suppressions, violation, now))
845
+ .map((violation) => annotatedByTable(suppressions, violation, now));
846
+ }
847
+
818
848
  /**
819
849
  * One run of the engine over every import site: the judged verdict per site
820
850
  * plus the raw superset it was picked from.
@@ -85,7 +85,7 @@ import { posix } from "node:path";
85
85
  * costs is bounded at the other end, by `MAX_SPECIFIER_LENGTH` below, because
86
86
  * a degree-2 pattern is still quadratic in a subject nothing else limits.
87
87
  */
88
- export const MAX_RESPLIT_REPETITIONS = 2;
88
+ const MAX_RESPLIT_REPETITIONS = 2;
89
89
 
90
90
  /**
91
91
  * The most repetitions a SINGLE-DELIMITER chain may carry — the one shape
@@ -114,7 +114,7 @@ export const MAX_RESPLIT_REPETITIONS = 2;
114
114
  * path pattern naming every segment of `libs/<area>/<name>/<entry>` needs
115
115
  * four.
116
116
  */
117
- export const MAX_DELIMITED_SEGMENTS = 8;
117
+ export const MAX_DELIMITED_SEGMENTS = 8; // used by its own test
118
118
 
119
119
  /**
120
120
  * The longest import specifier this engine will match a consumer-written
@@ -148,7 +148,7 @@ export const MAX_DELIMITED_SEGMENTS = 8;
148
148
  * 1024 `b`s. That is the number to compare a change here against; before this
149
149
  * bound and the refusals above, the same sweep found 59 SECONDS.
150
150
  */
151
- export const MAX_SPECIFIER_LENGTH = 1024;
151
+ export const MAX_SPECIFIER_LENGTH = 1024; // used by its own test
152
152
 
153
153
  /**
154
154
  * The deepest group nesting this model reads. Past it a pattern is refused
@@ -1123,6 +1123,7 @@ function tradesWithNeighbour(elements, index, last) {
1123
1123
  * @returns {string|null}
1124
1124
  */
1125
1125
  export function regexComplexityError(source) {
1126
+ // used by its own test
1126
1127
  const { root, tooDeep } = parsePattern(source);
1127
1128
  if (tooDeep) {
1128
1129
  return (
@@ -1174,7 +1175,7 @@ function resplitReason(degree) {
1174
1175
  * @param {string} specifier
1175
1176
  * @returns {string|null}
1176
1177
  */
1177
- export function specifierLengthError(specifier) {
1178
+ function specifierLengthError(specifier) {
1178
1179
  if (specifier.length <= MAX_SPECIFIER_LENGTH) return null;
1179
1180
  return (
1180
1181
  `is ${specifier.length} characters, past the ${MAX_SPECIFIER_LENGTH} this engine will match ` +
@@ -1538,7 +1539,7 @@ export function findMatchingProjects(patterns, nodes) {
1538
1539
  * anything a real suppression, exemption or project rule plausibly needs to
1539
1540
  * name from ONE glob string.
1540
1541
  */
1541
- export const MAX_GLOB_EXPANSIONS = 512;
1542
+ export const MAX_GLOB_EXPANSIONS = 512; // used by its own test
1542
1543
 
1543
1544
  /**
1544
1545
  * A brace group's content matches one of these two shapes instead of a
@@ -1620,6 +1621,7 @@ function rangeCardinality(content) {
1620
1621
  * certain to exceed `cap`.
1621
1622
  */
1622
1623
  export function braceExpansionCount(pattern, cap) {
1624
+ // used by its own test
1623
1625
  const limit = cap + 1;
1624
1626
  /** @type {{alternatives: number, branchProduct: number, start: number}[]} */
1625
1627
  const frames = [{ alternatives: 0, branchProduct: 1, start: 0 }];
@@ -48,6 +48,7 @@ export const DEFAULT_WORKSPACE_LAYOUT = Object.freeze({ libsDir: "libs", appsDir
48
48
 
49
49
  /** `./x` or `../x` — upstream's `isRelative`, from `runtime-lint-utils`. */
50
50
  export function isRelative(s) {
51
+ // used by its own test
51
52
  return s.startsWith("./") || s.startsWith("../");
52
53
  }
53
54
 
@@ -191,6 +192,7 @@ export function getTargetProjectBasedOnRelativeImport(imp, sourceFile, projectRo
191
192
  * @returns {boolean}
192
193
  */
193
194
  export function isConstraintBanningProject(externalProject, constraint, imp) {
195
+ // used by its own test
194
196
  assertMatchableSpecifier(imp, "import specifier judged against the constraint table");
195
197
  const { allowedExternalImports, bannedExternalImports } = constraint;
196
198
  const { packageName } = externalProject.data;
@@ -29,7 +29,7 @@ import { tagMatches } from "./match.mjs";
29
29
  import { getPath, pathExists } from "./reachability.mjs";
30
30
 
31
31
  /** `"a", "b"` — how upstream renders a tag list inside a message. */
32
- export function stringifyTags(tags) {
32
+ function stringifyTags(tags) {
33
33
  return tags.map((t) => `"${t}"`).join(", ");
34
34
  }
35
35
 
@@ -55,7 +55,7 @@ export function hasTag(project, tag) {
55
55
  }
56
56
 
57
57
  /** True when the project carries NONE of these tags — upstream's spelling. */
58
- export function hasNoneOfTheseTags(project, tags) {
58
+ function hasNoneOfTheseTags(project, tags) {
59
59
  return tags.filter((tag) => hasTag(project, tag)).length === 0;
60
60
  }
61
61
 
@@ -165,6 +165,7 @@ export function orphanedNotDependOnTags(depConstraints, graph) {
165
165
  * are what the message prints so a reader can see the hop that did it.
166
166
  */
167
167
  export function findDependenciesWithTags(targetProject, tags, graph, reach) {
168
+ // used by its own test
168
169
  const reachable = Object.keys(graph.nodes)
169
170
  .sort()
170
171
  .filter(
@@ -57,6 +57,7 @@ export function appIsMFERemote(project) {
57
57
  * — the common case, and the one that keeps `noSelfCircularDependencies` armed.
58
58
  */
59
59
  export function entryPointOf(file, projectRoot, entryPoints) {
60
+ // used by its own test
60
61
  if (!entryPoints || entryPoints.length === 0) return undefined;
61
62
  const fileEntryPoint = entryPoints.find((entry) => entry.file === file);
62
63
  if (fileEntryPoint) return fileEntryPoint.file;
@@ -88,7 +89,7 @@ export function entryPointOf(file, projectRoot, entryPoints) {
88
89
  * standing in for upstream's module resolution — the resolver already ran; this
89
90
  * layer never runs a second one.
90
91
  */
91
- export function secondaryEntryPointPath(resolvedFile, targetProject) {
92
+ function secondaryEntryPointPath(resolvedFile, targetProject) {
92
93
  if (!resolvedFile) return undefined;
93
94
  return entryPointOf(resolvedFile, targetProject.data.root, targetProject.data?.entryPoints);
94
95
  }
@@ -128,6 +129,7 @@ export function belongsToDifferentEntryPoint(resolvedFile, sourceFile, sourcePro
128
129
  * @returns {string[]|null}
129
130
  */
130
131
  export function findDynamicImportPath(graph, sourceProjectName, targetProjectName, visited = []) {
132
+ // used by its own test
131
133
  if (visited.indexOf(sourceProjectName) > -1) return null;
132
134
  for (const dependency of graph.dependencies?.[sourceProjectName] ?? []) {
133
135
  if (dependency.type !== DYNAMIC) continue;
@@ -152,6 +154,7 @@ export function findDynamicImportPath(graph, sourceProjectName, targetProjectNam
152
154
  * fired, and then the verdict would name evidence for a route nobody walked.
153
155
  */
154
156
  export function hasDynamicImport(graph, sourceProjectName, targetProjectName) {
157
+ // used by its own test
155
158
  return findDynamicImportPath(graph, sourceProjectName, targetProjectName) !== null;
156
159
  }
157
160
 
@@ -209,6 +212,7 @@ export function createFileDependencyIndex(edges) {
209
212
 
210
213
  /** The files carrying each hop of a path — one entry per consecutive pair. */
211
214
  export function findFilesInCircularPath(fileIndex, circularPath) {
215
+ // used by its own test
212
216
  const chain = [];
213
217
  for (let i = 0; i < circularPath.length - 1; i++) {
214
218
  chain.push(fileIndex.any.get(edgeKey(circularPath[i].name, circularPath[i + 1].name)) ?? []);
@@ -218,6 +222,7 @@ export function findFilesInCircularPath(fileIndex, circularPath) {
218
222
 
219
223
  /** The files that lazy-load `target` from inside `source`. */
220
224
  export function findFilesWithDynamicImports(fileIndex, sourceProjectName, targetProjectName) {
225
+ // used by its own test
221
226
  return fileIndex.dynamic.get(edgeKey(sourceProjectName, targetProjectName)) ?? [];
222
227
  }
223
228
 
package/src/verdict.mjs CHANGED
@@ -61,6 +61,33 @@ export function coverageIncompleteReasons({ unchecked, blindSpots, analyzed }) {
61
61
  analyzed === 0 ? "no file in scope could be analyzed — coverage incomplete" : null,
62
62
  ].filter(Boolean);
63
63
  }
64
+
65
+ /**
66
+ * The one completeness predicate — the three coverage axes conjoined, the
67
+ * boolean twin of `coverageIncompleteReasons` directly above, which words the
68
+ * same axes as clauses. `coverageVerdict` (`./commands/coverage-verdict.mjs`)
69
+ * reads its `complete` from here, `verdictFor` reads the decision's
70
+ * `coverageComplete` from here, and `check`'s coverage block reads its
71
+ * `complete` from here — three faces of one claim, so the envelope's
72
+ * `coverage.complete` and its `decision.coverageComplete` cannot disagree
73
+ * about a run neither re-derives from the other.
74
+ *
75
+ * The counts come from the caller because a command's coverage universe is
76
+ * its own: `check`'s is wider than `commandContext.analysis` (the go.work and
77
+ * tsconfig whole-file failures it pushes, the accepted `coverage.unowned`
78
+ * files it withdraws), and each face feeds the counts it is a claim about. A
79
+ * caller that reads plain `commandContext.analysis` should call
80
+ * `coverageVerdict` instead — this predicate is the law's last step, not the
81
+ * place failure classes get decided (`./analysis/source-util.mjs`'s
82
+ * classifiers own that line).
83
+ *
84
+ * @param {{unchecked: number, blindSpotCount: number, analyzed: number}} counts
85
+ * @returns {boolean} Whether the run judged everything in its scope.
86
+ */
87
+ export function coverageComplete({ unchecked, blindSpotCount, analyzed }) {
88
+ return unchecked === 0 && blindSpotCount === 0 && analyzed > 0;
89
+ }
90
+
64
91
  /**
65
92
  * The one place that turns a run's counts into the verdict every format
66
93
  * agrees on. `runCheck` uses it for the process's exit code; `check` uses the
@@ -127,7 +154,11 @@ export function verdictFor({
127
154
  reasons: coverageReasons,
128
155
  decision: buildDecision({
129
156
  status: "findings",
130
- coverageComplete: unchecked === 0 && blindSpots === 0 && analyzed > 0,
157
+ // The one completeness predicate, not a restatement: the decision's
158
+ // `coverageComplete` and the envelope's `coverage.complete` are the
159
+ // same claim about the same counts (`check` feeds both from one
160
+ // object), so they read it from one expression.
161
+ coverageComplete: coverageComplete({ unchecked, blindSpotCount: blindSpots, analyzed }),
131
162
  findings:
132
163
  violations +
133
164
  declaredEdgeFindings +
@@ -188,7 +219,7 @@ export function verdictFor({
188
219
  reasons,
189
220
  decision: buildDecision({
190
221
  status: "no-verdict",
191
- coverageComplete: unchecked === 0 && blindSpots === 0 && analyzed > 0,
222
+ coverageComplete: coverageComplete({ unchecked, blindSpotCount: blindSpots, analyzed }),
192
223
  findings: 0,
193
224
  reason: reasons.join("; "),
194
225
  }),
package/src/workspace.mjs CHANGED
@@ -721,6 +721,7 @@ const DOTLESS_FURNITURE = Object.freeze(
721
721
  * @returns {boolean}
722
722
  */
723
723
  export function exemptFromUnsupportedLanguage(sourceFile) {
724
+ // used by its own test
724
725
  const base = sourceFile.slice(sourceFile.lastIndexOf("/") + 1);
725
726
  // Dotfiles are editor and tool state (`.gitignore`, `.env`, `.npmrc`).
726
727
  if (base.startsWith(".")) return true;