@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,790 @@
1
+ /**
2
+ * The preamble every command shares: which workspace, which provider, which
3
+ * files, and what a whole-tree read of them found. `../../cli.mjs`'s `check`
4
+ * built exactly this before `--format json` existed; this module is that
5
+ * preamble lifted out so a second command can reuse it rather than reimplement
6
+ * it a rule at a time. What is deliberately NOT here: the boundary policy
7
+ * (`../config.mjs`'s `loadBoundaryConfig`/`loadBoundaryConfigFile`), the
8
+ * go.work drift check, and the tsconfig paths hygiene check — all three are
9
+ * `check`'s own concerns, judged from what this module hands back, not part of
10
+ * establishing "which tree, which files, what did reading them find" that a
11
+ * later command (`graph`, `explain`, `impact`, `diff`) needs identically.
12
+ *
13
+ * Everything below is a straight extraction: no branch here changes what
14
+ * `check` decided before this module existed, because the byte-for-byte
15
+ * unchanged verdict on an unchanged tree is itself part of the contract
16
+ * (`../../../../AGENTS.md`, "a change to what is reported on an unchanged
17
+ * workspace is a breaking change").
18
+ *
19
+ * `../providers/native/index.mjs`'s header states that module imports nothing
20
+ * from `../workspace.mjs`; this module is the one place both a provider and
21
+ * `../workspace.mjs` are composed, which is why it lives beside the commands
22
+ * that need the composition rather than inside either provider
23
+ * (`./README.md`).
24
+ */
25
+ import { existsSync, readFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+
28
+ import { containmentViolation } from "../containment.mjs";
29
+ import { languageOf } from "../analysis/registry.mjs";
30
+ import { pythonUnmodelledFailures } from "../analysis/python.mjs";
31
+ import { fileFailure } from "../analysis/source-util.mjs";
32
+ import {
33
+ DEFAULT_OPTIONS,
34
+ NX_CONFIG_FILE,
35
+ pluginIsRegistered,
36
+ readMoonOptions,
37
+ readPluginOptions,
38
+ } from "../options.mjs";
39
+ import { readProjectGraph } from "../providers/nx.mjs";
40
+ import { ARCHKEEP_MODEL_FILE } from "../providers/native/model.mjs";
41
+ import {
42
+ MOON_DIR,
43
+ MOON_ALT_DIR,
44
+ mergeImportEdges,
45
+ moonMarkerAt,
46
+ moonProvider,
47
+ } from "../providers/moon.mjs";
48
+ import { nativeProvider } from "../providers/native/index.mjs";
49
+ import {
50
+ analyzeWorkspace,
51
+ annotateMFERemotes,
52
+ annotatePackageFacts,
53
+ createWorkspace,
54
+ findWorkspaceRoot,
55
+ listTrackedFiles,
56
+ polyglotManifests,
57
+ selectFiles,
58
+ } from "../workspace.mjs";
59
+
60
+ /**
61
+ * Workspace-relative read from `root` — the same default `createWorkspace`
62
+ * builds when no reader is injected (`../workspace.mjs`), duplicated here
63
+ * rather than imported because it is needed BEFORE a `Workspace` exists to
64
+ * read from: `nativeProvider.discover` needs one to load `archkeep.json`
65
+ * itself.
66
+ *
67
+ * Carries the same containment rule as that default reader: a tracked symlink
68
+ * whose realpath leaves the workspace would hand the reader outside bytes as
69
+ * the workspace's own declaration — a model file read that way is a whole
70
+ * verdict built on attacker-controlled input, reported clean. Refusing (null)
71
+ * makes the read a loud "cannot load" failure rather than a silent
72
+ * read-and-judge (`../containment.mjs`, the G-10 closure).
73
+ *
74
+ * @param {string} root
75
+ * @returns {(path: string) => string|null}
76
+ */
77
+ function readWorkspaceRoot(root) {
78
+ return (path) => {
79
+ const abs = join(root, path);
80
+ if (containmentViolation(root, abs) !== null) return null;
81
+ try {
82
+ return readFileSync(abs, "utf8");
83
+ } catch {
84
+ return null;
85
+ }
86
+ };
87
+ }
88
+
89
+ /**
90
+ * The real-filesystem reader `pluginIsRegistered` (`../options.mjs`) gets
91
+ * when no seam overrides it. Unlike `readWorkspaceRoot` above, this one takes
92
+ * an ALREADY-ABSOLUTE path: `pluginIsRegistered` builds
93
+ * `${workspaceRoot}/${NX_CONFIG_FILE}` itself before calling its reader, so a
94
+ * `join(root, path)`-style reader would double the root onto an already-full
95
+ * path.
96
+ *
97
+ * @param {string} path
98
+ * @returns {string|null}
99
+ */
100
+ function readFileAbsolute(path) {
101
+ try {
102
+ return readFileSync(path, "utf8");
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Which Moon directory marks `root` — or none. Presence facts only; see
110
+ * `requireSingleProjectModel` below for the one-decision gate.
111
+ *
112
+ * @param {string} root
113
+ * @returns {{hasNx: boolean, hasNative: boolean, hasMoon: boolean}}
114
+ */
115
+ export function markersAt(root) {
116
+ return {
117
+ hasNx: existsSync(join(root, NX_CONFIG_FILE)),
118
+ hasNative: existsSync(join(root, ARCHKEEP_MODEL_FILE)),
119
+ hasMoon: moonMarkerAt(root) !== null,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * The one gate deciding whether `root` may be judged at all: more than ONE
125
+ * project-model marker present is refused, naming what conflicts.
126
+ *
127
+ * Every entry point that picks a provider must answer this identically —
128
+ * `resolveCommandContext` below reads it before any command runs, and
129
+ * `../lsp/workspace-index.mjs`'s index build reads it before choosing a
130
+ * branch. A second copy of the condition was exactly how the faces drifted
131
+ * apart once: the CLI refused a tree carrying a Moon directory beside
132
+ * `nx.json`/`archkeep.json` while the editor indexed it anyway — a clean
133
+ * diagnostic list over a tree nobody agreed could be judged (#223's silent
134
+ * shape, one level up). Moon-versus-Moon coexistence (`.moon/` AND
135
+ * `.config/moon/`) is refused inside `../providers/moon.mjs`'s
136
+ * `moonMarkerAt`, which this gate calls first; the cross-family pairs are
137
+ * refused here, all in the same terms: which model to judge against is a
138
+ * decision nobody made, not one this tool can make for them.
139
+ *
140
+ * @param {string} root
141
+ * @param {{exists?: (path: string) => boolean}} [io] Injectable existence
142
+ * test (absolute paths), so a test drives this without a filesystem.
143
+ * @returns {{hasNx: boolean, hasNative: boolean, moonMarker: string|null}}
144
+ * The facts a provider choice needs; `moonMarker` names whichever Moon
145
+ * directory is present, `null` when neither spelling is.
146
+ * @throws {Error} when more than one marker is present.
147
+ */
148
+ export function requireSingleProjectModel(root, { exists = existsSync } = {}) {
149
+ const moonMarker = moonMarkerAt(root, { exists });
150
+ const hasNx = exists(join(root, NX_CONFIG_FILE));
151
+ const hasNative = exists(join(root, ARCHKEEP_MODEL_FILE));
152
+ const refusal = (a, b) =>
153
+ new Error(
154
+ `archkeep: ${root} declares both ${a} and ${b} — this tool judges a workspace ` +
155
+ `against exactly one project model, and a tree carrying both is a decision nobody made ` +
156
+ `rather than one this tool can make for them. Remove whichever one is not the ` +
157
+ `workspace's real source of truth for projects and tags.`,
158
+ );
159
+ if (moonMarker !== null && hasNx) throw refusal(moonMarker, NX_CONFIG_FILE);
160
+ if (moonMarker !== null && hasNative) throw refusal(moonMarker, ARCHKEEP_MODEL_FILE);
161
+ if (hasNx && hasNative) throw refusal(NX_CONFIG_FILE, ARCHKEEP_MODEL_FILE);
162
+ return { hasNx, hasNative, moonMarker };
163
+ }
164
+
165
+ /**
166
+ * Every marker a workspace root may be recognised by, as one list.
167
+ *
168
+ * Exported because `cli.mjs` walks for a root a second time — `--help` and
169
+ * `adr` need one before a `CommandContext` exists — and that walk kept its own
170
+ * copy of this list. The copy went stale: it named `nx.json` and `archkeep.json`
171
+ * only, so `adr` answered "no workspace root" on every Moon tree while naming
172
+ * `.moon` in the very message, and `--help` fell back to defaults there. The
173
+ * two callers still differ in posture — one throws where the other returns a
174
+ * default — but they may not differ in what a workspace root IS.
175
+ *
176
+ * @type {string[]}
177
+ */
178
+ export const WORKSPACE_MARKERS = [NX_CONFIG_FILE, ARCHKEEP_MODEL_FILE, MOON_DIR, MOON_ALT_DIR];
179
+
180
+ /**
181
+ * @typedef {object} CommandContext
182
+ * @property {string} root Absolute workspace root.
183
+ * @property {"nx"|"native"|"moon"} provider Which project-model provider answered.
184
+ * @property {string} marker `nx.json`, `archkeep.json`, `.moon`, or `.config/moon` — whichever
185
+ * `root` carries, and the one this run's provider came from.
186
+ * @property {object} graph `{nodes, dependencies}`, from `readProjectGraph`
187
+ * or `nativeProvider.buildGraph`.
188
+ * @property {object} workspace The `Workspace` `../workspace.mjs`'s
189
+ * `createWorkspace` returns.
190
+ * @property {string[]} tracked Every tracked file, from `listFiles(root)`.
191
+ * @property {{imports: object[], failures: object[], analyzed: number,
192
+ * analyzedFiles: string[], exemptedFiles: string[]}} analysis The
193
+ * whole-tree-then-scoped (native) or scoped-then-analyzed (nx/moon) result —
194
+ * see the branches below for why the order is not the same on all three.
195
+ * `exemptedFiles` is always `[]` on nx/moon: `coverage.exempt` is a
196
+ * native-only `archkeep.json` key.
197
+ * @property {{boundaryConfig: string|object, tsConfig: object|undefined,
198
+ * boundaryConfigDeclared: boolean, profiles?: string, inline?: boolean}} options
199
+ * What this workspace names its boundary law, its shared tsconfig, and —
200
+ * when it uses one — its named profile registry. `check`'s `--config` flag,
201
+ * if given, still wins over `options.boundaryConfig`; that override is
202
+ * `check`'s decision, not this module's.
203
+ * `boundaryConfigDeclared` is not a name but the provenance of one: `true`
204
+ * when this workspace's own config named a boundary law (`nx.json`'s
205
+ * `plugins[].options.boundaryConfig`, or `archkeep.json`'s field in either
206
+ * its filename or its inline-policy spelling), `false` when the name above
207
+ * is `../options.mjs`'s `DEFAULT_OPTIONS` convention and nobody wrote one.
208
+ * Always present, on all three providers. A command that may tolerate a law
209
+ * file that is not there — `graph`, which describes the project graph and
210
+ * judges nothing against a constraint row — reads this to tell "no law was
211
+ * ever written" from "the law this workspace named has been renamed or
212
+ * deleted", which the merged `boundaryConfig` string alone cannot. That
213
+ * module's header owns the argument.
214
+ * @property {{registered: boolean, manifests: string[]}} pluginGap Whether
215
+ * this workspace's own provider is the one Nx would actually run, and which
216
+ * tracked polyglot manifests sit under a project root either way. Always
217
+ * `{registered: true, manifests: []}` on a native or Moon workspace: there
218
+ * is no Nx plugin registration to be missing, because there is no `nx.json`.
219
+ * Computed and returned, but not consulted by `check`'s refusal logic —
220
+ * `../../../../docs/usage/` names the gap this fills and the issue tracking it
221
+ * wiring it in.
222
+ * @property {{files: string[], languages: string[]}} unownedGap The tracked
223
+ * analyzable files no project owns that this run deliberately does NOT fail
224
+ * on — `unownedAnalyzableFiles` below owns the argument, and
225
+ * `./check.mjs` turns a non-empty `files` into the `"unowned-files"`
226
+ * `coverageGaps` entry. Always `{files: [], languages: []}` on a native
227
+ * workspace: there, every unclaimed analyzable file is already a whole-file
228
+ * failure (`../providers/native/coverage.mjs`'s `judgeCoverage`) and so
229
+ * already refuses the run with exit 3 — a gap beside it would be a second,
230
+ * quieter voice for a state that is answered loudly.
231
+ * @property {{file: string, project: string}[]} owned Every tracked file that
232
+ * belongs to a project, paired with its owning project — the ownership map
233
+ * `createWorkspace` already built. A command that needs to know WHICH project
234
+ * owns a file (the planning context's path→project scoping, `./plan-context-command.mjs`)
235
+ * reads this rather than re-deriving ownership a second way.
236
+ */
237
+
238
+ /**
239
+ * The languages Nx cannot draw an edge for and ESLint cannot parse at all —
240
+ * `../../../AGENTS.md`'s own opening line: "Nx reads TypeScript and
241
+ * JavaScript imports and so `nx affected` and `@nx/enforce-module-boundaries`
242
+ * work there; for the other three both go quiet." TypeScript, JavaScript
243
+ * (and their `.mjs`/`.cjs`/`.jsx`/`.tsx` siblings) and Vue are deliberately
244
+ * OUTSIDE this set: Nx's own graph already draws their edges from real
245
+ * imports, and `@nx/enforce-module-boundaries` already lints them through
246
+ * ESLint's normal file scoping, so a root-level tooling script in one of
247
+ * those languages sitting outside every declared project is the ordinary,
248
+ * unremarkable shape of an Nx or Moon workspace — this very repository's own
249
+ * root carries a whole `scripts/` directory of them, plus `.opencode/plugins/`
250
+ * and `commitlint.config.mjs`, inside neither of its two Moon projects — not
251
+ * a gap this tool introduces. `../workspace.mjs`'s `polyglotManifests` already
252
+ * draws this exact same line for the unregistered-plugin coverage gap, over
253
+ * the three languages' manifests rather than their sources; this is that
254
+ * same boundary restated for `unclaimedFileFailures` below, which has no
255
+ * manifest to key off since an unclaimed file is, by definition, one with no
256
+ * project (and so no `go.mod`/`Cargo.toml`/`pyproject.toml`) to belong to.
257
+ *
258
+ * Scoping to these three is not a smaller fix chosen for convenience — it is
259
+ * the fix: widening this to every analyzable language turns `check` into a
260
+ * breaking change for nearly every real Nx/Moon consumer, measured by running
261
+ * it over this repository's own tree, whose tooling layer is exactly this
262
+ * shape and whose own `check` must keep exiting 0.
263
+ */
264
+ const UNCLAIMED_CHECK_LANGUAGES = new Set(["go", "rust", "python"]);
265
+
266
+ /**
267
+ * Tracked Go, Rust or Python files that no project in `owned` claims — the
268
+ * same "unclaimed file" question `../providers/native/coverage.mjs`'s
269
+ * `judgeCoverage` answers for a native workspace (over every analyzable
270
+ * language there, `UNCLAIMED_CHECK_LANGUAGES` above argues why this narrower
271
+ * set is the right one here), asked for the Nx and Moon branches below,
272
+ * neither of which has a discovery step of its own to answer it from: both
273
+ * build their graph from `nx graph`/`moon project-graph` rather than from
274
+ * `archkeep.json`, and `../providers/native/coverage.mjs`'s own header names
275
+ * the gap this closes — "this package's Nx path ... has no unclaimed-file
276
+ * check of its own: both compute imports and violations only for files a
277
+ * project already claims."
278
+ *
279
+ * `owned` already IS the claimed half of this question: `createWorkspace`
280
+ * (`../workspace.mjs`) computed it over the FULL tracked-file list by the
281
+ * same longest-root-prefix match `../providers/native/coverage.mjs`'s
282
+ * `projectOf` uses, silently dropping any file that matched no project
283
+ * (`../workspace.mjs`'s own header — "A file no project owns ... is dropped
284
+ * here rather than read and analyzed for a verdict that cannot exist").
285
+ * Comparing against the set `createWorkspace` already produced, rather than
286
+ * matching roots a second time, is what keeps this answer from being able to
287
+ * disagree with what "owned" already means for this graph.
288
+ *
289
+ * A file this returns is exactly the silent hole `../../../../AGENTS.md`'s
290
+ * invariant refuses: analyzed by nothing, judged by nothing, and an empty
291
+ * violation list reading identically to a file that really was clean. It
292
+ * ignores path scoping on purpose — `tracked`, not the caller's scoped
293
+ * selection — the same workspace-wide posture native's own unclaimed check
294
+ * already has (its failures ride in `discovered.failures` below, unfiltered
295
+ * by `paths`), because a `check <path>` run must not be able to hide an
296
+ * orphan file elsewhere in the tree by naming a path that excludes it.
297
+ *
298
+ * Returns the SAME whole-file `fileFailure` shape (`../analysis/source-util.mjs`)
299
+ * a language analyzer produces for a file it could not read, so `../../cli.mjs`'s
300
+ * existing `unchecked`/`coverage.complete` logic — already built to treat any
301
+ * whole-file failure as a coverage hole — picks these up with no change of its
302
+ * own, exactly as it already does for native's. The wording names
303
+ * `providerLabel` rather than native's `archkeep.json`/`coverage.exempt`
304
+ * vocabulary, because neither Nx nor Moon has an exemption mechanism this
305
+ * tool reads — inventing one is out of scope here; this only detects and
306
+ * reports.
307
+ *
308
+ * @param {{tracked: string[], owned: {file: string, project: string}[], providerLabel: string}} args
309
+ * @returns {object[]}
310
+ */
311
+ function unclaimedFileFailures({ tracked, owned, providerLabel }) {
312
+ const ownedFiles = new Set(owned.map(({ file }) => file));
313
+ return tracked
314
+ .filter((file) => UNCLAIMED_CHECK_LANGUAGES.has(languageOf(file)) && !ownedFiles.has(file))
315
+ .map((file) =>
316
+ fileFailure(
317
+ file,
318
+ `is not owned by any project in ${providerLabel} — every tracked Go, Rust or Python file ` +
319
+ `must belong to exactly one declared project, so its cross-project imports can be checked`,
320
+ ),
321
+ );
322
+ }
323
+
324
+ /**
325
+ * The OTHER half of the same question: tracked analyzable files no project
326
+ * owns whose language `UNCLAIMED_CHECK_LANGUAGES` above deliberately leaves
327
+ * out — TypeScript, JavaScript and Vue. That set's own argument stands
328
+ * unchanged and is not widened here: those three keep producing no failure,
329
+ * no `notAnalyzed` entry, no `coverage.complete: false` and no exit 3.
330
+ *
331
+ * What they were also producing was nothing at all. `createWorkspace`
332
+ * (`../workspace.mjs`) drops a file no project owns, so such a file left no
333
+ * trace on any surface: `coverage.analyzedFiles` counted only owned files,
334
+ * `notAnalyzed`/`coverageGaps`/`notes` stayed empty, and a run over a tree
335
+ * with fifty of them printed the same bytes as a run over a tree with none —
336
+ * measured on this repository, where 50 of 425 tracked analyzable files sit
337
+ * outside both Moon projects — 49 of them reported, once the run's own
338
+ * boundary config is subtracted by `unownedGapWithoutRunConfiguration` below. Tolerated is a decision; invisible is the
339
+ * silent direction `../../../../AGENTS.md`'s invariant refuses, and the two
340
+ * are not the same thing.
341
+ *
342
+ * So this reports rather than judges: `./check.mjs` shapes what this returns
343
+ * into a `coverageGaps` entry — the same degraded-coverage channel
344
+ * `../workspace.mjs`'s `polyglotManifests` already feeds through
345
+ * `pluginGap`, which likewise changes no exit code and no verdict. `files` is
346
+ * the whole list rather than a count, so the JSON envelope carries something
347
+ * a reader can act on and the text face can bound its own rendering
348
+ * (`../report/text.mjs`) without either surface having to trust a number it
349
+ * cannot check.
350
+ *
351
+ * Empty `files` is the answer for a workspace where every analyzable file is
352
+ * owned, and `./check.mjs` contributes no gap entry at all then: a gap that
353
+ * always fires teaches a reader to skip the line it is written on.
354
+ *
355
+ * Workspace-scoped like `unclaimedFileFailures` above, and for the same
356
+ * reason — a `check <path>` must not be able to hide an orphan elsewhere in
357
+ * the tree by naming a path that excludes it.
358
+ *
359
+ * **This list still holds the files the run reads as its own configuration**,
360
+ * and `unownedGapWithoutRunConfiguration` below is what removes them. The
361
+ * split is forced rather than stylistic: `resolvePolicy` needs a resolved
362
+ * `CommandContext` to run, so it runs AFTER this does, and until it has run
363
+ * nothing here knows which law actually governed the run. A `--config`
364
+ * override, a profile name, or an inline policy object all decide that later.
365
+ * Subtracting the DECLARED name here would exclude a file the run never read
366
+ * while listing the one it did — worse than not filtering at all.
367
+ *
368
+ * @param {{tracked: string[], owned: {file: string, project: string}[]}} args
369
+ * @returns {{files: string[], languages: string[]}} `languages` is the sorted
370
+ * distinct set of languages `files` spans — derived here, beside the filter
371
+ * that decided the list, so no face can name a language the list does not
372
+ * contain.
373
+ */
374
+ function unownedAnalyzableFiles({ tracked, owned }) {
375
+ const ownedFiles = new Set(owned.map(({ file }) => file));
376
+ const files = tracked.filter((file) => {
377
+ const language = languageOf(file);
378
+ return language !== null && !UNCLAIMED_CHECK_LANGUAGES.has(language) && !ownedFiles.has(file);
379
+ });
380
+ return {
381
+ files,
382
+ languages: [...new Set(files.map((file) => languageOf(file)))].sort(),
383
+ };
384
+ }
385
+
386
+ /**
387
+ * The same gap with the files this run read as its own configuration removed,
388
+ * and the languages recounted over what is left.
389
+ *
390
+ * A file the run read as configuration is not a file the run failed to cover:
391
+ * it is not source judged by the boundary law, it IS the boundary law, and
392
+ * "no verdict covers this file" is vacuous when said of it. The concrete
393
+ * failure without this is worse than vacuous, and
394
+ * `../config-spelling.integration.test.mjs` is what proved it: a law spelled
395
+ * `module-boundaries.config.mjs` and one spelled `law/custom.mjs` produced
396
+ * different reports, so RENAMING THE LAW CHANGED THE VERDICT.
397
+ *
398
+ * It takes the names as arguments rather than reading `CommandContext.options`
399
+ * because the caller is the only layer that knows them. `options.boundaryConfig`
400
+ * is what the workspace DECLARED; the law that actually ran may be a `--config`
401
+ * override or a profile, which `./policy.mjs`'s `resolvePolicy` reports as its
402
+ * workspace-relative `source` — and that resolution cannot happen before
403
+ * `resolveCommandContext`, because it takes the context as an argument.
404
+ *
405
+ * Names are normalised toward the spelling `git ls-files` uses, because that
406
+ * is what `tracked` holds: an `nx.json` may legitimately declare
407
+ * `"./module-boundaries.config.mjs"` or a backslash-separated path, and an
408
+ * unnormalised compare would silently fail to exclude it. An absolute path
409
+ * matches nothing and is left alone — no tracked entry is absolute, so it
410
+ * cannot collide with one.
411
+ *
412
+ * @param {{files: string[], languages: string[]}} gap
413
+ * @param {(string|object|null|undefined)[]} configNames Every name this run
414
+ * read as configuration. Non-strings are ignored, which is how an inline
415
+ * policy object (`archkeep.json`'s object form) and an absent profile both
416
+ * pass through without naming a file.
417
+ * @returns {{files: string[], languages: string[]}}
418
+ */
419
+ export function unownedGapWithoutRunConfiguration(gap, configNames) {
420
+ const excluded = new Set(
421
+ configNames
422
+ .filter((name) => typeof name === "string")
423
+ .map((name) => name.replace(/\\/gu, "/").replace(/^\.\//u, "")),
424
+ );
425
+ if (excluded.size === 0) return gap;
426
+ const files = gap.files.filter((file) => !excluded.has(file));
427
+ if (files.length === gap.files.length) return gap;
428
+ return {
429
+ files,
430
+ languages: [...new Set(files.map((file) => languageOf(file)))].sort(),
431
+ };
432
+ }
433
+
434
+ /**
435
+ * Resolves everything a command needs before it can ask its own question:
436
+ * which workspace, which provider, which files, and what analyzing them
437
+ * found.
438
+ *
439
+ * Throws rather than returning a partial context on every condition that
440
+ * would otherwise leave a caller building a verdict over a tree it could not
441
+ * fully read — no workspace root, both markers present, or a requested path
442
+ * outside the workspace or matching no tracked file at all
443
+ * (`../workspace.mjs`'s `selectFiles`). That is the empty-result invariant
444
+ * (`../../../../AGENTS.md`) applied one layer before any command's own
445
+ * report: a context half-built is exactly the silent direction the invariant
446
+ * refuses.
447
+ *
448
+ * @param {{cwd: string, paths?: string[]}} request
449
+ * @param {{readGraph?: Function, listFiles?: Function, readFile?: (path: string) => string|null}} [io]
450
+ * The seams that reach outside this process — Nx, git, and (on the Nx
451
+ * branch) the `nx.json` read behind `pluginGap.registered` — injectable for
452
+ * the same reason `check` always took the first two: a test drives the real
453
+ * analysis over a fixture tree with none of them touching a real
454
+ * filesystem or subprocess. `readFile` takes an ALREADY-ABSOLUTE path, the
455
+ * shape `pluginIsRegistered` (`../options.mjs`) calls its reader with.
456
+ * @returns {CommandContext}
457
+ */
458
+ export function resolveCommandContext(
459
+ { cwd, paths = [] },
460
+ { readGraph, listFiles = listTrackedFiles, readFile = readFileAbsolute } = {},
461
+ ) {
462
+ // All three markers in one walk (`../workspace.mjs`'s `findWorkspaceRoot`), so a
463
+ // native root nested under an unrelated Nx tree — or vice versa — is found
464
+ // from the working directory the same way either alone would be. Which
465
+ // marker(s) the returned directory actually carries is then read back
466
+ // below, because a walk that STOPPED at the first marker it saw could never
467
+ // tell "only archkeep.json here" from "both, one level up".
468
+ const root = findWorkspaceRoot(cwd, WORKSPACE_MARKERS);
469
+ if (root === null) {
470
+ throw new Error(
471
+ `archkeep: no workspace root above ${cwd} — looked for an nx.json, a archkeep.json, or a ` +
472
+ `.moon (or .config/moon) directory in every parent. The tree to judge is found from the working directory, ` +
473
+ `never from this tool's own location: installed from the registry, this tool lives under ` +
474
+ `the consumer's node_modules and the two are always different trees.`,
475
+ );
476
+ }
477
+ // Which provider may judge at all — the one gate
478
+ // (`requireSingleProjectModel` above) every entry point shares, CLI and
479
+ // language server alike. Moon-versus-Moon rides it through `moonMarkerAt`.
480
+ const { hasNative, moonMarker } = requireSingleProjectModel(root);
481
+ const hasMoon = moonMarker !== null;
482
+
483
+ // Resolve the default graph reader based on provider: Moon workspaces read
484
+ // their graph from `moon project-graph --json`, Nx workspaces from
485
+ // `nx graph --file=`. The native provider uses a two-call discover/buildGraph
486
+ // contract instead, so it never goes through `readGraph` at all.
487
+ const defaultReadGraph = hasMoon ? moonProvider.readProjectGraph : readProjectGraph;
488
+ const effectiveReadGraph = readGraph ?? defaultReadGraph;
489
+
490
+ const tracked = listFiles(root);
491
+ let graph;
492
+ let workspace;
493
+ let owned;
494
+ let options;
495
+ let imports;
496
+ let failures;
497
+ let analyzed;
498
+ let analyzedFiles;
499
+ let pluginGap;
500
+ let unownedGap;
501
+ let exemptedFiles;
502
+
503
+ if (hasNative) {
504
+ // No `nx graph`, no `nx.json`, and — verified by this branch existing at
505
+ // all — no `nx` needing to be installed: `nativeProvider` is imported
506
+ // from `../providers/native/index.mjs`, which imports nothing from
507
+ // `../providers/nx.mjs` and nothing that resolves the `nx` package.
508
+ const readFile = readWorkspaceRoot(root);
509
+ const discovered = nativeProvider.discover({ root, files: tracked, readFile });
510
+ // A graph with nodes but no dependencies yet — `createWorkspace` only
511
+ // ever reads `data.root` off each node, and dependencies are not known
512
+ // until the import sites below are analyzed against these same projects.
513
+ const preGraph = {
514
+ nodes: Object.fromEntries(
515
+ discovered.projects.map((project) => [
516
+ project.name,
517
+ { name: project.name, data: { root: project.root } },
518
+ ]),
519
+ ),
520
+ };
521
+ ({ workspace, owned } = createWorkspace({
522
+ root,
523
+ graph: preGraph,
524
+ files: tracked,
525
+ tsConfig: discovered.model.tsConfig,
526
+ }));
527
+
528
+ // On the Nx path `graph.dependencies` comes from `nx graph`, computed
529
+ // over the WHOLE workspace regardless of `paths`, so scoping only ever
530
+ // narrows which import sites are handed back for reporting. The native
531
+ // path has no such independent source — `nativeProvider.buildGraph`
532
+ // DERIVES `dependencies` from import sites — so analyzing only the
533
+ // requested scope first would drop every project outside it from the
534
+ // dependency graph itself, and a cycle or a transitive violation that
535
+ // only closes once the rest of the tree's imports are counted would go
536
+ // unreported. Every owned file is analyzed here, unconditionally;
537
+ // `selected` below only filters which of the resulting sites are handed
538
+ // back.
539
+ const wholeTreeAnalysis = analyzeWorkspace(
540
+ workspace,
541
+ owned.map(({ file }) => file),
542
+ );
543
+ graph = nativeProvider.buildGraph({
544
+ discovered,
545
+ importSites: wholeTreeAnalysis.imports,
546
+ });
547
+ annotateMFERemotes(graph.nodes, workspace.readFile);
548
+ annotatePackageFacts(graph.nodes, workspace.readFile);
549
+
550
+ // `boundaryConfigDeclared` is carried straight off the model rather than
551
+ // re-derived here: `../providers/native/model.mjs`'s
552
+ // `normalizeNativeModel` is the only code that still sees the raw
553
+ // `archkeep.json`, so it is the only place that can answer whether the
554
+ // file named a law. Re-deriving it from `options.boundaryConfig` at this
555
+ // point is exactly the mistake this whole thread exists to undo — a
556
+ // declared name and the convention default are the same string by then.
557
+ options = {
558
+ boundaryConfig: discovered.model.boundaryConfig,
559
+ tsConfig: discovered.model.tsConfig,
560
+ boundaryConfigDeclared: discovered.model.boundaryConfigDeclared,
561
+ ...(typeof discovered.model.boundaryConfig === "string" ? {} : { inline: true }),
562
+ };
563
+
564
+ const selected = selectFiles(
565
+ owned.map(({ file }) => file),
566
+ paths,
567
+ { root, cwd, tracked },
568
+ );
569
+ const selectedFiles = new Set(selected);
570
+ imports = wholeTreeAnalysis.imports.filter((site) => selectedFiles.has(site.sourceFile));
571
+ // Unclaimed analyzable files — this branch's own source is native
572
+ // discovery's `discovered.failures` (`../providers/native/coverage.mjs`'s
573
+ // `judgeCoverage`, reached through `nativeProvider.discover` above), which
574
+ // already carries the unclaimed-file list alongside any unparseable-
575
+ // manifest failure. The Nx and Moon branches below have no such discovery
576
+ // step to answer the same question from, so they compute the equivalent
577
+ // list themselves (`unclaimedFileFailures` above) — three different
578
+ // sources feeding the SAME whole-file failure shape a language analyzer
579
+ // produces for an unreadable file, so nothing downstream needs to know
580
+ // which provider found the gap.
581
+ failures = [
582
+ ...wholeTreeAnalysis.failures.filter((failure) => selectedFiles.has(failure.sourceFile)),
583
+ ...discovered.failures,
584
+ // Workspace-scoped on purpose, the same posture the two unclaimed
585
+ // equivalents above hold: a wildcard run must not be able to hide a
586
+ // project whose manifest it cannot read by naming a path that excludes
587
+ // it (`../analysis/python.mjs`'s `pythonUnmodelledFailures`).
588
+ ...pythonUnmodelledFailures(workspace),
589
+ ];
590
+ analyzedFiles = wholeTreeAnalysis.analyzedFiles.filter((file) => selectedFiles.has(file));
591
+ analyzed = analyzedFiles.length;
592
+ // Unaffected by `paths`: an exempted file is by definition unowned by any
593
+ // project, so it was never a candidate for `owned`/`selected` in the
594
+ // first place — the same reason `pluginGap` below is a workspace-wide
595
+ // fact rather than a scoped one.
596
+ exemptedFiles = discovered.exempted;
597
+
598
+ // There is no Nx plugin registration to be missing on a workspace that
599
+ // has no `nx.json` at all.
600
+ pluginGap = { registered: true, manifests: [] };
601
+ // Nothing to report as a tolerated gap either: native's own coverage
602
+ // judgment already fails on EVERY unclaimed analyzable file, in every
603
+ // language, and those failures are in `discovered.failures` above — the
604
+ // run refuses with exit 3 rather than tolerating them, which is the
605
+ // deliberate difference between this provider and the two below
606
+ // (`../providers/native/coverage.mjs`). Stated rather than left off, for
607
+ // the reason `pluginGap` is: a reader must not have to tell "false" from
608
+ // "this branch forgot".
609
+ unownedGap = { files: [], languages: [] };
610
+ } else if (hasMoon) {
611
+ // Moon provider — reads graph from `moon project-graph --json`, the same
612
+ // one-call contract as the Nx path: Moon already resolved projects, tags
613
+ // and edges before this package ever asked. Both option names are
614
+ // convention here, because Moon carries no `plugins[].options` table and
615
+ // a `archkeep.json` beside `.moon/` is refused outright
616
+ // (`../providers/moon.mjs`) — so there is nowhere in a Moon workspace to
617
+ // name either file, and `readMoonOptions` is where they are decided
618
+ // rather than read. `boundaryConfigDeclared: false` therefore states a
619
+ // fact about Moon rather than a fallback, and it is written out rather
620
+ // than left off so the key is present on all three providers: a reader
621
+ // that had to tell "false" from "this branch forgot" would be back to
622
+ // guessing provenance, which is the defect.
623
+ //
624
+ // The two names are NOT symmetrical, which is why this is a call and not
625
+ // a literal. `boundaryConfig` is the default outright. `tsConfig` walks
626
+ // the short ordered chain `MOON_TSCONFIG_CHAIN` — a workspace whose paths
627
+ // table lives in `tsconfig.json` rather than `tsconfig.base.json` was
628
+ // previously judged against a file it does not have, where every aliased
629
+ // import resolves to nothing and the report is a wall of crossings with
630
+ // no line saying the table was never found. `listFiles` is a thunk the
631
+ // chain only calls on the branch that needs it (neither candidate
632
+ // present), so a workspace carrying one pays nothing for the question.
633
+ options = readMoonOptions(root, { listFiles: () => tracked });
634
+
635
+ graph = effectiveReadGraph(root);
636
+ ({ workspace, owned } = createWorkspace({
637
+ root,
638
+ graph,
639
+ files: tracked,
640
+ tsConfig: options.tsConfig,
641
+ }));
642
+ annotateMFERemotes(graph.nodes, workspace.readFile);
643
+ annotatePackageFacts(graph.nodes, workspace.readFile);
644
+
645
+ // Unlike the Nx path, Moon's own graph carries NO edge this package's own
646
+ // analysis found: `moon project-graph --json` only knows a project
647
+ // depends on another when `moon.yml` says `dependsOn`, because Moon has no
648
+ // plugin hook this package can register the way `../nx.mjs`'s
649
+ // `createDependencies` registers into Nx's own graph computation. A Go,
650
+ // Rust or Python import crossing a project boundary with no hand-written
651
+ // `dependsOn` entry is therefore invisible to `graph.dependencies` and to
652
+ // every verdict computed from it — architecture-intent, drift, cycles,
653
+ // `impact`, `diff` — while `check`'s own import-site rules judge it fine,
654
+ // because those read analysis records directly rather than the graph.
655
+ // Analyzing the whole tree first, before `paths` narrows anything, mirrors
656
+ // the native branch above for the same reason it states there: an edge
657
+ // from a project outside the scoped paths is still part of the graph a
658
+ // cycle or a transitive violation is judged against.
659
+ const wholeTreeAnalysis = analyzeWorkspace(
660
+ workspace,
661
+ owned.map(({ file }) => file),
662
+ );
663
+ const projectOfFile = new Map(owned.map(({ file, project }) => [file, project]));
664
+ // Moon's own graph carries only edges Moon itself resolved (`dependsOn`);
665
+ // the imports this tree writes are folded in here by the same merge the
666
+ // language server's index runs — one implementation
667
+ // (`../providers/moon.mjs`'s `mergeImportEdges`), so the two faces cannot
668
+ // disagree about which edges exist.
669
+ mergeImportEdges(graph, {
670
+ importSites: wholeTreeAnalysis.imports,
671
+ projectOf: (file) => projectOfFile.get(file),
672
+ });
673
+
674
+ const selected = selectFiles(
675
+ owned.map(({ file }) => file),
676
+ paths,
677
+ { root, cwd, tracked },
678
+ );
679
+ const selectedFiles = new Set(selected);
680
+ imports = wholeTreeAnalysis.imports.filter((site) => selectedFiles.has(site.sourceFile));
681
+ // Unclaimed analyzable files — `unclaimedFileFailures` above — join the
682
+ // scoped read failures unconditionally, the same workspace-wide posture
683
+ // native's own `discovered.failures` has (this branch's header already
684
+ // analyzes the whole tree before `paths` narrows anything, for the same
685
+ // reason).
686
+ failures = [
687
+ ...wholeTreeAnalysis.failures.filter((failure) => selectedFiles.has(failure.sourceFile)),
688
+ ...unclaimedFileFailures({ tracked, owned, providerLabel: "the Moon project graph" }),
689
+ ...pythonUnmodelledFailures(workspace),
690
+ ];
691
+ analyzedFiles = wholeTreeAnalysis.analyzedFiles.filter((file) => selectedFiles.has(file));
692
+ analyzed = analyzedFiles.length;
693
+ // `coverage.exempt` is a native-only key (`../providers/native/coverage.mjs`'s
694
+ // header: "Nx has no equivalent question") — Moon carries no such list.
695
+ exemptedFiles = [];
696
+
697
+ // There is no Nx plugin registration to be missing on a workspace that
698
+ // has no `nx.json` at all.
699
+ pluginGap = { registered: true, manifests: [] };
700
+ // The tolerated half of the same unclaimed-file question the failures
701
+ // above answer for Go, Rust and Python — counted and reported rather than
702
+ // judged (`unownedAnalyzableFiles`).
703
+ unownedGap = unownedAnalyzableFiles({ tracked, owned });
704
+ } else {
705
+ // What this workspace calls the two files whose names are conventions
706
+ // rather than contracts. Read before the graph, because it decides which
707
+ // tsconfig `createWorkspace` resolves paths against.
708
+ const pluginOptions = readPluginOptions(root);
709
+ options = {
710
+ boundaryConfig: pluginOptions.boundaryConfig,
711
+ tsConfig: pluginOptions.tsConfig,
712
+ // Straight off `readPluginOptions`, for the reason the native branch
713
+ // above states: `nx.json`'s `plugins[].options` is the last place the
714
+ // declaration is still distinguishable from `DEFAULT_OPTIONS`.
715
+ boundaryConfigDeclared: pluginOptions.boundaryConfigDeclared,
716
+ ...(pluginOptions.profiles === undefined ? {} : { profiles: pluginOptions.profiles }),
717
+ };
718
+
719
+ graph = effectiveReadGraph(root);
720
+ ({ workspace, owned } = createWorkspace({
721
+ root,
722
+ graph,
723
+ files: tracked,
724
+ tsConfig: pluginOptions.tsConfig,
725
+ }));
726
+ // `nx graph --file=` does not carry the Module Federation fact — see
727
+ // `annotateMFERemotes` — so it is computed here, before any rule runs, or
728
+ // every import of a real remote app would be a false `noImportsOfApps`.
729
+ annotateMFERemotes(graph.nodes, workspace.readFile);
730
+ // Nor the two `package.json` facts — `data.entryPoints` and
731
+ // `data.declaredPackages`, see `annotatePackageFacts` — which decide the
732
+ // secondary-entry-point exemptions and `noTransitiveDependencies`.
733
+ annotatePackageFacts(graph.nodes, workspace.readFile);
734
+
735
+ const selected = selectFiles(
736
+ owned.map(({ file }) => file),
737
+ paths,
738
+ { root, cwd, tracked },
739
+ );
740
+ ({ imports, failures, analyzed, analyzedFiles } = analyzeWorkspace(workspace, selected));
741
+ // Unclaimed analyzable files — `unclaimedFileFailures` above — join
742
+ // unconditionally, the same workspace-wide posture native's own
743
+ // `discovered.failures` has, so a scoped `check <path>` cannot hide an
744
+ // orphan file elsewhere in the tree by naming a path that excludes it.
745
+ failures = [
746
+ ...failures,
747
+ ...unclaimedFileFailures({ tracked, owned, providerLabel: "the Nx project graph" }),
748
+ ...pythonUnmodelledFailures(workspace),
749
+ ];
750
+ // Same reason as the Moon branch above: `coverage.exempt` is a native-only
751
+ // concept, so an Nx workspace has nothing to report here.
752
+ exemptedFiles = [];
753
+
754
+ pluginGap = {
755
+ registered: pluginIsRegistered(root, { readFile }),
756
+ manifests: polyglotManifests(tracked, workspace.projects),
757
+ };
758
+ // Same as the Moon branch above, and computed from `tracked` rather than
759
+ // `selected` for the same reason `unclaimedFileFailures` is.
760
+ unownedGap = unownedAnalyzableFiles({ tracked, owned });
761
+ }
762
+
763
+ // `moonMarker` — resolved once at the top, where coexistence was refused —
764
+ // names whichever Moon directory this root actually carries, so diagnostics
765
+ // can name it correctly.
766
+ return {
767
+ root,
768
+ provider: hasMoon ? "moon" : hasNative ? "native" : "nx",
769
+ marker: hasMoon ? moonMarker : hasNative ? ARCHKEEP_MODEL_FILE : NX_CONFIG_FILE,
770
+ graph,
771
+ workspace,
772
+ tracked,
773
+ analysis: { imports, failures, analyzed, analyzedFiles, exemptedFiles },
774
+ options,
775
+ pluginGap,
776
+ unownedGap,
777
+ // Every tracked file that belongs to a project, paired with its project —
778
+ // the ownership map `createWorkspace` already built (`own ./workspace.mjs`).
779
+ // A command that needs to know WHICH project owns a file (the planning
780
+ // context's path→project scoping) reads this rather than re-deriving
781
+ // ownership a second way. Not part of any existing command's consumption.
782
+ owned,
783
+ };
784
+ }
785
+
786
+ // Re-exported so a caller that only needs "does this tree look like a
787
+ // workspace at all" (`../../cli.mjs`'s `optionsForUsage`) is not forced to
788
+ // duplicate the marker check a second time; `DEFAULT_OPTIONS` rides along for
789
+ // the same reason, since the two are always read together there.
790
+ export { DEFAULT_OPTIONS };