@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,611 @@
1
+ /**
2
+ * The plugin's options: what a workspace may tell this tool about itself.
3
+ *
4
+ * Two filenames, and one optional third, and nothing else. All three are Nx
5
+ * CONVENTIONS rather than fixed contracts — a workspace is free to name them
6
+ * otherwise, and when it does, every answer this tool gives about that workspace
7
+ * is silently wrong: a `tsconfig.base.json` under another name means no path
8
+ * alias resolves, a boundary config under another name means the enforcer
9
+ * cannot find the law and says so at best, reads a stale one at worst, and a
10
+ * profiles registry under another name means the `check` command cannot find
11
+ * the named laws it was asked to enforce. That is the difference between this
12
+ * triple and the manifests next door: `go.mod`, `Cargo.toml`,
13
+ * `pyproject.toml`, `nx.json` and `project.json` are named by their own
14
+ * toolchains and cannot be renamed by a workspace decision, so they stay
15
+ * hardcoded at their single use site: a literal belongs inline only where it is
16
+ * intrinsic to a fixed external contract and appears in exactly one place.
17
+ *
18
+ * ## Where the options come from, and why there is no config file for them
19
+ *
20
+ * `nx.json → plugins` already carries a per-plugin `options` object, and Nx
21
+ * threads it into every hook (`CreateDependencies<T> = (options, context) =>`).
22
+ * A workspace that registers this plugin has already written the entry; adding
23
+ * two keys to it costs nothing and adds no file. Inventing an
24
+ * `archkeep.config.mjs` instead would add a file when the workspace
25
+ * already carries the same options in `nx.json` — and it would need its own
26
+ * filename option to find itself, which is the joke that gives the game away.
27
+ *
28
+ * ```json
29
+ * "plugins": [
30
+ * {
31
+ * "plugin": "@ecoma-io/archkeep/nx",
32
+ * "options": { "boundaryConfig": "module-boundaries.config.mjs", "tsConfig": "tsconfig.base.json" }
33
+ * }
34
+ * ]
35
+ * ```
36
+ *
37
+ * A Moon workspace can write neither key: Moon's own configuration carries no
38
+ * plugin-options table, and the `archkeep.json` that would state them is
39
+ * refused beside `.moon/`. What it gets instead is convention — and the one
40
+ * name whose convention is an ordered CHAIN rather than a single default is
41
+ * argued at `MOON_TSCONFIG_CHAIN` below, with `readMoonOptions` as the reader.
42
+ *
43
+ * ## What is deliberately NOT an option: the language list
44
+ *
45
+ * The obvious next key is `languages: ["go", "rust"]`, and it must not exist.
46
+ * Switching a language off there is indistinguishable, in every report this
47
+ * tool prints, from that language having no violations — which is the exact
48
+ * silence the whole project exists to end (`AGENTS.md`: an empty result is a
49
+ * claim, not a shrug). A workspace with no Go pays nothing for Go support
50
+ * already: each resolver keys off the manifests that exist, so it finds none
51
+ * and does nothing. There is no cost to buy off, and the option would only sell
52
+ * a way to turn enforcement off where nobody would see it happen.
53
+ *
54
+ * ## Why an unknown key throws
55
+ *
56
+ * `./config.mjs` takes the same posture with `depConstraints`, for the same
57
+ * reason. A `tsconfigBase` typed for `tsConfig` that quietly fell back to the
58
+ * default would produce a full green run against a rule nobody wrote. The
59
+ * failure has to arrive at the first `nx` invocation, naming the key.
60
+ *
61
+ * ## The one field here that no workspace writes: `boundaryConfigDeclared`
62
+ *
63
+ * Merging the declaration over the default is what makes every reader
64
+ * downstream simple — one string, always present, never a fallback to
65
+ * re-derive. It also destroys the only fact that separates two workspaces
66
+ * whose resolved options are byte-identical: one that never named a boundary
67
+ * law and takes `DEFAULT_OPTIONS.boundaryConfig` by Nx convention, and one
68
+ * that named `policy-we-declared.mjs` and then renamed or deleted it. A
69
+ * command that may tolerate the first must not tolerate the second, and with
70
+ * the provenance merged away it cannot tell them apart — measured on `graph`,
71
+ * which answered exit 0 with no `policy` field on a native tree declaring a
72
+ * `boundaryConfig` that was not there, byte-identical to a tree that never
73
+ * had a law.
74
+ *
75
+ * So the bit rides out alongside the two names: `true` when the caller's own
76
+ * object carried a `boundaryConfig` key, `false` when the value came from
77
+ * `DEFAULT_OPTIONS`. It is deliberately NOT a member of `DEFAULT_OPTIONS`,
78
+ * because that object is also the known-key roster the unknown-key check
79
+ * above reads: adding it there would make `boundaryConfigDeclared: true` a
80
+ * spelling a consumer could write into `nx.json`, which is a workspace
81
+ * asserting its own provenance — the one claim this field exists to compute
82
+ * rather than accept. Written into a `plugins[].options` table it throws like
83
+ * any other unknown key.
84
+ *
85
+ * `./providers/native/model.mjs` computes the same bit for `archkeep.json`'s
86
+ * own `boundaryConfig` field (including the inline-policy spelling, which
87
+ * never reaches `resolveOptions` at all) and `./commands/context.mjs` is what
88
+ * carries it onto `CommandContext.options` for all three providers — each
89
+ * argued where it is written, not restated here.
90
+ */
91
+ import { existsSync, readFileSync } from "node:fs";
92
+ import { join } from "node:path";
93
+
94
+ import { languageOf } from "./analysis/registry.mjs";
95
+
96
+ import { containmentViolation } from "./containment.mjs";
97
+ import { parseNxJson } from "./nx-json.mjs";
98
+
99
+ /**
100
+ * The Nx conventions, so a workspace that follows them writes no options at
101
+ * all. Frozen: a caller that mutated this would move the default for every
102
+ * later reader in the process.
103
+ */
104
+ export const DEFAULT_OPTIONS = Object.freeze({
105
+ /** The workspace's module-boundary law — the table ESLint reads too. */
106
+ boundaryConfig: "module-boundaries.config.mjs",
107
+ /** Where the workspace's shared `compilerOptions` and `paths` live. */
108
+ tsConfig: "tsconfig.base.json",
109
+ /**
110
+ * The workspace's named-profile registry, when it uses one — see
111
+ * `./governance/profile-registry.mjs`. Absent (`undefined`) means the
112
+ * workspace enforces by file, exactly as before; present, the value of
113
+ * `boundaryConfig` becomes a profile NAME selected from this registry (the
114
+ * check command's "select by name instead of by file" — documented in
115
+ * `../../../docs/concepts/profiles.md`).
116
+ */
117
+ profiles: undefined,
118
+ });
119
+
120
+ /** The file Nx reads to learn a workspace exists — and where the options live. */
121
+ export const NX_CONFIG_FILE = "nx.json";
122
+
123
+ /**
124
+ * Merges a raw options object over the defaults, refusing anything it does not
125
+ * recognise.
126
+ *
127
+ * Pure, and takes the object rather than a path, for the reason every gate in
128
+ * this repository takes its facts as arguments: a function that reads a file
129
+ * AND decides something has to be split before it can be tested, and the split
130
+ * is the improvement. `readPluginOptions` below is the only half that touches
131
+ * a filesystem.
132
+ *
133
+ * @param {object|undefined|null} rawOptions Whatever `nx.json` carried, or
134
+ * whatever Nx handed the hook. Absent is legal and means "all defaults".
135
+ * @returns {{boundaryConfig: string, tsConfig: string, profiles?: string,
136
+ * boundaryConfigDeclared: boolean}} `boundaryConfigDeclared` is the
137
+ * provenance of `boundaryConfig` and nothing else — `true` only when
138
+ * `rawOptions` carried the key itself. See the module header, "The one
139
+ * field here that no workspace writes".
140
+ * @throws {Error} on an unknown key, a non-object, or a value that is not a
141
+ * non-empty string. An empty string would build `<root>/` and read a
142
+ * directory as a config, which fails somewhere far from the typo.
143
+ */
144
+ export function resolveOptions(rawOptions) {
145
+ if (rawOptions === undefined || rawOptions === null) {
146
+ return { ...DEFAULT_OPTIONS, boundaryConfigDeclared: false };
147
+ }
148
+ if (typeof rawOptions !== "object" || Array.isArray(rawOptions)) {
149
+ throw new Error(
150
+ `archkeep: plugin options must be an object, got ${Array.isArray(rawOptions) ? "an array" : typeof rawOptions}`,
151
+ );
152
+ }
153
+
154
+ // `known` stays exactly `DEFAULT_OPTIONS`' keys, which is what keeps
155
+ // `boundaryConfigDeclared` an OUTPUT: it is not in that object, so a
156
+ // `plugins[].options` table naming it hits the unknown-key throw below.
157
+ const known = Object.keys(DEFAULT_OPTIONS);
158
+ const resolved = {
159
+ ...DEFAULT_OPTIONS,
160
+ boundaryConfigDeclared: Object.hasOwn(rawOptions, "boundaryConfig"),
161
+ };
162
+ for (const [key, value] of Object.entries(rawOptions)) {
163
+ if (!known.includes(key)) {
164
+ throw new Error(
165
+ `archkeep: unknown plugin option '${key}' — expected one of ${known.join(", ")}. ` +
166
+ `Refused rather than ignored: an option that falls back to its default in silence is a ` +
167
+ `green run against a rule nobody wrote.`,
168
+ );
169
+ }
170
+ if (typeof value !== "string" || value === "") {
171
+ throw new Error(
172
+ `archkeep: plugin option '${key}' must be a non-empty string, got ` +
173
+ `${value === "" ? "an empty string" : typeof value}`,
174
+ );
175
+ }
176
+ resolved[key] = value;
177
+ }
178
+ return resolved;
179
+ }
180
+
181
+ /**
182
+ * This plugin's own entry in an `nx.json` `plugins` array, in either form Nx
183
+ * accepts: a bare string, or `{plugin, options}`.
184
+ *
185
+ * Matched on the specifier's TAIL rather than by equality, because the same
186
+ * plugin is named three legitimate ways — `@ecoma-io/archkeep/nx` once it
187
+ * resolves from the registry, `./packages/archkeep/nx.mjs` inside this
188
+ * repository, and a path with or without the `.mjs` in a workspace that
189
+ * vendored it. Requiring one spelling would make the options invisible in the
190
+ * other two, and invisible options mean the defaults — silently.
191
+ *
192
+ * The bare package specifier (`@ecoma-io/archkeep`, with no `/nx`) does NOT
193
+ * match: that resolves to the engine entry, which exports neither `name` nor
194
+ * `createDependencies`, so Nx would not have loaded a plugin at all — an entry
195
+ * this function accepted there would claim options for a plugin Nx never ran.
196
+ *
197
+ * @param {unknown} entry One element of `nx.json`'s `plugins`.
198
+ * @returns {boolean}
199
+ */
200
+ function namesThisPlugin(entry) {
201
+ const specifier =
202
+ typeof entry === "string" ? entry : /** @type {{ plugin?: unknown }} */ (entry)?.plugin;
203
+ if (typeof specifier !== "string") return false;
204
+ const withoutExt = specifier.replace(/\.mjs$/u, "");
205
+ return withoutExt === "archkeep/nx" || withoutExt.endsWith("/archkeep/nx");
206
+ }
207
+
208
+ /**
209
+ * `nx.json`'s parsed contents, or `null` when the workspace has none.
210
+ *
211
+ * The one place this file spawns a read and a parse, so `readPluginOptions`
212
+ * and `readWorkspaceLayout` below — two independent readers of the same file
213
+ * — cannot disagree about whether it is readable: both go through here, and
214
+ * both therefore throw the identical `cannot read <path>` message on the same
215
+ * malformed input rather than each carrying its own copy that could drift.
216
+ *
217
+ * @param {string} workspaceRoot
218
+ * @param {(path: string) => string|null} readFile
219
+ * @returns {object|null}
220
+ * @throws {Error} when the file exists but `parseNxJson` cannot read it.
221
+ */
222
+ function readNxJsonOrNull(workspaceRoot, readFile) {
223
+ const path = `${String(workspaceRoot).replace(/\/$/u, "")}/${NX_CONFIG_FILE}`;
224
+ // A tracked symlink at `nx.json` whose realpath leaves the workspace would
225
+ // hand outside bytes in as the workspace's own registration — a whole
226
+ // options read, and every verdict downstream of it, built on
227
+ // attacker-controlled input and reported clean. Refusing turns that silent
228
+ // read into a loud "cannot read" throw (`../containment.mjs`'s
229
+ // `containmentViolation`, the read-side G-10 closure). The check applies to
230
+ // every read of a real root: an injected in-memory reader a test drives is
231
+ // keyed by a fixture path (`/w`, `/fixture`) that does not exist on disk, so
232
+ // `existsSync(workspaceRoot)` is what keeps that seam untrodden, not a sentinel
233
+ // on the reader — `pluginIsRegistered`'s real-fs `readFileAbsolute` is a
234
+ // different function from `readFileOrNull` and must be contained too.
235
+ const violation = existsSync(workspaceRoot) ? containmentViolation(workspaceRoot, path) : null;
236
+ if (violation !== null) throw new Error(`archkeep: cannot read ${path}: ${violation}`);
237
+ const text = readFile(path);
238
+ if (text === null) return null;
239
+ try {
240
+ return parseNxJson(text);
241
+ } catch (cause) {
242
+ throw new Error(`archkeep: cannot read ${path}: ${cause?.message ?? cause}`, {
243
+ cause,
244
+ });
245
+ }
246
+ }
247
+
248
+ /**
249
+ * The resolved options a workspace root declares.
250
+ *
251
+ * The read is injectable, which is what keeps the one filesystem-touching
252
+ * function here testable without a tree on disk: `readFile` takes an absolute
253
+ * path and answers `null` for a file that is not there, so a test states an
254
+ * `nx.json` as a string and every branch below is exercised over it. The
255
+ * default reader is the only code in this module that reaches outside the
256
+ * process.
257
+ *
258
+ * A workspace with no `nx.json`, no `plugins` array, or no entry for this
259
+ * plugin gets the defaults. That is not the silent fallback the unknown-key
260
+ * check refuses: the CLI and the language server are both usable in a tree that
261
+ * never registered the plugin at all, and defaulting there is the Nx convention
262
+ * being what it is. A `plugins` entry that IS present and carries a bad option
263
+ * still throws.
264
+ *
265
+ * @param {string} workspaceRoot Absolute path of the tree being judged — never
266
+ * derived from this file's own location, for the reason `./config.mjs` gives.
267
+ * @param {{readFile?: (path: string) => string|null}} [io] Injectable read, so
268
+ * `resolveOptions`'s callers can be driven over a tree that is not on disk.
269
+ * @returns {{boundaryConfig: string, tsConfig: string, profiles?: string,
270
+ * boundaryConfigDeclared: boolean}} The three no-registration states below
271
+ * all answer `boundaryConfigDeclared: false`, because none of them is a
272
+ * workspace naming a law — see the module header.
273
+ * @throws {Error} when `nx.json` is unparseable, or its options are malformed.
274
+ * Loud on purpose, and the same posture as everywhere else here: a tool that
275
+ * could not read its own configuration must not answer as though it had.
276
+ */
277
+ export function readPluginOptions(workspaceRoot, { readFile = readFileOrNull } = {}) {
278
+ const nxJson = readNxJsonOrNull(workspaceRoot, readFile);
279
+ // The three "nothing registered here" exits go through `resolveOptions`
280
+ // rather than spreading `DEFAULT_OPTIONS` directly, so the shape this
281
+ // function returns is produced in exactly one place. A spread would have to
282
+ // remember `boundaryConfigDeclared: false` three separate times, and the
283
+ // one that forgot it would answer `undefined` — a provenance nobody wrote,
284
+ // read downstream as whichever direction that caller's `??`/`!==` happened
285
+ // to fall.
286
+ if (nxJson === null) return resolveOptions(undefined);
287
+
288
+ const plugins = Array.isArray(nxJson?.plugins) ? nxJson.plugins : [];
289
+ const entry = plugins.find(namesThisPlugin);
290
+ if (entry === undefined) return resolveOptions(undefined);
291
+ return resolveOptions(typeof entry === "string" ? undefined : entry.options);
292
+ }
293
+
294
+ /**
295
+ * The tsconfig filenames a Moon workspace is read against, in the order they
296
+ * are tried — first one that exists wins.
297
+ *
298
+ * Moon is the one provider with nowhere to state the name. Nx states it in
299
+ * `nx.json` → `plugins[].options.tsConfig` and a native root states it on
300
+ * `archkeep.json`'s own `tsConfig` field; Moon's own configuration carries no
301
+ * plugin-options table, and a `archkeep.json` beside `.moon/` is refused
302
+ * outright (`./commands/context.mjs`'s `requireSingleProjectModel`), so every
303
+ * door to a stated name is shut. What is left is convention, and one name is
304
+ * not enough of it: measured on a 94-project Vue Moon workspace whose `paths`
305
+ * table lives in `tsconfig.json` with no `tsconfig.base.json` beside it, a
306
+ * provider fixed at the first name alone read the absent file, fell back to
307
+ * `ts.resolveModuleName`'s compiler defaults, resolved every internal
308
+ * specifier to nothing, and reported several hundred findings on a tree with
309
+ * no architecture violation in it.
310
+ *
311
+ * The order is the point, not the membership: `tsconfig.base.json` is the Nx
312
+ * convention this tool's default already names, and a workspace carrying both
313
+ * files means the base one — `tsconfig.json` there is the editor's own
314
+ * per-root config, which typically `extends` it. "Whichever we find" would
315
+ * make the answer depend on the order two names happen to be written in.
316
+ *
317
+ * Extending the chain is a compatibility decision, not a lookup detail: a
318
+ * third name added here changes the verdict of an unchanged workspace that
319
+ * carries it. `../../../docs/integrations/moon.md`'s Configuration section
320
+ * owns the consumer-facing statement of both the chain and what it does not
321
+ * solve.
322
+ *
323
+ * The first entry is `DEFAULT_OPTIONS.tsConfig` itself rather than a second
324
+ * spelling of the same string: the two must never disagree about which name
325
+ * the convention starts at.
326
+ */
327
+ export const MOON_TSCONFIG_CHAIN = Object.freeze([DEFAULT_OPTIONS.tsConfig, "tsconfig.json"]);
328
+
329
+ /**
330
+ * The `tsConfigSource` a Moon workspace's options carry — the provenance
331
+ * field beside `tsConfig`, the same shape `./commands/graph.mjs`'s
332
+ * `workspaceLayoutSource` is to `workspaceLayout`.
333
+ *
334
+ * It exists because the name alone is not the whole fact. Two machines
335
+ * checking out the same Moon workspace, one of them with an untracked
336
+ * `tsconfig.json` in it, resolve different `paths` tables and report
337
+ * different verdicts; with only the resolved name carried, a reader cannot
338
+ * tell a name the workspace stated from a name this tool picked off a chain.
339
+ * Nx and native options carry no such field — there the name IS stated — so
340
+ * its presence is itself the "this was convention, not a declaration" fact.
341
+ *
342
+ * `./lsp/server.mjs`'s `watchedFilesFor` reads it for a second reason argued
343
+ * there: the chain is ordered, so the file that would WIN can change without
344
+ * the file that is currently chosen ever being touched.
345
+ */
346
+ export const MOON_TSCONFIG_SOURCE = "moon-convention";
347
+
348
+ /**
349
+ * The extensions whose absence of a tsconfig is genuinely ambiguous — the
350
+ * only files that make `readMoonOptions` refuse.
351
+ *
352
+ * Two exclusions, both deliberate, and the second is the reason this is a
353
+ * list of EXTENSIONS rather than the obvious list of languages:
354
+ *
355
+ * - Go, Rust and Python resolve through their own manifests and never read a
356
+ * tsconfig at all, so a Moon workspace of those three alone must not be
357
+ * refused for lacking a file nothing in it would have read.
358
+ * - **`.js`, `.jsx`, `.mjs` and `.cjs` are absent even though
359
+ * `LANGUAGE_BY_EXTENSION` calls all four `typescript`**, because that table
360
+ * answers "which analyzer reads this" and the question here is a different
361
+ * one: "does a missing tsconfig mean we failed to find the paths table, or
362
+ * that there is no paths table?" For a `.ts` file the answer is ambiguous —
363
+ * `tsc` cannot run without a config, so one almost certainly exists
364
+ * somewhere and not finding it is evidence of a miss. For a plain-JS
365
+ * workspace it is not ambiguous at all: JavaScript needs no tsconfig, most
366
+ * such trees have never had one, and every relative and package specifier
367
+ * in them resolves correctly against the compiler defaults today. Refusing
368
+ * those would turn a run that is currently CORRECT into exit 3 — a
369
+ * regression wearing a hardening's clothes, which is the one thing this
370
+ * guard must not be.
371
+ *
372
+ * `.vue` is included because the Vue analyzer hands its `<script>` block to
373
+ * the TypeScript one (`./analysis/vue.mjs`), which resolves it against the
374
+ * same table.
375
+ *
376
+ * This is a second copy of extension knowledge that `./analysis/registry.mjs`
377
+ * otherwise owns, so the filter below runs `languageOf` FIRST and this list
378
+ * only ever narrows what the registry already claimed. An entry naming an
379
+ * extension the registry does not claim therefore cannot widen the refusal —
380
+ * it simply never matches — and `./options.test.mjs` pins that, probing
381
+ * extensions from outside the registry as well as inside it, because a probe
382
+ * set drawn only from the registry's own keys could not tell the two cases
383
+ * apart.
384
+ */
385
+ const TSCONFIG_RESOLVED_EXTENSIONS = Object.freeze([".ts", ".tsx", ".mts", ".cts", ".vue"]);
386
+
387
+ /**
388
+ * The resolved options for a Moon workspace root.
389
+ *
390
+ * Every field is convention: Moon has no place to state either name, so this
391
+ * function is where both are decided rather than read. `boundaryConfig` is
392
+ * the default outright and `boundaryConfigDeclared` is therefore `false` — a
393
+ * fact about Moon, not a fallback — while `tsConfig` walks
394
+ * `MOON_TSCONFIG_CHAIN` and reports which entry answered through
395
+ * `tsConfigSource`.
396
+ *
397
+ * **Neither candidate present, in a workspace with files that resolve
398
+ * through a `paths` table, THROWS.** That is a change of behaviour and the
399
+ * point of this function: before it, such a tree was judged against
400
+ * TypeScript's compiler defaults, where an aliased specifier resolves to
401
+ * nothing, every internal import reads as a boundary crossing, and the report
402
+ * is a wall of findings with no line anywhere saying the paths table was
403
+ * never found. A tool that could not resolve the workspace's own imports must
404
+ * not answer as though it had (`../../../AGENTS.md`, "The invariant everything
405
+ * is judged against") — so the run stops, naming both candidate names and the
406
+ * files that needed one.
407
+ *
408
+ * A Moon workspace with no such file is NOT refused: it would be refused for
409
+ * lacking a config nothing in it reads. That covers Go, Rust and Python — and
410
+ * also a plain-JavaScript tree, because `TSCONFIG_RESOLVED_EXTENSIONS` is
411
+ * narrower than the language table on purpose; its own comment argues why.
412
+ * There the chain's first entry is carried as the name, so the watcher list
413
+ * `./lsp/server.mjs` derives still covers the file that would change the
414
+ * answer if it arrived.
415
+ *
416
+ * @param {string} workspaceRoot Absolute path of the tree being judged.
417
+ * @param {{exists?: (path: string) => boolean, listFiles: () => string[]}} io
418
+ * `exists` is plain filesystem existence, the same test every other marker
419
+ * is read by, injectable so a test drives the chain with no tree on disk.
420
+ * `listFiles` is REQUIRED and a thunk rather than an array: it is called
421
+ * only on the one branch that needs it — neither candidate present — so a
422
+ * workspace that carries one pays nothing for the question, and this module
423
+ * never grows an import of `./workspace.mjs`, which imports it back.
424
+ * @returns {{boundaryConfig: string, tsConfig: string, tsConfigSource: string,
425
+ * boundaryConfigDeclared: boolean}}
426
+ * @throws {Error} when no chain entry exists and the tracked files include a
427
+ * language that resolves through the `paths` table.
428
+ */
429
+ export function readMoonOptions(workspaceRoot, { exists = existsSync, listFiles }) {
430
+ const found = MOON_TSCONFIG_CHAIN.find((name) => exists(join(workspaceRoot, name)));
431
+ if (found === undefined) {
432
+ // Two conditions, and the first is what keeps this list strictly NARROWER
433
+ // than the analyzer registry rather than merely different from it.
434
+ // `languageOf` owns the matching rule — last dot of the basename — so a
435
+ // path whose whole basename is `.ts` resolves to `null` there and is
436
+ // skipped here too, instead of triggering a refusal for a file no
437
+ // analyzer would ever read.
438
+ const needing = listFiles().filter(
439
+ (file) =>
440
+ languageOf(file) !== null &&
441
+ TSCONFIG_RESOLVED_EXTENSIONS.some((extension) => file.endsWith(extension)),
442
+ );
443
+ if (needing.length > 0) {
444
+ throw new Error(
445
+ `archkeep: ${workspaceRoot} is a Moon workspace carrying ${needing.length} file` +
446
+ `${needing.length === 1 ? "" : "s"} that resolve` +
447
+ `${needing.length === 1 ? "s" : ""} through a TypeScript paths table ` +
448
+ `(${needing.slice(0, 3).join(", ")}${needing.length > 3 ? ", …" : ""}), and none of ` +
449
+ `${MOON_TSCONFIG_CHAIN.join(" or ")} is there to read it from. Moon carries no ` +
450
+ `plugin-options table to name one under and a archkeep.json beside .moon is refused, ` +
451
+ `so those names are the whole convention. Refused rather than judged: read against ` +
452
+ `the compiler defaults instead, every aliased import resolves to nothing and the run ` +
453
+ `reports a boundary crossing for each one — a wall of findings on a workspace that ` +
454
+ `may have no violation in it at all. Add ${MOON_TSCONFIG_CHAIN[0]} at the workspace ` +
455
+ `root, or rename the config that already holds the paths table to one of those two ` +
456
+ `names. The Moon integration guide in this tool's documentation ` +
457
+ `covers both routes.`,
458
+ );
459
+ }
460
+ }
461
+ return {
462
+ boundaryConfig: DEFAULT_OPTIONS.boundaryConfig,
463
+ // No candidate and nothing that would have read one: the chain's first
464
+ // entry is the name carried, so the file whose ARRIVAL would change the
465
+ // answer is the file the watcher list already covers.
466
+ tsConfig: found ?? MOON_TSCONFIG_CHAIN[0],
467
+ tsConfigSource: MOON_TSCONFIG_SOURCE,
468
+ boundaryConfigDeclared: false,
469
+ };
470
+ }
471
+
472
+ /**
473
+ * The two keys `nx.json`'s `workspaceLayout` may carry — the same pair
474
+ * `./providers/native/model.mjs`'s `WORKSPACE_LAYOUT_KEYS` validates for
475
+ * `archkeep.json`'s identically-named field, kept as two separate constants
476
+ * because the two files sit on opposite sides of the layer boundary
477
+ * `packages/archkeep/AGENTS.md` draws (`src/options.mjs` owns what a workspace
478
+ * may tell this tool about ITSELF via Nx's own config; `src/providers/native/`
479
+ * owns the `archkeep.json` dialect) — a shared constant would import one layer
480
+ * into the other for two frozen strings.
481
+ */
482
+ const WORKSPACE_LAYOUT_KEYS = Object.freeze(["appsDir", "libsDir"]);
483
+
484
+ /**
485
+ * `nx.json`'s `workspaceLayout`, exactly as declared — never merged with a
486
+ * default and never required to be complete. A caller downstream has to be
487
+ * able to tell "declared nothing" from "declared the default", which is why
488
+ * this returns `null` for the former and the declared object verbatim
489
+ * (partial or not) for the latter, rather than folding either case into
490
+ * `./rules/specifiers.mjs`'s `DEFAULT_WORKSPACE_LAYOUT`. Whether a PARTIAL
491
+ * declaration is usable is a different, narrower question — this reader only
492
+ * answers "is what's here well-formed" — and `requireCompleteWorkspaceLayout`
493
+ * below answers the narrower one for the two callers that need it.
494
+ *
495
+ * @param {string} workspaceRoot
496
+ * @param {{readFile?: (path: string) => string|null}} [io]
497
+ * @returns {{appsDir?: string, libsDir?: string}|null} `null` when the
498
+ * workspace has no `nx.json`, or its `nx.json` declares no `workspaceLayout`
499
+ * key.
500
+ * @throws {Error} when `workspaceLayout` is present but is not a plain
501
+ * object, names a key other than `appsDir`/`libsDir`, or gives either key a
502
+ * non-string or empty-string value. Reading any of those as "no layout
503
+ * declared" would silence `noRelativeOrAbsoluteImportsAcrossLibraries` on
504
+ * exactly the workspace whose configuration is broken — the same reasoning
505
+ * `./providers/native/model.mjs`'s `workspaceLayoutViolations` already
506
+ * applies to `archkeep.json`'s identically-shaped field.
507
+ */
508
+ export function readWorkspaceLayout(workspaceRoot, { readFile = readFileOrNull } = {}) {
509
+ const nxJson = readNxJsonOrNull(workspaceRoot, readFile);
510
+ if (nxJson === null) return null;
511
+ const declared = /** @type {{workspaceLayout?: unknown}} */ (nxJson)?.workspaceLayout;
512
+ if (declared === undefined) return null;
513
+ if (typeof declared !== "object" || declared === null || Array.isArray(declared)) {
514
+ throw new Error(
515
+ `archkeep: nx.json's workspaceLayout must be an object, got ` +
516
+ `${Array.isArray(declared) ? "an array" : typeof declared}`,
517
+ );
518
+ }
519
+ for (const key of Object.keys(declared)) {
520
+ if (!WORKSPACE_LAYOUT_KEYS.includes(key)) {
521
+ throw new Error(
522
+ `archkeep: nx.json's workspaceLayout.${key} is not a workspaceLayout field — expected ` +
523
+ `one of ${WORKSPACE_LAYOUT_KEYS.join(", ")}`,
524
+ );
525
+ }
526
+ const value = /** @type {Record<string, unknown>} */ (declared)[key];
527
+ if (typeof value !== "string" || value === "") {
528
+ throw new Error(
529
+ `archkeep: nx.json's workspaceLayout.${key} must be a non-empty string, got ` +
530
+ `${value === "" ? "an empty string" : typeof value}`,
531
+ );
532
+ }
533
+ }
534
+ return /** @type {{appsDir?: string, libsDir?: string}} */ (declared);
535
+ }
536
+
537
+ /**
538
+ * Narrows a `readWorkspaceLayout` result to "usable by the rule engine":
539
+ * either nothing declared (`null`, unchanged) or BOTH `appsDir` and `libsDir`
540
+ * present. `./rules/specifiers.mjs`'s `isAbsoluteImportIntoAnotherProject`
541
+ * reads both keys off one object with no per-key fallback — a workspace
542
+ * declaring only `libsDir` would silently evaluate `appsDir` as `undefined`
543
+ * and never match a real `apps/` import, the exact silent degradation
544
+ * `AGENTS.md`'s invariant rules out.
545
+ *
546
+ * This is also the parity point between the two providers:
547
+ * `./providers/native/model.mjs`'s `workspaceLayoutViolations` already
548
+ * refuses an incomplete `workspaceLayout` for `archkeep.json` — it never
549
+ * merges a partial declaration onto a default, it rejects the whole file — so
550
+ * `./providers/nx.mjs` and `./lsp/workspace-index.mjs` both call this
551
+ * function rather than each deciding independently, and the same declared
552
+ * object is accepted or refused identically by both.
553
+ *
554
+ * @param {{appsDir?: string, libsDir?: string}|null} declared
555
+ * @returns {{appsDir: string, libsDir: string}|null}
556
+ * @throws {Error} when `declared` is non-null but missing `appsDir` or
557
+ * `libsDir`.
558
+ */
559
+ export function requireCompleteWorkspaceLayout(declared) {
560
+ if (declared === null) return null;
561
+ const missing = WORKSPACE_LAYOUT_KEYS.filter((key) => !(key in declared));
562
+ if (missing.length === 0) {
563
+ return /** @type {{appsDir: string, libsDir: string}} */ (declared);
564
+ }
565
+ throw new Error(
566
+ `archkeep: nx.json's workspaceLayout declares ${Object.keys(declared).join(", ") || "nothing"} ` +
567
+ `but is missing ${missing.join(", ")} — declare both appsDir and libsDir, or neither.`,
568
+ );
569
+ }
570
+
571
+ /**
572
+ * Whether `workspaceRoot`'s `nx.json` registers THIS plugin — the other half
573
+ * of the unregistered-plugin gap `./workspace.mjs`'s `polyglotManifests`
574
+ * names the manifests for. A workspace can carry a tracked `go.mod` and never
575
+ * have told Nx to look, and that tree's `nx affected` under-selects with no
576
+ * warning at all — measured, the same silence `AGENTS.md`'s empty-result
577
+ * invariant refuses. This function only answers the yes/no; it is exported
578
+ * and tested on its own, and it is not currently consulted by `check`'s
579
+ * refusal logic — a later caller wires the two facts together.
580
+ *
581
+ * Shares `readPluginOptions`'s reader convention deliberately: `readFile`
582
+ * takes an absolute path and answers `null` for a file that is not there, the
583
+ * same default as that function, so a caller that already has one reader for
584
+ * this workspace's `nx.json` uses it for both calls.
585
+ *
586
+ * @param {string} workspaceRoot Absolute path of the tree being judged.
587
+ * @param {{readFile?: (path: string) => string|null}} [io]
588
+ * @returns {boolean} `false` for no `nx.json`, no `plugins` array, or no
589
+ * entry naming this plugin — the same three no-registration states
590
+ * `readPluginOptions` treats as "use the defaults". `true` for either form
591
+ * `namesThisPlugin` accepts: a bare string, or `{plugin, options}`.
592
+ * @throws {Error} when `nx.json` exists but cannot be parsed — the same
593
+ * posture as `readPluginOptions`: a tool that could not read its own
594
+ * configuration must not answer as though it had.
595
+ */
596
+ export function pluginIsRegistered(workspaceRoot, { readFile = readFileOrNull } = {}) {
597
+ const nxJson = readNxJsonOrNull(workspaceRoot, readFile);
598
+ if (nxJson === null) return false;
599
+
600
+ const plugins = Array.isArray(nxJson?.plugins) ? nxJson.plugins : [];
601
+ return plugins.some(namesThisPlugin);
602
+ }
603
+
604
+ /** A file's contents, or `null` when it does not exist or cannot be read. */
605
+ function readFileOrNull(path) {
606
+ try {
607
+ return readFileSync(path, "utf8");
608
+ } catch {
609
+ return null;
610
+ }
611
+ }