@ecoma-io/archkeep 0.14.0 → 0.16.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 (68) hide show
  1. package/README.md +11 -5
  2. package/cli.mjs +571 -61
  3. package/commands.mjs +57 -0
  4. package/lsp.mjs +15 -2
  5. package/package.json +8 -2
  6. package/src/analysis/analyze.mjs +15 -0
  7. package/src/analysis/contract.md +36 -18
  8. package/src/analysis/csharp.mjs +485 -0
  9. package/src/analysis/dotnet/csproj.mjs +380 -0
  10. package/src/analysis/dotnet/mask.mjs +178 -0
  11. package/src/analysis/dotnet/namespaces.mjs +172 -0
  12. package/src/analysis/dotnet/resolve.mjs +89 -0
  13. package/src/analysis/go.mjs +289 -5
  14. package/src/analysis/java.mjs +329 -0
  15. package/src/analysis/jvm/gradle.mjs +545 -0
  16. package/src/analysis/jvm/mask.mjs +170 -0
  17. package/src/analysis/jvm/maven.mjs +612 -0
  18. package/src/analysis/jvm/packages.mjs +209 -0
  19. package/src/analysis/jvm/resolve.mjs +139 -0
  20. package/src/analysis/kotlin.mjs +210 -0
  21. package/src/analysis/manifest-util.mjs +30 -0
  22. package/src/analysis/python.mjs +3 -2
  23. package/src/analysis/registry.mjs +11 -0
  24. package/src/analysis/rust.mjs +171 -17
  25. package/src/analysis/source-util.mjs +155 -6
  26. package/src/analysis/typescript.mjs +11 -3
  27. package/src/commands/README.md +52 -1
  28. package/src/commands/change-intent.mjs +461 -0
  29. package/src/commands/change.mjs +612 -0
  30. package/src/commands/check.mjs +2 -1
  31. package/src/commands/context.mjs +124 -16
  32. package/src/commands/custom-rules.mjs +286 -2
  33. package/src/commands/delta-classify.mjs +195 -33
  34. package/src/commands/delta-snapshot.mjs +156 -1
  35. package/src/commands/delta.mjs +142 -17
  36. package/src/commands/diff.mjs +41 -13
  37. package/src/commands/evolution.mjs +473 -0
  38. package/src/commands/history.mjs +130 -103
  39. package/src/commands/policy.mjs +57 -0
  40. package/src/commands/provenance.mjs +7 -44
  41. package/src/commands/rules.mjs +775 -0
  42. package/src/commands/trajectory.mjs +437 -0
  43. package/src/governance/profile-registry.mjs +0 -1
  44. package/src/graph/create-dependencies.mjs +138 -15
  45. package/src/lsp/diagnose.mjs +1 -1
  46. package/src/lsp/server.mjs +97 -1
  47. package/src/lsp/workspace-index.mjs +106 -15
  48. package/src/options.mjs +30 -7
  49. package/src/path-util.mjs +40 -0
  50. package/src/process.mjs +10 -1
  51. package/src/providers/moon.mjs +287 -36
  52. package/src/providers/native/differential.fixtures.mjs +32 -6
  53. package/src/providers/native/discover.mjs +83 -4
  54. package/src/providers/native/graph.mjs +58 -0
  55. package/src/providers/native/model.mjs +59 -1
  56. package/src/report/change-text.mjs +148 -0
  57. package/src/report/delta-text.mjs +82 -1
  58. package/src/report/evolution-text.mjs +83 -0
  59. package/src/report/history-text.mjs +4 -114
  60. package/src/report/sarif.mjs +255 -0
  61. package/src/report/snapshot-text.mjs +123 -0
  62. package/src/report/trajectory-text.mjs +143 -0
  63. package/src/rules/index.mjs +21 -6
  64. package/src/rules/reachability.mjs +2 -0
  65. package/src/rules/tags.mjs +7 -5
  66. package/src/rules/topology.mjs +5 -3
  67. package/src/tsconfig-paths.mjs +3 -2
  68. package/src/workspace.mjs +115 -23
package/src/workspace.mjs CHANGED
@@ -28,11 +28,12 @@
28
28
  * is the same tracked-file set every resolver in this project already reasons
29
29
  * about ("Resolvers read tracked files only", project `AGENTS.md`).
30
30
  */
31
- import { existsSync, readFileSync } from "node:fs";
31
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
32
32
  import { dirname, isAbsolute, join, posix, relative, resolve } from "node:path";
33
33
 
34
34
  import { containmentViolation } from "./containment.mjs";
35
35
  import { analyzeFile, languageOf } from "./analysis/analyze.mjs";
36
+ import { basenameMatches } from "./analysis/manifest-util.mjs";
36
37
  import { fileFailure, projectOwning } from "./analysis/source-util.mjs";
37
38
  import { UsageError } from "./errors.mjs";
38
39
  import { parseNxJson } from "./nx-json.mjs";
@@ -57,29 +58,109 @@ export { environmentForTree, runProcess };
57
58
  * with a hoisted install, the right one by luck (same reason
58
59
  * `loadBoundaryConfig` takes a root — see `config.mjs`).
59
60
  *
61
+ * The walk is bounded by the enclosing git repository: a marker above the top
62
+ * level `git rev-parse --show-toplevel` names for `from` is tooling state,
63
+ * not this workspace's root. The case that made the bound necessary is
64
+ * `~/.moon` — moonrepo's user-level state directory, present on every machine
65
+ * moonrepo has ever run on (its own documentation puts the shared cache at
66
+ * `~/.moon/cache/shared`), which a walk reading only directory existence
67
+ * climbed to from any unmarked directory under the home directory, selecting
68
+ * `$HOME` as a "Moon workspace" and failing to load
69
+ * `$HOME/module-boundaries.config.mjs` instead of refusing (#339). The bound
70
+ * is inclusive: a marker ON the top level is the ordinary case, a repository
71
+ * that is itself the workspace. With no enclosing repository — or no `git` to
72
+ * ask — there is no bound, and the walk climbs as far as it did before one
73
+ * existed; the shape of the Moon markers is what holds the line there.
74
+ *
60
75
  * `markers` defaults to `nx.json` alone, so every existing caller keeps
61
76
  * finding exactly the root it found before. A native-provider caller passes
62
77
  * `[NX_CONFIG_FILE, ARCHKEEP_MODEL_FILE]` to recognise either root marker in
63
78
  * one walk — see `../cli.mjs`, which is the only caller that needs to tell
64
79
  * the two apart, and does so by checking which marker(s) the returned
65
- * directory actually carries.
80
+ * directory actually carries. A Moon marker names the directory's
81
+ * `workspace.yml` — the file moonrepo itself requires of a workspace — never
82
+ * the directory alone (`../providers/moon.mjs`'s `MOON_WORKSPACE_MARKER`):
83
+ * a bare `.moon` is the user-level state directory again, and directory
84
+ * presence alone is exactly what selected `$HOME`.
66
85
  *
67
86
  * @param {string} from Absolute directory to start at.
68
- * @param {string[]} [markers] Filenames or directory names whose presence
69
- * marks a workspace root. `existsSync` works for both — a directory name
70
- * like `.moon` is detected the same way a filename like `nx.json` is.
87
+ * @param {string[]} [markers] Filenames or relative paths whose presence
88
+ * marks a workspace root. `existsSync` works for all of them — a relative
89
+ * path like `.moon/workspace.yml` is detected the same way a filename like
90
+ * `nx.json` is.
91
+ * @param {{gitTopLevel?: (from: string) => string|null}} [io] The git seam:
92
+ * how the walk asks for the enclosing repository's top level, injectable
93
+ * for the same reason every spawn here is. The default runs
94
+ * `git rev-parse --show-toplevel` through `runProcess` — so ambient
95
+ * `GIT_DIR`-style redirects are stripped, the boundary describing the tree
96
+ * at `from` rather than whatever repository a hook exported — and answers
97
+ * `null` when git cannot (no repository encloses `from`, or git is absent).
98
+ * `null` is a missing boundary, never a refusal: the walk then climbs
99
+ * unbounded, exactly as it did before the boundary existed.
71
100
  * @returns {string|null} Absolute path, or `null` when no ancestor has one.
72
101
  */
73
- export function findWorkspaceRoot(from, markers = [NX_CONFIG_FILE]) {
102
+ export function findWorkspaceRoot(
103
+ from,
104
+ markers = [NX_CONFIG_FILE],
105
+ { gitTopLevel = gitTopLevelOf } = {},
106
+ ) {
107
+ const ceiling = gitTopLevel(resolve(from));
74
108
  let current = resolve(from);
75
109
  for (;;) {
76
110
  if (markers.some((marker) => existsSync(join(current, marker)))) return current;
111
+ // Inclusive on purpose: the marker check above already ran for the top
112
+ // level itself, so reaching the ceiling with no marker means no ancestor
113
+ // within the repository is a workspace root — and every ancestor beyond
114
+ // it is outside the tree `git ls-files` would answer for.
115
+ if (ceiling !== null && sameDirectory(current, ceiling)) return null;
77
116
  const parent = dirname(current);
78
117
  if (parent === current) return null;
79
118
  current = parent;
80
119
  }
81
120
  }
82
121
 
122
+ /**
123
+ * The top level of the git repository enclosing `from` — the ceiling
124
+ * `findWorkspaceRoot` above stops its walk at.
125
+ *
126
+ * `null` when none does or when git cannot answer (absent binary, bare
127
+ * repository, unreadable `.git`): a boundary that cannot be measured is a
128
+ * boundary absent, and the walk degrades to its previous unbounded climb
129
+ * rather than refusing a root it never looked at. A repository git genuinely
130
+ * cannot read is caught loudly one call later by `listTrackedFiles`, whose
131
+ * own spawn has no `null` answer.
132
+ */
133
+ function gitTopLevelOf(from) {
134
+ try {
135
+ // `stderr: "ignore"` because a directory no repository encloses is the
136
+ // COMMON case this probe must answer quietly — git's `fatal: not a git
137
+ // repository` belongs nowhere near a user's terminal for it.
138
+ return runProcess("git", ["rev-parse", "--show-toplevel"], from, undefined, {
139
+ stderr: "ignore",
140
+ }).trim();
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Do `a` and `b` name the same directory? `git rev-parse --show-toplevel`
148
+ * answers a fully resolved path while the walk holds the spelling it was
149
+ * given — on macOS every `os.tmpdir()` path starts `/var/folders/…`, whose
150
+ * real spelling is `/private/var/folders/…`, and a plain `===` would let the
151
+ * ceiling silently never bind there. Doubt answers false: a boundary that
152
+ * fails to bind is the old walk, while one that binds wrongly refuses a real
153
+ * workspace.
154
+ */
155
+ const sameDirectory = (a, b) => {
156
+ if (a === b) return true;
157
+ try {
158
+ return realpathSync(a) === realpathSync(b);
159
+ } catch {
160
+ return false;
161
+ }
162
+ };
163
+
83
164
  /**
84
165
  * Every tracked file in the workspace, workspace-relative.
85
166
  *
@@ -536,26 +617,34 @@ export function analyzeWorkspace(workspace, files, { analyze = analyzeFile } = {
536
617
  return { imports, failures, analyzed: analyzedFiles.length, analyzedFiles };
537
618
  }
538
619
 
539
- /** The three polyglot manifests `polyglotManifests` looks for. */
540
- const POLYGLOT_MANIFEST_NAMES = ["go.mod", "Cargo.toml", "pyproject.toml"];
620
+ /** The polyglot manifests `polyglotManifests` looks for. */
621
+ const POLYGLOT_MANIFEST_NAMES = [
622
+ "go.mod",
623
+ "Cargo.toml",
624
+ "pyproject.toml",
625
+ "pom.xml",
626
+ "settings.gradle",
627
+ "settings.gradle.kts",
628
+ "*.csproj",
629
+ ];
541
630
 
542
631
  /**
543
- * Tracked Go, Rust and Python manifests that sit under some project's root —
544
- * the fact the unregistered-Nx-plugin gap turns on
632
+ * Tracked Go, Rust, Python, Maven and .NET manifests that sit under some project's
633
+ * root — the fact the unregistered-Nx-plugin gap turns on
545
634
  * (`./commands/context.mjs`'s `pluginGap.manifests` is where a caller reads
546
635
  * it, and `./options.mjs`'s `pluginIsRegistered` is the other half of that
547
- * gap). A workspace running under Nx draws no edge for any of these three
548
- * languages unless this plugin is registered in `nx.json` — Nx parses only
549
- * TypeScript and JavaScript imports natively (`../../../AGENTS.md`, "for the
550
- * other three both go quiet") — so a tracked manifest with no registered
551
- * plugin is exactly the silent hole that invariant refuses. This function
552
- * only names the manifests; it does not decide whether the plugin is
553
- * registered. The pair the gap turns on is wired in twice today:
554
- * `resolveCommandContext` reads both into its `pluginGap`, which every
555
- * descriptive command refuses on, while `check` renders the same fact as a
556
- * `coverageGaps` degraded-coverage note rather than a refusal
557
- * (`../cli.mjs`) — the checker's own analysis covers what the graph does
558
- * not, so a note is the right level there.
636
+ * gap). A workspace running under Nx draws no edge for any of these languages
637
+ * unless this plugin is registered in `nx.json` — Nx parses only TypeScript
638
+ * and JavaScript imports natively (`../../../AGENTS.md`, "for the other
639
+ * three both go quiet") — so a tracked manifest with no registered plugin is
640
+ * exactly the silent hole that invariant refuses. This function only names
641
+ * the manifests; it does not decide whether the plugin is registered. The
642
+ * pair the gap turns on is wired in twice today: `resolveCommandContext`
643
+ * reads both into its `pluginGap`, which every descriptive command refuses
644
+ * on, while `check` renders the same fact as a `coverageGaps`
645
+ * degraded-coverage note rather than a refusal (`../cli.mjs`) — the checker's
646
+ * own analysis covers what the graph does not, so a note is the right level
647
+ * there.
559
648
  *
560
649
  * Root matching mirrors `projectOwning`'s longest-prefix attribution of a
561
650
  * source file: a project rooted at the workspace root (`root: ""` or `"."`)
@@ -572,7 +661,10 @@ export function polyglotManifests(tracked, projects) {
572
661
  const roots = projects.map((project) => project.root);
573
662
  return tracked.filter((file) => {
574
663
  const base = file.slice(file.lastIndexOf("/") + 1);
575
- if (!POLYGLOT_MANIFEST_NAMES.includes(base)) return false;
664
+ // Literal-first: four of the five names answer by equality, and only
665
+ // `*.csproj` reaches the glob — this filter runs once per tracked file
666
+ // (`./analysis/manifest-util.mjs`'s `basenameMatches` owns why).
667
+ if (!basenameMatches(base, POLYGLOT_MANIFEST_NAMES, posix.matchesGlob)) return false;
576
668
  return roots.some(
577
669
  (root) => root === "" || root === "." || file === root || file.startsWith(`${root}/`),
578
670
  );