@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,891 @@
1
+ /**
2
+ * The workspace as the rule engine needs to see it: an Nx-shaped project graph,
3
+ * and the `Workspace` object every analyzer resolves against.
4
+ *
5
+ * `evaluate(sites, graph, config)` is pure and takes a graph it does not build
6
+ * (`../rules/README.md`). Under Nx that graph arrives from Nx. A language
7
+ * server has no Nx — it is spawned by an editor, in a directory, with nothing
8
+ * else — so this module builds the same shape from whichever source of truth
9
+ * the root actually carries: the tracked `project.json` files when there is no
10
+ * `archkeep.json` at the root, `../providers/native/`'s own `discover()`/
11
+ * `buildGraph()` when there is (`buildNativeWorkspaceIndex` below), and
12
+ * `../providers/moon.mjs`'s one-call `readProjectGraph` when the root carries
13
+ * `.moon/` or `.config/moon/` (`buildMoonWorkspaceIndex`) — the same provider
14
+ * object `../commands/context.mjs` hands `check`, so the editor's graph and
15
+ * the CLI's come from one dispatch rather than two (#223). Before that Moon
16
+ * branch existed, this module fell through to `discoverProjects` on a
17
+ * `.moon`-rooted tree — which finds a project only by its `project.json`, a
18
+ * file a Moon workspace never has — and built a zero-node index that read as
19
+ * clean while `check` exited 1 on the same tree.
20
+ *
21
+ * `nodeTypeOf`, `PROJECT_CONFIG_FILE` and `buildDependencies` are imported
22
+ * from `../providers/native/`, not defined here — that package is where a
23
+ * `project.json`-shaped graph is built for BOTH the Nx-less native provider
24
+ * and this server, so the two do not grow separate copies of Nx's own
25
+ * `getProjectType` rule and its implicit-dependency expansion
26
+ * (`../../AGENTS.md`, "`src/providers/` is the only layer allowed to build a
27
+ * graph"). `discoverProjects` and `buildNodes` below are the Nx-shaped
28
+ * branch's own project discovery — reading `project.json` is correct THERE,
29
+ * because a root with no `archkeep.json` has no other source of truth to read.
30
+ *
31
+ * ## Why the native branch cannot reuse `discoverProjects`
32
+ *
33
+ * A native workspace can declare or infer a project with no `project.json` at
34
+ * all — the whole point of `archkeep.json` is not needing one — and
35
+ * `discoverProjects` below finds nothing there. A silently missing project is
36
+ * indistinguishable from a project that legitimately has no boundary
37
+ * violations, which is exactly the hole `../../../../AGENTS.md`'s invariant
38
+ * refuses ("An empty result is a claim, not a shrug"). So a root carrying
39
+ * `ARCHKEEP_MODEL_FILE` runs `buildNativeWorkspaceIndex` instead: it drives
40
+ * `../providers/native/`'s own `discover()` (declared∪inferred projects, the
41
+ * files none of them own) and `buildGraph()` (nodes and dependencies from the
42
+ * import sites this module still analyzes itself — provider-agnostic, and
43
+ * unchanged either way).
44
+ *
45
+ * A `discover()` throw — a malformed `archkeep.json`, a declared root with no
46
+ * tracked file under it, a `projectRules` row matching no project, a stale
47
+ * `coverage.exempt` entry — is caught rather than left to blank the whole
48
+ * session: it becomes `nativeModelFailure` on the returned index, which
49
+ * `indexGaps` turns into a diagnostic naming the defect, on an index that is
50
+ * otherwise a valid, empty `workspace`/`graph` shape every caller downstream
51
+ * can still iterate over. `archkeep.json` is a watched file (`./server.mjs`),
52
+ * so fixing it clears the gap the same way fixing a broken `project.json`
53
+ * clears a `skippedProjects` one.
54
+ *
55
+ * ## What it may assume about the tree, which is nothing
56
+ *
57
+ * No project name, no directory layout, no tag vocabulary (`../../AGENTS.md` —
58
+ * the tool is installed into workspaces it has never seen). Everything below is
59
+ * derived: projects from the `project.json` files that exist (Nx-shaped
60
+ * branch) or from `archkeep.json`'s declared∪inferred model (native branch),
61
+ * node types from `projectType` by Nx's own rule either way, tags from each
62
+ * project's own list, edges from the imports the analyzers actually find.
63
+ *
64
+ * ## Why the file list comes from git
65
+ *
66
+ * The analysis contract's `filesOf` means "the project's tracked files", and
67
+ * git is the one component that already answers that exactly. The alternative
68
+ * is a directory walk with a skip list — `node_modules`, `dist`, `target`,
69
+ * `.venv` — which is a config nobody maintains until the day it swallows a real
70
+ * source directory and the boundary quietly stops being enforced there. The
71
+ * list is TRACKED files only, the same set `../../cli.mjs`'s `check` reads
72
+ * (`../workspace.mjs`'s `listTrackedFiles`, one git spawn shared by both faces)
73
+ * — an untracked file is a file `archkeep check` does not judge, and an editor
74
+ * verdict must match the CLI's or the two would disagree about the same tree.
75
+ *
76
+ * A workspace git cannot answer for is a LOUD failure, never a silent empty
77
+ * index: `buildWorkspaceIndex` throws, and the server turns that into a
78
+ * diagnostic on every open document rather than a clean bill of health.
79
+ *
80
+ * ## Why the whole tree's import sites stay on the index
81
+ *
82
+ * `analyzeTrackedFiles` runs in every success branch below, and its sites used
83
+ * to be discarded once the graph was built — `evaluate(sites, graph, config)`
84
+ * judges the graph, so what else could the records be for? The evidence. The
85
+ * rule engine derives its evidence index (`../rules/index.mjs`'s
86
+ * `createContext`) from exactly the records it is handed, and two rules render
87
+ * evidence out of that index: the file list of
88
+ * `noImportsOfLazyLoadedLibraries` ("Library X is lazy-loaded in these
89
+ * files:") and the per-hop file lists of `noCircularDependencies`. Handed one
90
+ * open document's records, the engine can cite only that document — so an
91
+ * editor verdict named no backing files wherever the backing import lived in a
92
+ * file nobody had open, while `lattice check`, which hands over the whole
93
+ * tree's sites, named them: two faces of one analysis disagreeing about the
94
+ * same tree. So the sites are retained on every returned index (an empty list
95
+ * on the two failure branches below, which analyze nothing), and
96
+ * `./diagnose.mjs` composes its input from them — every retained site EXCEPT
97
+ * the diagnosed document's own stale disk copy, plus the fresh records for the
98
+ * live editor buffer that replaces it before evaluation.
99
+ *
100
+ * ## What the index could not read is data the caller must publish
101
+ *
102
+ * The two failures below are recorded rather than thrown, because one project
103
+ * being edited must not blank the graph for the other nineteen. Recording them
104
+ * is only half an answer: an index missing a project or an edge produces a
105
+ * verdict that is not the verdict, and a caller that reads neither list
106
+ * publishes that verdict as if the tree had been read whole. `indexGaps` turns
107
+ * both lists into sentences, and `./diagnose.mjs` refuses to call a document
108
+ * analyzed while either is non-empty.
109
+ */
110
+ import { existsSync, readFileSync } from "node:fs";
111
+ import { join } from "node:path";
112
+
113
+ import { analyzeFile } from "../analysis/analyze.mjs";
114
+ import { fileFailure, isWholeFileFailure } from "../analysis/source-util.mjs";
115
+ import { containmentViolation } from "../containment.mjs";
116
+ import { parseNxJson } from "../nx-json.mjs";
117
+ import {
118
+ NX_CONFIG_FILE,
119
+ readWorkspaceLayout,
120
+ requireCompleteWorkspaceLayout,
121
+ } from "../options.mjs";
122
+ import {
123
+ analyzeWorkspace,
124
+ annotateMFERemotes,
125
+ annotatePackageFacts,
126
+ createWorkspace,
127
+ listTrackedFiles,
128
+ } from "../workspace.mjs";
129
+ import { buildDependencies } from "../providers/native/graph.mjs";
130
+ import { nodeTypeOf, PROJECT_CONFIG_FILE } from "../providers/native/discover.mjs";
131
+ import { ARCHKEEP_MODEL_FILE } from "../providers/native/model.mjs";
132
+ import { nativeProvider } from "../providers/native/index.mjs";
133
+ import { requireSingleProjectModel } from "../commands/context.mjs";
134
+ import { mergeImportEdges, moonProvider } from "../providers/moon.mjs";
135
+
136
+ export { PROJECT_CONFIG_FILE, nodeTypeOf, buildDependencies };
137
+
138
+ /**
139
+ * One `project.json` — or the `package.json` beside it — read the way Nx reads
140
+ * it, which is NOT `JSON.parse`.
141
+ *
142
+ * The reader itself is `../nx-json.mjs`, shared with `../options.mjs` because
143
+ * `nx.json` has to be read the same way for the same reason. This name stays as
144
+ * the local one because the stakes are specific to a project config, and worth
145
+ * stating where a reader of this module will look for them: losing a
146
+ * `project.json` here is the worst failure this server can have. The project
147
+ * leaves the graph; an import into it then resolves as external rather than
148
+ * cross-project; the rule engine's npm branch returns before the tag checks
149
+ * run; and the editor paints a real violation clean.
150
+ *
151
+ * @param {string} text
152
+ * @returns {object} Whatever the JSON describes.
153
+ * @throws {Error} when neither parser can read it.
154
+ */
155
+ export const parseProjectJson = parseNxJson;
156
+
157
+ /**
158
+ * Every file git considers part of the working tree, workspace-relative and
159
+ * `/`-separated.
160
+ *
161
+ * `-z` because a path may legitimately contain a newline, and splitting on one
162
+ * would invent two files that do not exist.
163
+ *
164
+ * @param {string} root Absolute workspace root.
165
+ * @returns {string[]}
166
+ * @throws {Error} when git cannot answer — not a git tree, git not installed.
167
+ */
168
+ export function listWorkspaceFiles(root) {
169
+ try {
170
+ // The one git spawn both faces share (`../workspace.mjs`'s
171
+ // `listTrackedFiles`), not a second git invocation with a different flag
172
+ // set: a CLI verdict and an editor verdict over the same tree must agree
173
+ // about which files even exist. `listTrackedFiles` runs through
174
+ // `../process.mjs`'s `runProcess`, which uses the same `environmentForTree`
175
+ // guard against an ambient `GIT_DIR` this branch used to apply itself.
176
+ return listTrackedFiles(root);
177
+ } catch (cause) {
178
+ throw new Error(
179
+ `archkeep: cannot list the files of ${root}: ${cause?.message ?? cause}. ` +
180
+ `The language server reads the workspace's file list from git; without it there is ` +
181
+ `no project list, and every file would be reported as having no boundary to cross.`,
182
+ { cause },
183
+ );
184
+ }
185
+ }
186
+
187
+ /** The directory part of a workspace-relative path; `""` at the tree root. */
188
+ const directoryOf = (file) => {
189
+ const slash = file.lastIndexOf("/");
190
+ return slash === -1 ? "" : file.slice(0, slash);
191
+ };
192
+
193
+ /**
194
+ * The projects declared in a tree, from its `project.json` files.
195
+ *
196
+ * A `project.json` that will not parse is SKIPPED and reported, not thrown on:
197
+ * one project being edited must not blank the graph for the other nineteen. The
198
+ * caller decides how loud to be about the ones that were skipped.
199
+ *
200
+ * @param {{files: string[], readFile: (path: string) => string|null}} tree
201
+ * @returns {{projects: {name: string, root: string, config: object}[], skipped: {file: string, reason: string}[]}}
202
+ */
203
+ export function discoverProjects({ files, readFile }) {
204
+ const projects = [];
205
+ const skipped = [];
206
+ for (const file of files) {
207
+ if (file !== PROJECT_CONFIG_FILE && !file.endsWith(`/${PROJECT_CONFIG_FILE}`)) continue;
208
+ const text = readFile(file);
209
+ if (text === null) {
210
+ skipped.push({ file, reason: "could not be read" });
211
+ continue;
212
+ }
213
+ let config;
214
+ try {
215
+ config = parseProjectJson(text);
216
+ } catch (cause) {
217
+ skipped.push({ file, reason: `is not valid JSON: ${cause?.message ?? cause}` });
218
+ continue;
219
+ }
220
+ const root = directoryOf(file);
221
+ // Nx's own precedence: the name a project states, then the one its
222
+ // `package.json` states, then the directory it lives in.
223
+ const packageName = (() => {
224
+ const manifest = readFile(root === "" ? "package.json" : `${root}/package.json`);
225
+ if (manifest === null) return undefined;
226
+ try {
227
+ // The same parser, because Nx reads this file with the same
228
+ // `readJsonFile` — a `package.json` Nx can name a project from must
229
+ // not become a project named after its directory here.
230
+ return parseProjectJson(manifest).name;
231
+ } catch {
232
+ return undefined;
233
+ }
234
+ })();
235
+ const name =
236
+ config.name ?? packageName ?? (root === "" ? "" : root.slice(root.lastIndexOf("/") + 1));
237
+ if (typeof name !== "string" || name === "") {
238
+ skipped.push({ file, reason: "declares no usable project name" });
239
+ continue;
240
+ }
241
+ projects.push({ name, root, config });
242
+ }
243
+ return { projects, skipped };
244
+ }
245
+
246
+ /**
247
+ * The graph nodes for a project list, in Nx's shape: `data` is the project's
248
+ * own configuration with `tags` guaranteed present, because `../rules/tags.mjs`
249
+ * reads it unguarded and an absent list is not the same fact as an empty one.
250
+ *
251
+ * @param {{name: string, root: string, config: object}[]} projects
252
+ * @returns {Record<string, object>}
253
+ */
254
+ export function buildNodes(projects) {
255
+ // Null-prototype for the same reason `../providers/native/graph.mjs` and
256
+ // `../providers/moon.mjs` use them: every key here is a project NAME, and
257
+ // project names come from a `project.json`'s own `name` field —
258
+ // attacker-supplied the moment a pull request adds a project called
259
+ // `__proto__`. A plain `{}` answers `nodes["__proto__"] = …` by repointing
260
+ // the object's OWN prototype rather than adding an entry, so the project
261
+ // vanishes from `graph.nodes` while `filesOf` still attributes it files — a
262
+ // real cross-project import into it then read a poisoned Node as a graph
263
+ // node and flips/throws on every rule that touches it. `Object.create(null)`
264
+ // has no inherited `__proto__` accessor to collide with, so the name behaves
265
+ // like every other project name: a real, own, enumerable entry.
266
+ const nodes = Object.create(null);
267
+ for (const { name, root, config } of projects) {
268
+ nodes[name] = {
269
+ name,
270
+ type: nodeTypeOf(name, config.projectType),
271
+ data: { ...config, root, tags: config.tags ?? [] },
272
+ };
273
+ }
274
+ return nodes;
275
+ }
276
+
277
+ /**
278
+ * Everything a diagnosis needs about the tree, computed once.
279
+ *
280
+ * The `workspace` object is built ONCE and reused for every analysis, on
281
+ * purpose: `../analysis/source-util.mjs`'s `perWorkspace` cache is keyed on
282
+ * that object's identity, so a fresh object per file would re-read every Go,
283
+ * Cargo and uv manifest in the tree per file analyzed.
284
+ *
285
+ * That identity is also why `tsConfig` is a property of the object rather than
286
+ * an argument beside it — see `createWorkspace` in `../workspace.mjs`. A server
287
+ * that reloads its options after an `nx.json` edit rebuilds the index, so the
288
+ * new name arrives with a new object and the old parse is dropped with the old
289
+ * one.
290
+ *
291
+ * @param {{root: string, tsConfig?: string, listFiles?: (root: string) => string[], readFileAt?: (root: string, path: string) => string|null, readLayout?: typeof readWorkspaceLayout, pathExists?: (path: string) => boolean, readGraph?: (root: string) => object}} options
292
+ * `pathExists` and `readGraph` are the Moon branch's seams — plain
293
+ * filesystem existence (the marker is a directory, which `readFileAt`
294
+ * cannot answer) and the provider's one-call graph reader, both injectable
295
+ * for the same reason `listFiles` is.
296
+ * @returns {{root: string, files: string[], workspace: object, graph: object, skippedProjects: object[], fileFailures: object[], importSites: object[], nativeMarker: boolean, nativeModelFailure: string|null, moonModelFailure: string|null, nxModelFailure: string|null, workspaceLayoutFailure: string|null}}
297
+ * `importSites` is the whole tree's analysis output, retained past the graph
298
+ * build for the rule engine's evidence index — see this module header's "Why
299
+ * the whole tree's import sites stay on the index".
300
+ * @throws {Error} when the file list cannot be obtained. Loud on purpose: an
301
+ * index built from no files would put every file in no project, and a file in
302
+ * no project has no boundary to cross — a clean report, produced by not
303
+ * looking. Also when the root carries more than one project-model marker —
304
+ * a Moon directory beside `nx.json`/`archkeep.json`, both Moon spellings at
305
+ * once, or `nx.json` beside `archkeep.json` (`../commands/context.mjs`'s
306
+ * `requireSingleProjectModel`, the same refusal `check` makes) — which
307
+ * config governs at all is a decision nobody made, refused the same way an
308
+ * unreadable `nx.json` is, through this function's caller in `./server.mjs`.
309
+ */
310
+ export function buildWorkspaceIndex({
311
+ root,
312
+ tsConfig,
313
+ listFiles = listWorkspaceFiles,
314
+ readFileAt = readWorkspaceFile,
315
+ readLayout = readWorkspaceLayout,
316
+ pathExists = existsSync,
317
+ readGraph = moonProvider.readProjectGraph,
318
+ }) {
319
+ const files = listFiles(root);
320
+ const readFile = (path) => readFileAt(root, path);
321
+ // Which provider may judge this root at all — the SAME gate
322
+ // (`../commands/context.mjs`'s `requireSingleProjectModel`) the CLI reads
323
+ // before any command runs, so a tree carrying a Moon directory beside
324
+ // `nx.json`/`archkeep.json` is refused here exactly as `check` refuses it,
325
+ // in the same words, from the one copy of the rule. Before the shared
326
+ // gate, this module judged such trees anyway — `.moon` beside `archkeep.json`
327
+ // fell to the native branch and indexed nothing, silently; `.moon` beside
328
+ // `nx.json` built a Moon index and never read the `nx.json` it sat beside.
329
+ // Moon-versus-Moon coexistence rides the same gate through
330
+ // `../providers/moon.mjs`'s `moonMarkerAt`. Checked on the REAL filesystem
331
+ // (`pathExists`, default `existsSync`), never git's tracked list: a
332
+ // directory cannot be read the way `archkeep.json` is below, and the
333
+ // tracked-list gate is exactly the native-branch defect that check records
334
+ // in its own comment.
335
+ const { hasNx, moonMarker } = requireSingleProjectModel(root, { exists: pathExists });
336
+ // A root carrying ARCHKEEP_MODEL_FILE has a project model this module does
337
+ // not read from `project.json` at all — see this file's header — so it is
338
+ // handed to the native branch below rather than to `discoverProjects`.
339
+ //
340
+ // Detected by READING the file, not by whether git tracks it: `../../cli.mjs`
341
+ // and this server's own `readWorkspaceOptions` (`./server.mjs`'s `markersAt`)
342
+ // both dispatch on `existsSync(join(root, ARCHKEEP_MODEL_FILE))` — plain
343
+ // filesystem existence — and an untracked-but-present `archkeep.json` (added
344
+ // to the tree but not yet `git add`ed) exists by that test. Dispatching here
345
+ // on `files.includes(...)` instead — `files` is the TRACKED list `listFiles`
346
+ // returns — disagreed with both of them: this branch would fall through to
347
+ // `discoverProjects`, find no `project.json` for a native-only tree, and
348
+ // build a zero-node, zero-edge index that publishes `analyzed: true` with an
349
+ // empty diagnostic list on a workspace `archkeep check` exits 1 on — the gap
350
+ // machinery below has no entry for "wrong provider" to report. `readFile`
351
+ // reads the real filesystem the same way `existsSync` does (through
352
+ // `readFileAt`, `./workspace-index.mjs`'s own `readWorkspaceFile` by
353
+ // default), so this now agrees with the CLI regardless of git's index.
354
+ if (readFile(ARCHKEEP_MODEL_FILE) !== null) {
355
+ return buildNativeWorkspaceIndex({ root, files, readFile, tsConfig });
356
+ }
357
+ if (moonMarker !== null) {
358
+ return buildMoonWorkspaceIndex({ root, files, readFile, tsConfig, readGraph });
359
+ }
360
+
361
+ const { projects, skipped } = discoverProjects({ files, readFile });
362
+ const nodes = buildNodes(projects);
363
+ // The same Module Federation fact the CLI path computes, from the same
364
+ // predicate (`../workspace.mjs` → `annotateMFERemotes`): a CLI verdict and an
365
+ // editor verdict on the same import must match, and the field failing closed
366
+ // means an index that skipped this write would flag every import of a real
367
+ // remote as `noImportsOfApps`.
368
+ annotateMFERemotes(nodes, readFile);
369
+ // And the two `package.json` facts, from the same shared functions the CLI
370
+ // path calls (`../workspace.mjs` → `annotatePackageFacts`). Skipping this
371
+ // write would fail closed — extra reports, not waived ones — but the two
372
+ // faces would then disagree about the same import, which is the line
373
+ // `src/lsp/` exists to hold. It also DELETES a stale `entryPoints` or
374
+ // `declaredPackages` riding in from `project.json` (`buildNodes` spreads that
375
+ // config into `data` verbatim), because an unmeasured claim that waives
376
+ // violations is the silent direction.
377
+ annotatePackageFacts(nodes, readFile);
378
+
379
+ // The `Workspace` object and the per-project file index, from the SAME
380
+ // `createWorkspace` the CLI path uses (`../commands/context.mjs`) — longest
381
+ // root wins by `projectOwning`, and its root normalisation is what keeps a
382
+ // root-level project (`"."`) owning the root-level files at all (cf. #32).
383
+ // One implementation is also why `projectOf` here and the CLI's agree about
384
+ // which file belongs to which project; a second copy is a second answer.
385
+ const { workspace, owned } = createWorkspace({
386
+ root,
387
+ graph: { nodes },
388
+ files,
389
+ tsConfig,
390
+ read: readFile,
391
+ });
392
+ const projectOfFile = new Map(owned.map(({ file, project }) => [file, project]));
393
+ const projectOf = (file) => projectOfFile.get(file);
394
+
395
+ const { importSites, fileFailures } = analyzeTrackedFiles({ files, workspace });
396
+
397
+ // `nx.json`'s `workspaceLayout` reaches the rule engine here the same way
398
+ // `../providers/nx.mjs`'s `readProjectGraph` merges it onto the graph it
399
+ // returns to `cli.mjs` — see that function's own doc for why a merge step
400
+ // exists at all (`nx graph --file=` itself emits no such key) and why a
401
+ // declared-but-incomplete layout is refused rather than completed
402
+ // (`requireCompleteWorkspaceLayout`, `../options.mjs`). Without this, an
403
+ // editor open on a workspace with a non-default `appsDir`/`libsDir` would
404
+ // draw no diagnostic for exactly the import `archkeep check` flags on the
405
+ // same tree — the language server's own stated rule (this package's
406
+ // `AGENTS.md`, "An empty diagnostic list must mean 'no violation'"),
407
+ // violated from the direction it exists to catch. A read/validation
408
+ // failure is caught rather than thrown onward — one malformed `nx.json`
409
+ // must not blank the whole index — and recorded as `workspaceLayoutFailure`
410
+ // for `indexGaps` to turn into a diagnostic, the same shape
411
+ // `nativeModelFailure` already uses for the native branch's own
412
+ // model-load failure.
413
+ let workspaceLayout;
414
+ let workspaceLayoutFailure = null;
415
+ try {
416
+ const declared = requireCompleteWorkspaceLayout(readLayout(root));
417
+ if (declared !== null) workspaceLayout = declared;
418
+ } catch (cause) {
419
+ workspaceLayoutFailure = cause?.message ?? String(cause);
420
+ }
421
+
422
+ // An Nx-marked root that yielded no project at all is a tree this branch
423
+ // could not see the shape of, not a tree with nothing in it.
424
+ // `discoverProjects` above finds a project only by its `project.json`, and a
425
+ // PACKAGE-BASED Nx workspace has none: its projects are declared in
426
+ // `package.json` files, which this module reads only to resolve the NAME of a
427
+ // project a `project.json` already found. `../providers/nx.mjs`'s
428
+ // `readProjectGraph` asks Nx itself (`nx graph --file=`) and DOES see them,
429
+ // so `../../cli.mjs`'s `check` reports violations on exactly the tree this
430
+ // index would otherwise publish clean — zero nodes, every file in no project,
431
+ // no boundary to cross, an empty diagnostic list byte-for-byte identical to a
432
+ // clean workspace (`../../../../AGENTS.md`).
433
+ //
434
+ // Recorded, not thrown, and not resolved: this branch does not learn to read
435
+ // package-based projects — that is the second project-model reader this
436
+ // package must not grow (`../../AGENTS.md`) — it says it could not see the shape
437
+ // of the tree. The field is the same shape `nativeModelFailure` and
438
+ // `moonModelFailure` already use, a string on an index that is otherwise a
439
+ // valid, empty shape, so `indexGaps` turns it into one diagnostic and
440
+ // `./diagnose.mjs` refuses to call any document analyzed while it stands. It
441
+ // clears itself like the rest of that family: `nx.json` and `project.json`
442
+ // are both watched (`./server.mjs`), so the first `project.json` the tree
443
+ // gains republishes every open document.
444
+ //
445
+ // Gated on the `nx.json` marker `requireSingleProjectModel` already read,
446
+ // never on "zero nodes" alone: a root carrying NO project-model marker
447
+ // reaches this same branch, and what to say about a directory that is not a
448
+ // workspace at all is a different question, decided by the marker walk in
449
+ // `../commands/context.mjs` rather than here.
450
+ const nxModelFailure =
451
+ hasNx && Object.keys(nodes).length === 0
452
+ ? `no ${PROJECT_CONFIG_FILE} is among this tree's tracked files, and this server ` +
453
+ `discovers an Nx workspace's projects from those files only — a package-based ` +
454
+ `workspace, whose projects are declared in package.json, yields none`
455
+ : null;
456
+
457
+ return {
458
+ root,
459
+ files,
460
+ workspace,
461
+ graph: {
462
+ nodes,
463
+ dependencies: buildDependencies({ importSites, nodes, projectOf }),
464
+ ...(workspaceLayout === undefined ? {} : { workspaceLayout }),
465
+ },
466
+ skippedProjects: skipped,
467
+ fileFailures,
468
+ // Retained past the graph build — the evidence half of `evaluate()`'s
469
+ // input (`./diagnose.mjs` composes its run from these). See this module
470
+ // header's "Why the whole tree's import sites stay on the index".
471
+ importSites,
472
+ nativeMarker: false,
473
+ nativeModelFailure: null,
474
+ moonModelFailure: null,
475
+ nxModelFailure,
476
+ workspaceLayoutFailure,
477
+ };
478
+ }
479
+
480
+ /**
481
+ * Every import site the tracked, analyzable files yield, and what could not be
482
+ * read or analyzed along the way — shared between the Nx-shaped branch above
483
+ * and the native branch below, because the question ("what does this file
484
+ * import, and what stopped it from answering") does not depend on which
485
+ * provider found the project list.
486
+ *
487
+ * The loop is `../workspace.mjs`'s `analyzeWorkspace` — the SAME loop
488
+ * `../../cli.mjs`'s `check` runs over the same files — with one injected
489
+ * difference: a throw is caught into a whole-file `fileFailure` record rather
490
+ * than allowed to cost the whole graph. `analyzeFile` throws for a language
491
+ * whose analyzer is not written yet, and one such language must not blank
492
+ * nineteen projects' worth of edges; the document-level diagnosis re-analyzes
493
+ * the open file itself, where the same throw becomes a diagnostic the reader
494
+ * sees. That catch is the only behavioural difference from the CLI's loop —
495
+ * everything else (which files, which reads, which records) is the shared one.
496
+ *
497
+ * One filter: the loop records whole-file failures only
498
+ * (`../analysis/source-util.mjs`'s `isWholeFileFailure`). A positioned
499
+ * failure — a parse error at a line:column, a specifier TypeScript could not
500
+ * resolve from a particular file — is a site failure: that import was not
501
+ * judged, but the rest of the file was, and the document-level diagnosis
502
+ * re-analyzes the open file and shows that site failure wherever a reader can
503
+ * see it. A whole-file failure means the graph genuinely missed every import
504
+ * that file makes, which is the incompleteness `indexGaps` must report.
505
+ *
506
+ * @param {{files: string[], workspace: object}} args
507
+ * @returns {{importSites: object[], fileFailures: {sourceFile: string, reason: string}[]}}
508
+ */
509
+ function analyzeTrackedFiles({ files, workspace }) {
510
+ const analysis = analyzeWorkspace(workspace, files, {
511
+ analyze: (request) => {
512
+ try {
513
+ return analyzeFile(request);
514
+ } catch (cause) {
515
+ return {
516
+ imports: [],
517
+ failures: [fileFailure(request.sourceFile, cause?.message ?? String(cause))],
518
+ };
519
+ }
520
+ },
521
+ });
522
+ return {
523
+ importSites: analysis.imports,
524
+ // Whole-file failures only — the same split `../../cli.mjs`'s `check`
525
+ // draws (`notAnalyzed` vs `blindSpots`): a file whose imports are entirely
526
+ // unknown makes the graph INCOMPLETE for every open document, while a
527
+ // POSITIONED failure (one unparseable site) is a site fact reported at
528
+ // that file's own document level — its other import sites are still in the
529
+ // graph, so the prelude would over-warn. This matches the pre-fork LSP,
530
+ // which never surfaced positioned failures in `indexGaps` either.
531
+ fileFailures: analysis.failures.filter(isWholeFileFailure),
532
+ };
533
+ }
534
+
535
+ /**
536
+ * The native branch of `buildWorkspaceIndex`: drives `../providers/native/`'s
537
+ * two-call contract (`discover()` then `buildGraph()`) instead of
538
+ * `discoverProjects`/`buildNodes`, because a `archkeep.json` project can have no
539
+ * `project.json` at all — see this module's header.
540
+ *
541
+ * @param {{root: string, files: string[], readFile: (path: string) => string|null, tsConfig?: string}} args
542
+ * @returns {ReturnType<typeof buildWorkspaceIndex>}
543
+ */
544
+ function buildNativeWorkspaceIndex({ root, files, readFile, tsConfig }) {
545
+ let discovered;
546
+ try {
547
+ discovered = nativeProvider.discover({ root, files, readFile });
548
+ } catch (cause) {
549
+ // A model defect — malformed JSON, a declared root with no tracked file
550
+ // under it, a `projectRules` row matching nothing, a stale
551
+ // `coverage.exempt` entry (`../providers/native/index.mjs`'s `discover`) —
552
+ // is not thrown onward: one broken `archkeep.json` must not take the whole
553
+ // session down. It still has to be LOUD (`../../../../AGENTS.md`), so it
554
+ // becomes `nativeModelFailure` on an index that is otherwise a valid,
555
+ // empty shape rather than a missing one. `workspaceLayoutFailure` stays
556
+ // `null` here rather than growing a second try/catch of its own: a
557
+ // malformed `archkeep.json`'s `workspaceLayout` is one of the shapes
558
+ // `loadNativeModel` already refuses (`../providers/native/model.mjs`'s
559
+ // `workspaceLayoutViolations`), so it surfaces as THIS failure, not a
560
+ // separate one — the two fields would otherwise say the same thing twice.
561
+ const workspace = { root, projects: [], filesOf: () => [], readFile, tsConfig };
562
+ return {
563
+ root,
564
+ files,
565
+ workspace,
566
+ graph: { nodes: Object.create(null), dependencies: Object.create(null) },
567
+ skippedProjects: [],
568
+ fileFailures: [],
569
+ // Nothing was analyzed on this path — the model threw before any file
570
+ // was read — and an empty list is the honest answer, not a missing field
571
+ // every consumer would have to guard against.
572
+ importSites: [],
573
+ nativeMarker: true,
574
+ nativeModelFailure: cause?.message ?? String(cause),
575
+ moonModelFailure: null,
576
+ nxModelFailure: null,
577
+ workspaceLayoutFailure: null,
578
+ };
579
+ }
580
+
581
+ // The `Workspace` object, from the same `createWorkspace` the CLI's native
582
+ // branch uses (`../commands/context.mjs`'s native composition, preGraph →
583
+ // `createWorkspace` → `analyzeWorkspace`): the projects discovered here
584
+ // become the graph's nodes, and the workspace is built over them exactly the
585
+ // way `check` builds its own over the same discovery. `discovered.projectOf`
586
+ // is still what `nativeProvider.buildGraph` resolves import sites through —
587
+ // it and `createWorkspace`'s `projectOwning` answer the same longest-root
588
+ // question, and one graph is the answer `evaluate()` actually judges.
589
+ const preGraph = {
590
+ nodes: Object.fromEntries(
591
+ discovered.projects.map((project) => [
592
+ project.name,
593
+ { name: project.name, data: { root: project.root } },
594
+ ]),
595
+ ),
596
+ };
597
+ const { workspace } = createWorkspace({ root, graph: preGraph, files, tsConfig, read: readFile });
598
+
599
+ const { importSites, fileFailures: analysisFailures } = analyzeTrackedFiles({ files, workspace });
600
+ // `discovered.failures` — an unparseable `project.json`/`package.json`
601
+ // (`../providers/native/discover.mjs`) and a tracked file none of the
602
+ // discovered projects own (`../providers/native/coverage.mjs`) — are the
603
+ // SAME whole-file `fileFailure` shape `analyzeTrackedFiles` above produces
604
+ // for a file it could not read, so they fold into one list `indexGaps`
605
+ // reports the same way: an unowned file is analyzed by nothing and judged
606
+ // by nothing, which is exactly the hole `../providers/native/coverage.mjs`'s
607
+ // own header names.
608
+ const fileFailures = [...discovered.failures, ...analysisFailures];
609
+
610
+ const graph = nativeProvider.buildGraph({ discovered, importSites });
611
+ // The same Module Federation and `package.json` facts the Nx branch and
612
+ // `../../cli.mjs`'s native branch both compute, from the same shared
613
+ // functions (`../workspace.mjs`) — a CLI verdict and an editor verdict on
614
+ // the same import must match.
615
+ annotateMFERemotes(graph.nodes, readFile);
616
+ annotatePackageFacts(graph.nodes, readFile);
617
+
618
+ return {
619
+ root,
620
+ files,
621
+ workspace,
622
+ graph,
623
+ skippedProjects: [],
624
+ fileFailures,
625
+ // Retained past the graph build — the evidence half of `evaluate()`'s
626
+ // input (`./diagnose.mjs` composes its run from these), on this branch
627
+ // exactly as on the Nx-shaped one.
628
+ importSites,
629
+ nativeMarker: true,
630
+ nativeModelFailure: null,
631
+ moonModelFailure: null,
632
+ nxModelFailure: null,
633
+ workspaceLayoutFailure: null,
634
+ };
635
+ }
636
+
637
+ /**
638
+ * The Moon branch of `buildWorkspaceIndex`: drives `../providers/moon.mjs`'s
639
+ * one-call contract (`readProjectGraph`) — the same provider object
640
+ * `../commands/context.mjs` hands `check` on this tree — instead of
641
+ * `discoverProjects`/`buildNodes`, because a Moon project has no `project.json`
642
+ * for those to find. See this module's header for the fall-through this branch
643
+ * replaces.
644
+ *
645
+ * @param {{root: string, files: string[], readFile: (path: string) => string|null, tsConfig?: string, readGraph: (root: string) => object}} args
646
+ * @returns {ReturnType<typeof buildWorkspaceIndex>}
647
+ */
648
+ function buildMoonWorkspaceIndex({ root, files, readFile, tsConfig, readGraph }) {
649
+ let graph;
650
+ try {
651
+ graph = readGraph(root);
652
+ } catch (cause) {
653
+ // A Moon invocation that cannot answer — binary missing from the
654
+ // workspace's `node_modules/.bin`, nonzero exit, output that will not
655
+ // parse (`../providers/moon.test.mjs` pins each at the provider) — leaves
656
+ // ZERO nodes, and a zero-node graph judges every file clean. Recorded as
657
+ // `moonModelFailure` on an otherwise-valid empty index rather than thrown:
658
+ // one broken invocation must not blank the session, exactly as a broken
659
+ // `archkeep.json` does not (`buildNativeWorkspaceIndex`'s catch above), and
660
+ // `indexGaps` turns it into a diagnostic naming the failed command on every
661
+ // open document. The next successful rebuild clears it; nothing watches the
662
+ // binary, so that rebuild arrives through any watched-file change or an
663
+ // editor restart.
664
+ return {
665
+ root,
666
+ files,
667
+ workspace: { root, projects: [], filesOf: () => [], readFile, tsConfig },
668
+ graph: { nodes: Object.create(null), dependencies: Object.create(null) },
669
+ skippedProjects: [],
670
+ fileFailures: [],
671
+ // Nothing was analyzed on this path — the provider threw before any
672
+ // project was known, so no file could be attributed or read — and an
673
+ // empty list is the honest answer, not a missing field every consumer
674
+ // would have to guard against.
675
+ importSites: [],
676
+ nativeMarker: false,
677
+ nativeModelFailure: null,
678
+ moonModelFailure: cause?.message ?? String(cause),
679
+ nxModelFailure: null,
680
+ workspaceLayoutFailure: null,
681
+ };
682
+ }
683
+
684
+ // The `Workspace` object and ownership map, from the same `createWorkspace`
685
+ // every other branch here uses — one longest-root answer to "which project
686
+ // owns this file" for both faces.
687
+ const { workspace, owned } = createWorkspace({ root, graph, files, tsConfig, read: readFile });
688
+ const projectOfFile = new Map(owned.map(({ file, project }) => [file, project]));
689
+ // The same Module Federation and `package.json` facts the Nx branch, the
690
+ // native branch and `../../cli.mjs`'s Moon branch all compute, from the same
691
+ // shared functions (`../workspace.mjs`) — a CLI verdict and an editor verdict
692
+ // on the same import must match.
693
+ annotateMFERemotes(graph.nodes, readFile);
694
+ annotatePackageFacts(graph.nodes, readFile);
695
+
696
+ const { importSites, fileFailures } = analyzeTrackedFiles({ files, workspace });
697
+ // Moon's own graph carries only edges Moon itself resolved (`dependsOn`);
698
+ // the imports this tree writes are folded in by the SAME merge the CLI's
699
+ // Moon branch runs (`../providers/moon.mjs`'s `mergeImportEdges`), so an
700
+ // undeclared crossing is judged by the reachability rules in the editor
701
+ // exactly as `check` judges it — one implementation, not two (#223).
702
+ mergeImportEdges(graph, {
703
+ importSites,
704
+ projectOf: (file) => projectOfFile.get(file),
705
+ });
706
+
707
+ return {
708
+ root,
709
+ files,
710
+ workspace,
711
+ graph,
712
+ skippedProjects: [],
713
+ fileFailures,
714
+ // Retained past the graph build — the evidence half of `evaluate()`'s
715
+ // input (`./diagnose.mjs` composes its run from these), on this branch
716
+ // exactly as on the other two.
717
+ importSites,
718
+ nativeMarker: false,
719
+ nativeModelFailure: null,
720
+ moonModelFailure: null,
721
+ nxModelFailure: null,
722
+ workspaceLayoutFailure: null,
723
+ };
724
+ }
725
+
726
+ /**
727
+ * What the index could not read, as sentences a reader can act on — empty when
728
+ * the tree was read whole.
729
+ *
730
+ * ## Why a gap is reported to EVERY document and not to a chosen few
731
+ *
732
+ * The obvious economy is to tell only the documents a gap can plausibly reach:
733
+ * the files inside the project that vanished, the imports that pointed at it.
734
+ * It is not sound. Two of the fifteen rules are decided on the transitive
735
+ * closure of the graph — `noCircularDependencies`, and the upstream half of
736
+ * `notDependOnLibsWithTags` — and `../rules/reachability.mjs` builds that
737
+ * closure over every node. A project missing from `nodes` also silently drops
738
+ * every edge that pointed at it (`buildDependencies` refuses an edge to a node
739
+ * it does not have), and a file recorded in `fileFailures` was never analyzed,
740
+ * so it contributed none. Either one moves the closure for projects that are
741
+ * nowhere near the file that broke. Deciding a document is unaffected would
742
+ * mean recomputing its verdict against the complete graph — which is the thing
743
+ * that could not be built.
744
+ *
745
+ * ## Why saying it everywhere is still not noise
746
+ *
747
+ * Not because the audience is narrow, but because of what is said and when:
748
+ *
749
+ * - **One diagnostic, never one per gap.** Every gap folds into a single
750
+ * warning with a bounded list (`./diagnostics.mjs`), so the marker count does
751
+ * not scale with the breakage.
752
+ * - **A `skippedProjects`/`fileFailures` gap exists only while Nx is broken
753
+ * too.** `project.json` is parsed the way Nx parses it, so a skipped
754
+ * project is a file `nx graph` also refuses — a state a developer is
755
+ * walking out of, not one they work in. It also **clears itself**:
756
+ * `project.json` is already a watched file (`./server.mjs`), so the fix
757
+ * republishes every open document without any editor action.
758
+ * - **A `nativeModelFailure` gap behaves like the other two, not like the
759
+ * permanent one this replaced.** It is present only while
760
+ * `../providers/native/index.mjs`'s `discover()` actually threw — a
761
+ * defect in `archkeep.json` or the tree it describes, not merely the fact
762
+ * that the root carries one — and it **clears itself** the same way:
763
+ * `archkeep.json` is already a watched file (`./server.mjs`), so fixing it
764
+ * republishes every open document without any editor action.
765
+ * - **A `moonModelFailure` gap is the Moon branch's own member of that same
766
+ * family**, not a second copy of any of them: present only while
767
+ * `../providers/moon.mjs`'s `readProjectGraph` actually threw — binary
768
+ * missing, nonzero exit, unparseable output — never merely because the root
769
+ * carries a Moon marker. It clears itself on the next successful rebuild,
770
+ * which nothing watches the `moon` binary to trigger: it arrives through
771
+ * any watched-file change or an editor restart, and the gap names the
772
+ * command whose failure a developer has to resolve.
773
+ * - **An `nxModelFailure` gap is that same family again, for the Nx-shaped
774
+ * branch's own project discovery.** Present only while `nx.json` marks the
775
+ * root AND `discoverProjects` found no project in it at all — never merely
776
+ * because the root carries `nx.json`. A package-based Nx workspace (projects
777
+ * declared in `package.json`, no `project.json` anywhere) is the shape that
778
+ * reaches it, and reading that shape is NOT what this server does about it:
779
+ * `../providers/nx.mjs`'s `readProjectGraph` asks Nx for that graph and
780
+ * `../../cli.mjs`'s `check` reports violations on the same tree, so silence
781
+ * here is the editor painting clean a workspace the CLI is failing. It
782
+ * **clears itself** like the rest: `nx.json` and `project.json` are both
783
+ * watched (`./server.mjs`), so the first `project.json` the tree gains
784
+ * republishes every open document.
785
+ * - **A `workspaceLayoutFailure` gap is the Nx-shaped branch's own
786
+ * equivalent of `nativeModelFailure`, not a second copy of it.** It is
787
+ * present only while `NX_CONFIG_FILE`'s own `workspaceLayout` is malformed
788
+ * or declared partially (`../options.mjs`'s `readWorkspaceLayout` /
789
+ * `requireCompleteWorkspaceLayout`, called from `buildWorkspaceIndex`
790
+ * above) — never on the native branch, where the identically-shaped
791
+ * failure already surfaces as `nativeModelFailure` instead (see
792
+ * `buildNativeWorkspaceIndex`). It **clears itself** the same way: `nx.json`
793
+ * is already a watched file (`./server.mjs`), so fixing it republishes
794
+ * every open document without any editor action. Silently discarding the
795
+ * whole declaration instead — falling back to a default layout — would
796
+ * evaluate `noRelativeOrAbsoluteImportsAcrossLibraries` against a layout
797
+ * the workspace does not use, which reads to a developer as "no
798
+ * violation" rather than "this could not be checked".
799
+ *
800
+ * Each sentence names a path, so the diagnostic says which file to open.
801
+ *
802
+ * @param {{skippedProjects?: {file: string, reason: string}[], fileFailures?: {sourceFile: string, reason: string}[], nativeModelFailure?: string|null, moonModelFailure?: string|null, nxModelFailure?: string|null, workspaceLayoutFailure?: string|null}} index
803
+ * @returns {string[]}
804
+ */
805
+ export function indexGaps({
806
+ skippedProjects = [],
807
+ fileFailures = [],
808
+ nativeModelFailure = null,
809
+ moonModelFailure = null,
810
+ nxModelFailure = null,
811
+ workspaceLayoutFailure = null,
812
+ } = {}) {
813
+ return [
814
+ ...(nativeModelFailure === null
815
+ ? []
816
+ : [
817
+ `${ARCHKEEP_MODEL_FILE} at the workspace root could not be turned into a project model ` +
818
+ `(${firstLine(nativeModelFailure)}), so every project it declares or infers is ` +
819
+ `missing from the graph entirely`,
820
+ ]),
821
+ ...(moonModelFailure === null
822
+ ? []
823
+ : [
824
+ "`moon project-graph --json` could not be turned into a project model " +
825
+ `(${firstLine(moonModelFailure)}), so every project it resolves is missing from ` +
826
+ `the graph entirely`,
827
+ ]),
828
+ ...(nxModelFailure === null
829
+ ? []
830
+ : [
831
+ `${NX_CONFIG_FILE} at the workspace root marks an Nx workspace whose projects could ` +
832
+ `not be found (${firstLine(nxModelFailure)}), so every project it has is ` +
833
+ `missing from the graph entirely — \`archkeep check\`, which asks Nx itself for the ` +
834
+ `graph, still judges this tree`,
835
+ ]),
836
+ ...(workspaceLayoutFailure === null
837
+ ? []
838
+ : [
839
+ `${NX_CONFIG_FILE}'s workspaceLayout could not be read (${firstLine(workspaceLayoutFailure)}), ` +
840
+ `so imports across a non-default apps/libs boundary are judged against the default layout ` +
841
+ `instead of the one this workspace declared`,
842
+ ]),
843
+ ...skippedProjects.map(
844
+ ({ file, reason }) =>
845
+ `${file} ${firstLine(reason)}, so that project is missing from the graph entirely`,
846
+ ),
847
+ ...fileFailures.map(
848
+ ({ sourceFile, reason }) =>
849
+ `${sourceFile} could not be analyzed (${firstLine(reason)}), so the imports it makes are ` +
850
+ `missing from the graph`,
851
+ ),
852
+ ];
853
+ }
854
+
855
+ /**
856
+ * The first line of a recorded reason.
857
+ *
858
+ * Nx's parse errors carry a multi-line code frame after their first line, and
859
+ * that frame is decoration around a fact the first line already states — the
860
+ * `line:column` of the offending character. A diagnostic message that opens an
861
+ * ASCII drawing mid-sentence is read as noise, which is the one thing this
862
+ * report cannot afford to be. The full text stays in the index's own records.
863
+ */
864
+ const firstLine = (reason) => String(reason).split("\n")[0].trimEnd();
865
+
866
+ /**
867
+ * Workspace-relative read; `null` for a file that is absent or unreadable.
868
+ *
869
+ * The contract's own reader shape (`../analysis/contract.md`): `null` rather
870
+ * than a throw, because an analyzer treats a file it cannot read as a failure
871
+ * record and one such file must not blank a whole run.
872
+ *
873
+ * @param {string} root Absolute workspace root.
874
+ * @param {string} path Workspace-relative.
875
+ * @returns {string|null}
876
+ */
877
+ export function readWorkspaceFile(root, path) {
878
+ const abs = join(root, path);
879
+ // Same containment rule as `createWorkspace`'s default reader
880
+ // (`../workspace.mjs`): a tracked symlink whose realpath leaves the
881
+ // workspace is outside code read as the workspace's own source, so it is
882
+ // refused rather than read. `null` is a whole-file failure here — the
883
+ // analyzer records it, `analyzeTrackedFiles` surfaces it as an `indexGaps`
884
+ // diagnostic, never a silently empty index (`../../AGENTS.md`).
885
+ if (containmentViolation(root, abs) !== null) return null;
886
+ try {
887
+ return readFileSync(abs, "utf8");
888
+ } catch {
889
+ return null;
890
+ }
891
+ }