@ecoma-io/archkeep 0.13.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 (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,733 @@
1
+ /**
2
+ * The Moon project-model provider: `.moon/` or `.config/moon/` directory, no Nx installed.
3
+ *
4
+ * A Moonrepo workspace is identified by a `.moon/` directory at its root.
5
+ * Moonrepo v2.0+ also supports `.config/moon/` as an alternative config
6
+ * directory; which one a given root carries is answered by `moonMarkerAt`
7
+ * below — the one dispatcher every consumer shares, and the place a root
8
+ * carrying both is refused. The winner is recorded in the `CommandContext`'s
9
+ * `marker` field (`../commands/context.mjs`).
10
+ *
11
+ * This provider reads the project graph via `moon project-graph --json`,
12
+ * the same way `./nx.mjs` reads the Nx graph via `nx graph --file=`.
13
+ *
14
+ * This provider implements the one-call contract `./nx.mjs` already follows
15
+ * (`readProjectGraph`), not the two-call `./native/` contract
16
+ * (`discover`/`buildGraph`): Moon already resolves projects, tags and edges
17
+ * before this package ever asks, so a single call is enough — the same
18
+ * reason the Nx provider has no `discover` step.
19
+ *
20
+ * @typedef {object} ProjectModelProvider
21
+ * @property {string} name Short, stable identifier — `"moon"` here — for a
22
+ * diagnostic that needs to say which provider answered (or failed to).
23
+ * @property {(workspaceRoot: string, io?: object) => object} readProjectGraph
24
+ * The workspace root, plus whatever spawns/reads this provider needs
25
+ * injected for a test — and back comes the graph half of the shape
26
+ * `evaluate()` consumes: `{nodes, dependencies}`, plus `workspaceLayout`
27
+ * when the provider infers one.
28
+ */
29
+
30
+ import { existsSync } from "node:fs";
31
+ import { delimiter, join } from "node:path";
32
+
33
+ import { environmentForTree, runProcess } from "../process.mjs";
34
+ import { buildDependencies } from "./native/graph.mjs";
35
+
36
+ /**
37
+ * The directory that marks a Moonrepo workspace root.
38
+ *
39
+ * Moonrepo v1 uses `.moon/`; Moonrepo v2.0+ also supports `.config/moon/`
40
+ * as an alternative config directory. Both are valid markers; which one a
41
+ * workspace uses is Moon's own convention, not this tool's to decide.
42
+ */
43
+ export const MOON_DIR = ".moon";
44
+
45
+ /**
46
+ * The alternative config directory Moonrepo v2.0+ supports.
47
+ *
48
+ * A workspace may carry either `.moon/` or `.config/moon/`. Moon treats them
49
+ * as mutually exclusive roots, and so does this tool: a root carrying BOTH is
50
+ * refused outright by `moonMarkerAt` below, naming both directories, rather
51
+ * than silently judged against one of them. This constant lets that check —
52
+ * and the marker walk (`../commands/context.mjs`'s `WORKSPACE_MARKERS`) —
53
+ * recognize either spelling, the same way they already check for `nx.json`
54
+ * and `archkeep.json`.
55
+ */
56
+ export const MOON_ALT_DIR = ".config/moon";
57
+
58
+ /**
59
+ * Which Moon directory marks `root` as a Moonrepo workspace — `.moon/`,
60
+ * `.config/moon/`, or neither.
61
+ *
62
+ * The one answer to that question, shared by every entry point that must
63
+ * answer it identically: `../commands/context.mjs`'s provider choice reads it
64
+ * for the CLI, and `../lsp/workspace-index.mjs`'s dispatch reads it for the
65
+ * language server — so the editor and `check` cannot disagree about which
66
+ * marker resolves a Moon tree (#223's two-faces defect).
67
+ *
68
+ * A root carrying both directories is REFUSED rather than silently resolved:
69
+ * Moon treats them as mutually exclusive roots, and picking one for the
70
+ * consumer would judge their workspace against a config nobody chose. It is
71
+ * the same posture the `.moon`+`nx.json`, `.moon`+`archkeep.json` and
72
+ * `nx.json`+`archkeep.json` refusals take one level out
73
+ * (`../commands/context.mjs`), applied inside the pair itself.
74
+ *
75
+ * @param {string} root Absolute workspace root.
76
+ * @param {{exists?: (path: string) => boolean}} [io] Injectable existence test
77
+ * (absolute paths), so a test drives this without a filesystem. The default
78
+ * is plain filesystem existence, matching how every other marker is read.
79
+ * @returns {string} `MOON_DIR` or `MOON_ALT_DIR` — whichever the root carries;
80
+ * `.config/moon/` alone wins nothing over `.moon/` alone, each simply names
81
+ * itself when it is the only one present.
82
+ * @throws {Error} when both directories are present.
83
+ */
84
+ export function moonMarkerAt(root, { exists = existsSync } = {}) {
85
+ const hasPrimary = exists(join(root, MOON_DIR));
86
+ const hasAlt = exists(join(root, MOON_ALT_DIR));
87
+ if (hasPrimary && hasAlt) {
88
+ throw new Error(
89
+ `archkeep: ${root} declares both ${MOON_DIR} and ${MOON_ALT_DIR} — Moonrepo treats them as ` +
90
+ `mutually exclusive config roots, and this tool refuses to pick between them rather than ` +
91
+ `judge the workspace against a config nobody chose. Remove whichever one is not the ` +
92
+ `workspace's real source of truth.`,
93
+ );
94
+ }
95
+ return hasAlt ? MOON_ALT_DIR : hasPrimary ? MOON_DIR : null;
96
+ }
97
+
98
+ /**
99
+ * The key `env` spells PATH under, decided by the PLATFORM rather than by
100
+ * which spellings the object happens to carry.
101
+ *
102
+ * The two platforms disagree about what `Path` and `PATH` even ARE, and
103
+ * nothing about the keys present can settle it:
104
+ *
105
+ * - **Windows** — variable names are case-INSENSITIVE. `Path`, `PATH` and
106
+ * `path` are three spellings of ONE variable, and `Path` is the spelling
107
+ * the system itself writes. `process.env` mirrors that case-insensitivity,
108
+ * but the copy `../process.mjs`'s `environmentForTree` hands back is a plain
109
+ * spread, and a plain object is not: reading `clean.PATH` there answers
110
+ * `undefined` on a machine whose PATH is perfectly present, and writing
111
+ * `PATH` back onto it would leave the child holding two spellings of one
112
+ * variable with only the synthesized one populated — the system PATH
113
+ * dropped, silently. So on Windows the key is found case-insensitively and
114
+ * written back under the spelling it was read from, and `resolveMoonEnv`
115
+ * collapses the remaining case-variants.
116
+ * - **POSIX** — variable names are case-SENSITIVE. `Path` and `PATH` are two
117
+ * ordinary, unrelated variables; both may exist, and the loader resolves the
118
+ * child's binary from `PATH` alone. So `PATH` is the only key this function
119
+ * may answer with there, and the only one `resolveMoonEnv` may write or
120
+ * remove.
121
+ *
122
+ * Guessing the key from `Object.keys` order instead is what made this
123
+ * function delete a real POSIX PATH: measured, env `{Path: "/opt/foo", PATH:
124
+ * "/usr/bin:/bin"}` reached the child as `{Path:
125
+ * "<root>/node_modules/.bin:/opt/foo"}` and nothing else — `PATH` gone, `moon`
126
+ * therefore ENOENT, and `isMoonBinaryMissing` below telling a consumer with
127
+ * Moon installed to install Moon. Loudly, confidently wrong.
128
+ *
129
+ * On Windows a populated spelling wins over an empty one; with no spelling
130
+ * present at all the answer is `"PATH"` on both platforms, the name the
131
+ * variable is then created under.
132
+ *
133
+ * @param {Record<string, string|undefined>} env
134
+ * @param {boolean} windows Whether the platform this env is built for treats
135
+ * variable names case-insensitively.
136
+ * @returns {string}
137
+ */
138
+ function pathVariableKey(env, windows) {
139
+ if (!windows) return "PATH";
140
+ const spellings = Object.keys(env).filter((key) => key.toUpperCase() === "PATH");
141
+ return spellings.find((key) => env[key]) ?? spellings[0] ?? "PATH";
142
+ }
143
+
144
+ /**
145
+ * Resolves the Moon CLI binary by adding the workspace root's
146
+ * `node_modules/.bin` to PATH, the same directory pnpm installs platform
147
+ * shims into. This is the convention `npx` uses and the one
148
+ * `scripts/check-packages.mjs` follows for `moon projects --json`.
149
+ *
150
+ * Moon is not a peer dependency of this package — it is a dev dependency of
151
+ * the consumer workspace — so `require.resolve` cannot find it the way
152
+ * `./nx.mjs` resolves `nx`. Instead, the binary is found on PATH after the
153
+ * workspace's `node_modules/.bin` is prepended.
154
+ *
155
+ * **No entry of the PATH this builds is ever empty.** POSIX resolves an empty
156
+ * entry — a leading, trailing, or doubled delimiter — as the CURRENT
157
+ * DIRECTORY, and the current directory of the spawn this env is built for is
158
+ * the untrusted workspace being judged: a `moon` file committed into that tree
159
+ * would be executed in place of the real CLI. Joining the bin dir to an absent
160
+ * PATH produced exactly that trailing empty entry, and the Windows spelling
161
+ * `pathVariableKey` exists for is what made "absent" reachable on an ordinary
162
+ * machine. Empty entries inherited from the caller are dropped for the same
163
+ * reason: this env is built for one spawn into one untrusted tree, and no
164
+ * entry of it may mean "here".
165
+ *
166
+ * **Exactly one variable is touched, and which one is the platform's answer.**
167
+ * `pathVariableKey` above owns that decision; what follows from it here is the
168
+ * removal, which happens on Windows ONLY. There, the leftover case-variants
169
+ * are the same variable under other spellings and handing the child two views
170
+ * of it — one populated, one not — is ambiguous. On POSIX they are unrelated
171
+ * variables that merely look alike, so nothing is removed: deleting a POSIX
172
+ * `Path` because `PATH` was written is destroying a variable this tool was
173
+ * never asked about, and deleting the `PATH` a child is resolved through is
174
+ * how `moon` became ENOENT on a machine that had it.
175
+ *
176
+ * This function cannot fail. It reports where Moon will be looked for, not
177
+ * whether it is there — that is answered by the spawn itself, in
178
+ * `readProjectGraph`, which is the only place that can tell.
179
+ *
180
+ * @param {string} workspaceRoot
181
+ * @param {{ env?: Record<string, string|undefined>, platform?: string }} [io]
182
+ * The environment to build from, and the platform whose variable-name case
183
+ * rules apply. `platform` is injectable for one reason: the two branches
184
+ * below are opposite behaviours and a machine can only run one of them, so a
185
+ * Windows-only rule tested nowhere is a rule that ships unproven. It governs
186
+ * the case rule alone — the separator stays `node:path`'s `delimiter`,
187
+ * because the child really does run on this host.
188
+ * @returns {{ moon: string, env: Record<string, string|undefined> }}
189
+ * The Moon binary name and an env with the adjusted PATH.
190
+ */
191
+ function resolveMoonEnv(workspaceRoot, { env = process.env, platform = process.platform } = {}) {
192
+ const windows = platform === "win32";
193
+ // Strip ambient git redirects first — the same protection every other
194
+ // provider gets — then add the workspace's node_modules/.bin to PATH.
195
+ const clean = environmentForTree(env);
196
+ const binDir = join(workspaceRoot, "node_modules", ".bin");
197
+ const key = pathVariableKey(clean, windows);
198
+ const entries = String(clean[key] ?? "")
199
+ .split(delimiter)
200
+ .filter((entry) => entry !== "");
201
+ // Segment equality, never substring containment: a directory named
202
+ // `<root>/node_modules/.bin-old` CONTAINS the bin dir as a substring, and a
203
+ // containment test reads that as "already on PATH" and never prepends the
204
+ // real one — leaving `moon` to resolve to whatever else on the machine
205
+ // answers to the name.
206
+ if (!entries.includes(binDir)) entries.unshift(binDir);
207
+ /** @type {Record<string, string|undefined>} */
208
+ const next = { ...clean, [key]: entries.join(delimiter) };
209
+ if (windows) {
210
+ // One variable, one spelling, on the platform where they are one variable.
211
+ for (const spelling of Object.keys(next)) {
212
+ if (spelling !== key && spelling.toUpperCase() === "PATH") delete next[spelling];
213
+ }
214
+ }
215
+ return { moon: "moon", env: next };
216
+ }
217
+
218
+ /**
219
+ * Moon dependency scope (plus, where known, its `source`) → Archkeep edge type.
220
+ *
221
+ * **Moon's `"implicit"` is the INVERSE of Archkeep's, and this is the one place
222
+ * that has to know it.** The two vocabularies use the same word for opposite
223
+ * facts:
224
+ *
225
+ * - **Archkeep** (from Nx's `implicitDependencies`, and `archkeep.json`'s own
226
+ * row of that name): `type: "implicit"` means *a human declared this edge
227
+ * and there is no import behind it*. That is precisely why
228
+ * `../commands/edge-constraints.mjs`'s `declaredEdgeViolationsForCheck`
229
+ * exists — such an edge never becomes an `importSites` record, so
230
+ * `evaluate()` structurally cannot reach it and `check` judges it as an edge
231
+ * instead. `../commands/drift.mjs` and `../commands/discover.mjs` exclude
232
+ * the same set for the mirror reason: a declaration is not evidence of code.
233
+ * - **Moon**: `source` is `"explicit"` when a human wrote the dependency in
234
+ * `moon.yml`'s `dependsOn`, and `"implicit"` when **Moon derived it from
235
+ * source files** — a `package.json` entry under
236
+ * `javascript.syncProjectWorkspaceDependencies`, say. Moon's own shipped
237
+ * schema says so in as many words (`.moon/cache/schemas/project.json`,
238
+ * `DependencySource`, moon 2.4.6): *"The source where the dependency comes
239
+ * from. Either explicitly defined in configuration, or implicitly derived
240
+ * from source files."*
241
+ *
242
+ * So Moon's `"explicit"` carries Archkeep's `"implicit"` fact, and Moon's
243
+ * `"implicit"` is a code-backed edge that gets a `scope`-derived type. Mapping
244
+ * the two words onto each other by their spelling — which is what this
245
+ * function did — made `declaredEdgeViolationsForCheck` judge exactly the set it
246
+ * was written NOT to judge: every manifest-derived edge judged as though it had
247
+ * no import site, and every hand-declared edge, the only kind that really has
248
+ * none, skipped. A workspace with a forbidden dependency written into a
249
+ * `moon.yml` and no import to hide behind reported `no declared-edge
250
+ * violations` and exited 0 (#262).
251
+ *
252
+ * `source` is only available on a project node's own `dependencies[]` array
253
+ * (`transformMoonGraph`'s second edge-building loop) — `raw.graph.edges`'
254
+ * `[source, target, scope]` tuples carry no such field, so that loop's call
255
+ * always passes `source: undefined`, which cannot equal `"explicit"` and so
256
+ * always falls through to `scope`. The node loop is the only place a
257
+ * dependency can be typed `"implicit"` at all, which is what makes the
258
+ * "implicit wins per pair" rule in `transformMoonGraph`'s `add` correct: the
259
+ * loop that can see `source` overrules the loop that cannot.
260
+ *
261
+ * Once `source` is not `"explicit"` (or is unknown), Moon's `scope` decides —
262
+ * and every scope it can decide maps to `"static"`, never to `"dynamic"`
263
+ * (#280). A scope is a fact about a MANIFEST ROW — when the dependency is
264
+ * needed, runtime or build time — while `"dynamic"` upstream is a fact about
265
+ * SOURCE TEXT (`import()` written at an import site,
266
+ * `../../rules/topology.mjs`'s `noImportsOfLazyLoadedLibraries`). Feeding the
267
+ * first to rules that read the second manufactured lazy-loading nobody wrote:
268
+ * every dev-only dependency looked lazy-loaded, and that rule fired at the
269
+ * declaring project's own test file. The previous `"dynamic"` mapping existed
270
+ * only to serve that rule, which is exactly why a scope cannot feed it.
271
+ * Nothing is lost by refusing: genuine lazy loading still arrives through
272
+ * ANALYSIS — `mergeImportEdges` below folds real `import()` sites into this
273
+ * graph keyed `[source, target, type]`, so a real dynamic import adds its own
274
+ * `dynamic` edge regardless of the declared scope:
275
+ * - `"production"` — a runtime dependency. Maps to `"static"`.
276
+ * - `"development"` — a build-time-only dependency. Maps to `"static"`
277
+ * (#280).
278
+ * - `"build"` — a build-system dependency (not a source-level import). Maps
279
+ * to `"static"` as a conservative default; Archkeep judges source imports,
280
+ * not build graphs.
281
+ * - `"peer"` — a peer dependency. Maps to `"static"`.
282
+ * - `"root"` — the root workspace depends on a project. These are not
283
+ * project-to-project edges Archkeep judges, so they are omitted — checked
284
+ * before `source`, because a root-to-project edge is not a boundary either
285
+ * way.
286
+ *
287
+ * @param {string} scope
288
+ * @param {string} [source] Moon's own `"explicit"`/`"implicit"` marker for
289
+ * this specific dependency, when the caller has one.
290
+ * @returns {string|undefined} Archkeep edge type, or `undefined` to skip.
291
+ */
292
+ function edgeTypeFromScope(scope, source) {
293
+ if (scope === "root") {
294
+ // Root-to-project edges are not project-to-project boundaries, declared
295
+ // or not.
296
+ return undefined;
297
+ }
298
+ // Moon "explicit" — written by hand in `moon.yml` — IS Archkeep "implicit".
299
+ if (source === "explicit") return "implicit";
300
+ switch (scope) {
301
+ // A manifest scope says WHEN a dependency is needed, never HOW its imports
302
+ // are written — "dynamic" is a source-text fact only analysis can attest,
303
+ // which `mergeImportEdges` below keeps flowing — so every scope lands on
304
+ // "static" (#280).
305
+ case "production":
306
+ case "development":
307
+ case "build":
308
+ case "peer":
309
+ return "static";
310
+ default:
311
+ // Unknown scopes become "static" — conservative, and never silent.
312
+ return "static";
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Maps a Moon `layer` value to a Archkeep `node.type`.
318
+ *
319
+ * Moon's `layer` is one of: `automation`, `application`, `tool`, `library`,
320
+ * `scaffolding`, `configuration`, `unknown` (or null). Archkeep's `type` is
321
+ * `"app"`, `"e2e"`, or `"lib"` — a coarser taxonomy that the rule engine
322
+ * uses for the `noImportsOfApps`/`enforceBuildableLibDependency` checks.
323
+ *
324
+ * @param {string|null} layer
325
+ * @returns {string} One of `"app"`, `"e2e"`, `"lib"`.
326
+ */
327
+ function nodeTypeFromLayer(layer) {
328
+ switch (layer) {
329
+ case "application":
330
+ return "app";
331
+ case "automation":
332
+ return "e2e";
333
+ default:
334
+ return "lib";
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Derives the set of Archkeep tags from a Moon project node's metadata.
340
+ *
341
+ * Moon provides three sources of tag-like information:
342
+ * - `config.tags[]` — explicit tags declared in `moon.yml`. These become
343
+ * Archkeep tags verbatim.
344
+ * - `layer` — the project's layer classification. Becomes a derived tag
345
+ * `layer:<value>` (e.g. `layer:application`), matching Archkeep's tag
346
+ * convention for constraint tables. Moon tags use dashes (`type-lib`);
347
+ * the derived `layer:` and `stack:` tags use colons to match the
348
+ * convention the constraint table will use, just as Nx tags do.
349
+ * - `stack` — the project's stack. Becomes `stack:<value>`.
350
+ *
351
+ * All three are merged, deduplicated, and sorted.
352
+ *
353
+ * @param {object} projectNode A project node from `moon project-graph --json`.
354
+ * @returns {string[]}
355
+ */
356
+ function deriveTags(projectNode) {
357
+ const tags = new Set();
358
+ // Explicit tags from moon.yml — carried verbatim.
359
+ const declared = projectNode.config?.tags;
360
+ if (Array.isArray(declared)) {
361
+ for (const tag of declared) tags.add(tag);
362
+ }
363
+ // Derived tags from layer/stack — colon-prefixed to match Archkeep
364
+ // convention for constraint tables.
365
+ if (projectNode.layer) tags.add(`layer:${projectNode.layer}`);
366
+ if (projectNode.stack) tags.add(`stack:${projectNode.stack}`);
367
+ return [...tags].sort();
368
+ }
369
+
370
+ /**
371
+ * Infers `workspaceLayout` from the source paths of Moon's projects.
372
+ *
373
+ * Moon does not declare `appsDir`/`libsDir` — there is no `nx.json`-style
374
+ * `workspaceLayout` key. This function examines each project's `source`
375
+ * (workspace-relative root) and checks for a common directory prefix shared
376
+ * by all projects of the same `layer`:
377
+ * - `application`-layer projects whose sources all share a prefix → `appsDir`
378
+ * - `library`-layer projects whose sources all share a prefix → `libsDir`
379
+ *
380
+ * A project at the workspace ROOT contributes to neither. Its `source` is
381
+ * `"."` (or, on some Moon versions, `""`), whose top segment names no
382
+ * directory the workspace keeps apps or libs in — see the guard in the loop
383
+ * below for what inferring one from it would do.
384
+ *
385
+ * A prefix is "shared" when every project of that layer starts with the same
386
+ * top-level directory. If no consistent prefix exists, that key is omitted
387
+ * from the result. If either prefix is found but the other is not, `null` is
388
+ * returned — a partial layout like `{appsDir: "apps"}` would pass through
389
+ * `graph.workspaceLayout ?? DEFAULT_WORKSPACE_LAYOUT` unchanged (the object
390
+ * is truthy, so `??` does not fire) and `isAbsoluteImportIntoAnotherProject`
391
+ * would evaluate `${workspaceLayout.libsDir}/` as `"undefined/"`, silently
392
+ * disabling the absolute-import rule for the missing axis. Returning `null`
393
+ * forces the `??` fallback, which applies the complete default
394
+ * `{libsDir: "libs", appsDir: "apps"}`. If neither prefix is found, `null`
395
+ * is returned for the same reason.
396
+ *
397
+ * @param {object[]} projectNodes Project nodes from Moon's `data` map values.
398
+ * @returns {{appsDir: string, libsDir: string}|null}
399
+ */
400
+ function inferWorkspaceLayout(projectNodes) {
401
+ const appDirs = new Set();
402
+ const libDirs = new Set();
403
+ for (const node of projectNodes) {
404
+ if (!node.source) continue;
405
+ const topDir = node.source.split("/")[0];
406
+ // A source that names no directory BELOW the workspace root contributes no
407
+ // prefix. Moon spells the root project's source `"."`, and
408
+ // `".".split("/")[0]` is `"."` — which infers `appsDir: "."` and makes
409
+ // `../rules/specifiers.mjs`'s `isAbsoluteImportIntoAnotherProject` test
410
+ // `imp.startsWith("./")`: EVERY ordinary relative import in the workspace
411
+ // reported as an absolute import into another project, from one
412
+ // root-level `moon.yml`. The guard is on the resolved top SEGMENT rather
413
+ // than on `source` itself, so every spelling that lands there — `"."`,
414
+ // `"./"`, `"./apps/web"`, a leading `/` — is covered by one test instead
415
+ // of a list the next spelling escapes. Excluding a root project can only
416
+ // leave a prefix set empty, which returns the incomplete layout `null`
417
+ // above and falls back to the complete default: the loud direction.
418
+ if (topDir === "" || topDir === "." || topDir === "..") continue;
419
+ if (node.layer === "application") appDirs.add(topDir);
420
+ else if (node.layer === "library") libDirs.add(topDir);
421
+ }
422
+ const appsDir = appDirs.size === 1 ? [...appDirs][0] : undefined;
423
+ const libsDir = libDirs.size === 1 ? [...libDirs][0] : undefined;
424
+ // Both keys must be present, or neither. A partial layout would silently
425
+ // disable the absolute-import rule for the missing axis — see the header
426
+ // comment for the mechanism.
427
+ if (appsDir === undefined || libsDir === undefined) return null;
428
+ return { appsDir, libsDir };
429
+ }
430
+
431
+ /**
432
+ * Transforms Moon's `project-graph --json` output into the shape
433
+ * `evaluate()` consumes: `{nodes, dependencies}`.
434
+ *
435
+ * Moon's graph uses integer-indexed nodes and edges:
436
+ * ```jsonc
437
+ * {
438
+ * "graph": { "nodes": [0, 1], "edges": [[0, 1, "production"]] },
439
+ * "data": { "0": { "id": "web", "source": "apps/web", ... }, ... }
440
+ * }
441
+ * ```
442
+ *
443
+ * Archkeep's graph uses name-keyed records:
444
+ * ```js
445
+ * {
446
+ * nodes: { "web": { name: "web", type: "app", data: { root: "apps/web", tags: [...] } } },
447
+ * dependencies: { "web": [{ source: "web", target: "api", type: "static" }] }
448
+ * }
449
+ * ```
450
+ *
451
+ * Null-prototype objects for `nodes` and `dependencies`, for the same reason
452
+ * `./native/graph.mjs` uses them: a project literally named `__proto__` is a
453
+ * name this provider does not control (it comes from `moon.yml`'s `id`), and a
454
+ * plain `{}` answers `nodes["__proto__"] = …` by repointing the object's own
455
+ * prototype rather than adding an entry — silent, and exactly the shape
456
+ * `../../../AGENTS.md`'s invariant refuses.
457
+ *
458
+ * @param {object} raw The parsed JSON from `moon project-graph --json`.
459
+ * @returns {{nodes: Record<string, object>, dependencies: Record<string, object[]>, workspaceLayout?: object}}
460
+ */
461
+ export function transformMoonGraph(raw) {
462
+ if (!raw?.data) {
463
+ throw new Error(
464
+ "archkeep: `moon project-graph` produced no `data` map — " +
465
+ "nothing can be judged against a graph with no projects in it",
466
+ );
467
+ }
468
+
469
+ /** @type {Record<string, object>} */
470
+ const nodes = Object.create(null);
471
+ /** @type {Record<string, {source: string, target: string, type: string}[]>} */
472
+ const dependencies = Object.create(null);
473
+ // Keyed on the (source, target) PAIR alone, never on `type`: the same
474
+ // dependency is represented twice in Moon's own output — once in
475
+ // `raw.graph.edges` (typed only from `scope`, since that tuple carries no
476
+ // `source` field) and once in the owning node's own `dependencies[]`
477
+ // (which does carry `source`, and so is the only place a dependency can be
478
+ // typed `"implicit"` at all). Keying on `[source, target, type]` — the
479
+ // previous shape — let both survive as two distinct edges for one real
480
+ // dependency: an `implicit` edge from the second loop, plus a phantom
481
+ // `static`/`dynamic` duplicate from the first, and only the second loop's
482
+ // exclusion callers (`../commands/drift.mjs`, `../commands/discover.mjs`)
483
+ // ever look at `type`, so the phantom slipped past every `edge.type ===
484
+ // "implicit"` check as a fake code-derived edge. One pair now always
485
+ // resolves to exactly one entry, and `"implicit"` — the hand-declared fact,
486
+ // which only the node loop can see (`edgeTypeFromScope`'s header) — always
487
+ // wins over a scope-derived type for that same pair, in whichever order the
488
+ // two loops below discover it.
489
+ /** @type {Map<string, {source: string, target: string, type: string}>} */
490
+ const edgesByPair = new Map();
491
+ const add = (source, target, type) => {
492
+ if (!source || !target || source === target) return;
493
+ if (!nodes[target]) return;
494
+ const key = JSON.stringify([source, target]);
495
+ const existing = edgesByPair.get(key);
496
+ if (existing) {
497
+ if (type === "implicit" && existing.type !== "implicit") existing.type = "implicit";
498
+ return;
499
+ }
500
+ const entry = { source, target, type };
501
+ edgesByPair.set(key, entry);
502
+ (dependencies[source] ??= []).push(entry);
503
+ };
504
+
505
+ // Build nodes from Moon's data map. Each entry is indexed by integer key
506
+ // but identified by its `id` string — which becomes the Archkeep node key.
507
+ const projectNodes = Object.values(raw.data);
508
+ for (const node of projectNodes) {
509
+ if (!node.id || !node.source) continue;
510
+ const tags = deriveTags(node);
511
+ // Nx's `implicitDependencies` is the hand-declared list — so it is Moon's
512
+ // `"explicit"` dependencies that belong here, not its `"implicit"` ones.
513
+ // Same inversion as `edgeTypeFromScope` above, same reason.
514
+ const implicitDeps = Array.isArray(node.dependencies)
515
+ ? node.dependencies.filter((d) => d.source === "explicit").map((d) => d.id)
516
+ : [];
517
+ const taskTargets = Array.isArray(node.taskTargets) ? node.taskTargets : [];
518
+ nodes[node.id] = {
519
+ name: node.id,
520
+ type: nodeTypeFromLayer(node.layer),
521
+ data: {
522
+ root: node.source,
523
+ tags,
524
+ implicitDependencies: implicitDeps,
525
+ ...(taskTargets.length > 0
526
+ ? {
527
+ targets: Object.fromEntries(
528
+ taskTargets.map((target) => {
529
+ // Task targets are "projectId:taskId" — extract the task name.
530
+ const taskId = target.includes(":")
531
+ ? target.split(":").slice(1).join(":")
532
+ : target;
533
+ return [taskId, { executor: "moon:declared" }];
534
+ }),
535
+ ),
536
+ }
537
+ : {}),
538
+ },
539
+ };
540
+ }
541
+
542
+ // Build dependencies from Moon's graph edges.
543
+ if (Array.isArray(raw.graph?.edges)) {
544
+ for (const [srcIdx, tgtIdx, scope] of raw.graph.edges) {
545
+ const srcNode = raw.data[String(srcIdx)];
546
+ const tgtNode = raw.data[String(tgtIdx)];
547
+ if (!srcNode?.id || !tgtNode?.id) continue;
548
+ const type = edgeTypeFromScope(scope);
549
+ if (type === undefined) continue; // e.g. "root" scope
550
+ add(srcNode.id, tgtNode.id, type);
551
+ }
552
+ }
553
+
554
+ // Also add dependencies from each project node's own `dependencies` array,
555
+ // which carries `scope` and `source` metadata. This covers implicit
556
+ // dependencies that the graph edges may not explicitly represent as edges.
557
+ // `dep.source` is this loop's own reason to exist over the one above: it is
558
+ // the only place this function's `source` argument is ever real.
559
+ for (const node of projectNodes) {
560
+ if (!node.id || !Array.isArray(node.dependencies)) continue;
561
+ for (const dep of node.dependencies) {
562
+ if (!dep.id) continue;
563
+ const type = edgeTypeFromScope(dep.scope, dep.source);
564
+ if (type === undefined) continue;
565
+ add(node.id, dep.id, type);
566
+ }
567
+ }
568
+
569
+ const workspaceLayout = inferWorkspaceLayout(projectNodes);
570
+ return workspaceLayout === null
571
+ ? { nodes, dependencies }
572
+ : { nodes, dependencies, workspaceLayout };
573
+ }
574
+
575
+ /**
576
+ * Folds the package's own import analysis into a Moon graph, in place.
577
+ *
578
+ * `moon project-graph --json` knows a project depends on another only when
579
+ * `moon.yml` says `dependsOn` — Moon has no hook this package can register
580
+ * the way `./nx.mjs`'s `createDependencies` registers into Nx's own graph
581
+ * computation. A Go, Rust or Python import crossing a project boundary with
582
+ * no such entry is therefore invisible to the graph alone, while the
583
+ * reachability rules read exactly these lists (`noCircularDependencies`, the
584
+ * upstream half of `notDependOnLibsWithTags`) — an edge missing here hides a
585
+ * cycle or a transitive finding from every verdict built on the graph, which
586
+ * is the silent direction. The dedupe key is `[source, target, type]`: an
587
+ * import that agrees with a declared edge adds nothing; one that disagrees in
588
+ * kind (a dynamic import of a statically declared dependency) survives as its
589
+ * own record, because `noImportsOfLazyLoadedLibraries` turns on that kind.
590
+ *
591
+ * ONE implementation serves both faces that compose a Moon graph — the CLI's
592
+ * preamble (`../commands/context.mjs`) and the language server's index
593
+ * (`../lsp/workspace-index.mjs`) — so the editor and `check` cannot disagree
594
+ * about which edges exist (#223).
595
+ *
596
+ * @param {{nodes: object, dependencies: Record<string, object[]>}} graph The
597
+ * graph `transformMoonGraph` (or an equivalent) returned; mutated in place.
598
+ * @param {{importSites: object[], projectOf: (file: string) => string|undefined}} analysis
599
+ * Import records in the analysis contract's shape, and the file→project map
600
+ * to attribute them with.
601
+ * @returns {{nodes: object, dependencies: Record<string, object[]>}} The same graph.
602
+ */
603
+ export function mergeImportEdges(graph, { importSites, projectOf }) {
604
+ const importEdges = buildDependencies({ importSites, nodes: graph.nodes, projectOf });
605
+ const seenEdges = new Set(
606
+ Object.values(graph.dependencies)
607
+ .flat()
608
+ .map((edge) => JSON.stringify([edge.source, edge.target, edge.type])),
609
+ );
610
+ for (const [source, edges] of Object.entries(importEdges)) {
611
+ for (const edge of edges) {
612
+ const key = JSON.stringify([edge.source, edge.target, edge.type]);
613
+ if (seenEdges.has(key)) continue;
614
+ seenEdges.add(key);
615
+ (graph.dependencies[source] ??= []).push(edge);
616
+ }
617
+ }
618
+ return graph;
619
+ }
620
+
621
+ /**
622
+ * The name or path this provider will spawn as the Moon CLI.
623
+ *
624
+ * Unlike `./nx.mjs`'s `nxCli()`, this resolves nothing and verifies nothing.
625
+ * `moon` is not a peer dependency of this package, so there is no
626
+ * `node_modules/moon` to `require.resolve`, and the binary may legitimately
627
+ * sit either in the workspace's own `node_modules/.bin` (the usual case, which
628
+ * `resolveMoonEnv` puts on PATH) or anywhere else on the consumer's PATH. The
629
+ * default therefore returns the bare name `"moon"` and lets the spawn be the
630
+ * existence check — the only test that covers both places without this file
631
+ * reimplementing `which`, and one that cannot refuse a globally installed Moon
632
+ * the way a `node_modules/.bin` stat would.
633
+ *
634
+ * It used to document a "Moon cannot be found" error it had no way to throw:
635
+ * the default resolver is a constant. The error a consumer actually meets is
636
+ * built in `readProjectGraph` below, from the spawn's own `ENOENT` — the only
637
+ * place the fact is available to name.
638
+ *
639
+ * `resolveMoon` stays injectable so a test can point the spawn at a known
640
+ * binary path without a system-wide Moon installation.
641
+ *
642
+ * @param {string} workspaceRoot
643
+ * @param {{ resolveMoon?: (workspaceRoot: string) => string }} [io]
644
+ * @returns {string} Path or name of the Moon CLI binary.
645
+ */
646
+ function resolveMoonCli(workspaceRoot, { resolveMoon = () => "moon" } = {}) {
647
+ return resolveMoon(workspaceRoot);
648
+ }
649
+
650
+ /**
651
+ * Is this spawn failure "the binary is not there", as opposed to "it ran and
652
+ * failed"?
653
+ *
654
+ * Those are different facts and only the first has an install action behind
655
+ * it. Telling a consumer whose Moon ran and exited 2 to install Moon sends
656
+ * them to fix something that is not broken — the same mistake `./nx.mjs`'s
657
+ * `nxCli` guards against from the other direction, where only a
658
+ * `MODULE_NOT_FOUND` earns the "not installed" story.
659
+ *
660
+ * `../process.mjs`'s `runProcess` wraps the child's failure and carries the
661
+ * original on `cause`, so the code is read from there; the direct `code` is
662
+ * read too, for a `run` seam that surfaces a spawn error unwrapped.
663
+ *
664
+ * @param {unknown} error
665
+ * @returns {boolean}
666
+ */
667
+ function isMoonBinaryMissing(error) {
668
+ const thrown = /** @type {{code?: unknown, cause?: {code?: unknown}}|null|undefined} */ (error);
669
+ return thrown?.code === "ENOENT" || thrown?.cause?.code === "ENOENT";
670
+ }
671
+
672
+ /**
673
+ * The Moon project graph for `workspaceRoot`, in the shape `evaluate()` consumes.
674
+ *
675
+ * `moon project-graph --json` emits a project graph that includes the nodes
676
+ * and dependencies Moonrepo computed. This function reads that output,
677
+ * transforms it into the `{nodes, dependencies}` shape the rule engine
678
+ * expects, and returns it.
679
+ *
680
+ * @param {string} workspaceRoot
681
+ * @param {{ run?: typeof runProcess, resolveMoon?: (workspaceRoot: string) => string, env?: Record<string, string|undefined>, platform?: string }} [io]
682
+ * Injectable spawn, injectable Moon resolution, injectable env, and the
683
+ * platform whose environment-variable case rules `resolveMoonEnv` applies —
684
+ * see its own header for why that one is a seam.
685
+ * @returns {object} `{ nodes, dependencies }`, plus `workspaceLayout` when
686
+ * the project paths imply one.
687
+ * @throws {Error} naming the install action when the Moon binary is in neither
688
+ * place it is looked for. Every other spawn failure, and a JSON parse
689
+ * failure, propagates untouched — already named by `../process.mjs`'s
690
+ * `runProcess`. No path here answers with an empty graph.
691
+ */
692
+ export function readProjectGraph(
693
+ workspaceRoot,
694
+ { run = runProcess, resolveMoon, env, platform } = {},
695
+ ) {
696
+ const moonEnv = resolveMoonEnv(workspaceRoot, {
697
+ env: env ?? process.env,
698
+ platform: platform ?? process.platform,
699
+ });
700
+ const moon = resolveMoonCli(workspaceRoot, { resolveMoon });
701
+ let output;
702
+ try {
703
+ output = run(moon, ["project-graph", "--json"], workspaceRoot, moonEnv.env);
704
+ } catch (cause) {
705
+ // Everything except an absent binary propagates untouched: `runProcess`
706
+ // already names the command, the working directory and the child's own
707
+ // stderr, and rewriting a real Moon error here would bury it. The absent
708
+ // binary is re-thrown because it is the failure a consumer meets FIRST and
709
+ // its raw text — `spawnSync moon ENOENT` — names no action they can take.
710
+ if (!isMoonBinaryMissing(cause)) throw cause;
711
+ throw new Error(
712
+ `archkeep: the Moon CLI (\`${moon}\`) could not be run in ${workspaceRoot} — the Moon ` +
713
+ "provider reads the project graph with `moon project-graph --json`, looking for the " +
714
+ `binary in ${join(workspaceRoot, "node_modules", ".bin")} and then on PATH, and neither ` +
715
+ "answered. Install Moon in this workspace (`pnpm add -D @moonrepo/cli`), or put `moon` " +
716
+ "on PATH.",
717
+ { cause },
718
+ );
719
+ }
720
+ const raw = JSON.parse(output);
721
+ return transformMoonGraph(raw);
722
+ }
723
+
724
+ /**
725
+ * The `ProjectModelProvider`-family object `../../cli.mjs` selects when a
726
+ * workspace root carries a `.moon/` or `.config/moon/` directory rather than
727
+ * `nx.json` or `archkeep.json`.
728
+ */
729
+ export const moonProvider = {
730
+ name: "moon",
731
+ marker: MOON_DIR,
732
+ readProjectGraph,
733
+ };