@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,922 @@
1
+ /**
2
+ * The language server itself: lifecycle, document store, and the one place
3
+ * diagnostics are allowed to leave the process.
4
+ *
5
+ * Transport-free on purpose. It is handed a `send` and an `exit` and never
6
+ * touches a stream, so the whole protocol conversation can be driven in a test
7
+ * without a subprocess — while `../../lsp.mjs`, which owns the stdio wiring,
8
+ * stays small enough to read in one screen.
9
+ *
10
+ * ## Why a language server and not an ESLint plugin
11
+ *
12
+ * An ESLint plugin reaches what ESLint can parse, and in this workspace that is
13
+ * JS, TS and `.vue` — the root `eslint.config.mjs` hands the last one
14
+ * `vue-eslint-parser` and states the boundary rule in a block with no `files`
15
+ * filter, so `@nx/enforce-module-boundaries` judges a single-file component
16
+ * too. Measured: both engines report the same crossing for the same `.vue`
17
+ * import, differing only in the column they point at.
18
+ *
19
+ * Go, Rust and Python have no ESLint parser at all — `eslint` answers "File
20
+ * ignored because no matching configuration was supplied" and the project's
21
+ * `lint` target exits 0 over a violating import (`../../AGENTS.md`). So this
22
+ * server is the ONLY enforcement those three have, and for JS, TS and Vue it
23
+ * is a second opinion that `../conformance/` holds to the same verdict. One
24
+ * protocol every editor speaks serves all of them at once.
25
+ *
26
+ * ## The rule that governs every path through this file
27
+ *
28
+ * A published empty diagnostic list is a statement: "this file is clean". It is
29
+ * made in exactly two places, and both are named — `publishDiagnostics` after
30
+ * `diagnoseDocument` reported `analyzed: true`, and `clearDiagnostics` when a
31
+ * document closes and its markers must go. Nothing else may publish an empty
32
+ * list, and `publishDiagnostics` re-checks the invariant rather than trusting
33
+ * the caller of `diagnoseDocument` to have honoured it.
34
+ */
35
+ import { existsSync } from "node:fs";
36
+ import { isAbsolute, join, posix, relative, sep } from "node:path";
37
+
38
+ import {
39
+ DEFAULT_OPTIONS,
40
+ MOON_TSCONFIG_CHAIN,
41
+ MOON_TSCONFIG_SOURCE,
42
+ NX_CONFIG_FILE,
43
+ readMoonOptions,
44
+ readPluginOptions,
45
+ } from "../options.mjs";
46
+ import { moonMarkerAt } from "../providers/moon.mjs";
47
+ import { ARCHKEEP_MODEL_FILE, loadNativeModel } from "../providers/native/model.mjs";
48
+
49
+ import { readBoundaryConfig } from "./boundary-config.mjs";
50
+ import { analysisFailedDiagnostic, documentLines } from "./diagnostics.mjs";
51
+ import { diagnoseDocument } from "./diagnose.mjs";
52
+ import {
53
+ ERROR_CODES,
54
+ MESSAGE_TYPE,
55
+ SERVER_INFO,
56
+ TEXT_DOCUMENT_SYNC_KIND,
57
+ uriToPath,
58
+ } from "./protocol.mjs";
59
+ import {
60
+ buildWorkspaceIndex,
61
+ listWorkspaceFiles,
62
+ PROJECT_CONFIG_FILE,
63
+ readWorkspaceFile,
64
+ } from "./workspace-index.mjs";
65
+
66
+ /**
67
+ * A project's own `package.json`, and the two spellings of its Module
68
+ * Federation config — the per-project files `../workspace.mjs` reads for the
69
+ * three graph fields that WAIVE a violation.
70
+ *
71
+ * The names are that module's, not new ones: `annotatePackageFacts` reads the
72
+ * first, and `projectIsMFERemote` reads `module-federation.config.js` with the
73
+ * `.ts` spelling as its fallback, in that order and with upstream's own
74
+ * precedence. They are written out here because `watchedFilesFor` below needs
75
+ * the NAMES rather than the readings, and `../workspace.mjs` exports no
76
+ * constant for either — a reader changing one of those filenames has to change
77
+ * this list too, or the file stops being watched while it keeps being read.
78
+ */
79
+ const PACKAGE_MANIFEST_FILE = "package.json";
80
+ const MFE_CONFIG_FILES = Object.freeze([
81
+ "module-federation.config.js",
82
+ "module-federation.config.ts",
83
+ ]);
84
+
85
+ /**
86
+ * The files whose content changes the verdict for files that did not change,
87
+ * given the options this session resolved.
88
+ *
89
+ * Derived rather than constant, and that is the whole point of the function: the
90
+ * boundary config's filename is a per-workspace option now, and a watcher list
91
+ * fixed at module load would keep watching `module-boundaries.config.mjs` in a
92
+ * workspace that renamed it. Editing the real config would then change nothing
93
+ * on screen — every open file would keep showing a verdict from the config as it
94
+ * was when the editor opened, which is the silent-stale failure this server's
95
+ * whole revision mechanism exists to prevent.
96
+ *
97
+ * `nx.json` is here for the same reason one step further out: it is where the
98
+ * options themselves live, so a change to it can rename the config file. A
99
+ * server watching only the file the OLD options named would go on watching a
100
+ * filename the workspace stopped using. `archkeep.json` is watched for the
101
+ * same reason on the native side: `readWorkspaceOptions` below reads its
102
+ * `boundaryConfig`/`tsConfig` fields directly (`../providers/native/model.mjs`
103
+ * — a native root has no `nx.json` `plugins` table to nest them under), so an
104
+ * edit to either can rename the config the same way an `nx.json` edit can.
105
+ *
106
+ * `options.tsConfig` is here for the same argument one step further in: every
107
+ * TypeScript verdict is resolved against the compiler options that file names,
108
+ * so a `paths` edit there changes verdicts for files that did not change — and
109
+ * a server not watching it keeps showing the old verdict until an unrelated
110
+ * invalidation happens to re-read it. Same derivation seam as `boundaryConfig`:
111
+ * the name is an option, so the watched set is derived from the resolved
112
+ * options rather than fixed at module load.
113
+ *
114
+ * On a MOON root that name was not stated anywhere — it came off an ordered
115
+ * chain (`../options.mjs`'s `MOON_TSCONFIG_CHAIN`, flagged by
116
+ * `tsConfigSource`) — and there the whole chain is watched rather than the one
117
+ * entry that answered. Watching only the winner would hold the ordering half
118
+ * of the same staleness this list already refuses: with `tsconfig.json`
119
+ * chosen, a `tsconfig.base.json` appearing beside it takes the resolution over
120
+ * WITHOUT the chosen file being touched, so no notification would arrive and
121
+ * every verdict for the rest of the session would keep resolving through a
122
+ * table the tool no longer reads. The reverse arrival — the winner deleted —
123
+ * is the same event seen from the other side, and equally unwatched.
124
+ *
125
+ * Only the ROOT copy of either name can change the chain's answer, but the
126
+ * glob `registerFileWatchers` builds is `**\/<entry>`, so `tsconfig.json`
127
+ * also matches every per-project `apps/*\/tsconfig.json` — ubiquitous in the
128
+ * Vue and Angular Moon workspaces this chain exists for. Each such edit bumps
129
+ * the revision and re-diagnoses the open documents for a file that could not
130
+ * have moved the resolution. That is the same trade `package.json` below
131
+ * makes and settles the same way: the cost is LOUD (a slow editor while
132
+ * someone edits tsconfigs) and the failure it buys out is silent.
133
+ *
134
+ * `package.json` and the two `module-federation.config` spellings are here for
135
+ * that same argument one step further in again, and in the direction that
136
+ * matters most: each of them decides a verdict for files that did not change,
137
+ * and each decides it by WAIVING. `../workspace.mjs`'s `annotatePackageFacts`
138
+ * reads a project's `package.json` for `data.entryPoints` (the secondary
139
+ * entry-point exemption) and `data.declaredPackages` (what waives
140
+ * `noTransitiveDependencies`), and its `projectIsMFERemote` reads the Module
141
+ * Federation config for `data.mfeRemote` (the `noImportsOfApps` exemption —
142
+ * `../rules/index.mjs`). Delete that config, or drop a dependency from a
143
+ * `package.json`, and the violation those facts were waiving is real now —
144
+ * while a server not watching either file has no way to learn it: only a
145
+ * watched-file change bumps the revision, and every publish until then reads
146
+ * the annotations cached against the old one. The stale waiver then publishes
147
+ * `[]` for a real violation for the rest of the session, which is the silent
148
+ * direction exactly (`../../../../AGENTS.md`). `package.json` earns the entry a
149
+ * second way as well: it is where `./workspace-index.mjs`'s `discoverProjects`
150
+ * takes a project's NAME when its `project.json` states none, so an edit to it
151
+ * can move a project in the graph and not only waive something in it.
152
+ *
153
+ * Those three are PER-PROJECT paths rather than workspace-root singletons, and
154
+ * they need no new mechanism for it: `registerFileWatchers` gives every entry
155
+ * the glob `**\/<entry>` and `touchesWatchedFile` matches the same reach — an
156
+ * exact workspace-relative match, or a path ending in `/<entry>` — which is
157
+ * what already lets a `project.json` at any depth invalidate. That reach is
158
+ * also the cost, and it is not free: `**\/package.json` matches every manifest
159
+ * under `node_modules` too, so on a large workspace an install can fire a burst
160
+ * of notifications, each one dropping the index and re-analyzing the tree.
161
+ * That cost is LOUD — a slow editor, for as long as the install runs — and the
162
+ * failure it buys out is silent, which is the trade the two unequal error
163
+ * directions settle. Narrowing the glob to the project roots the index happens
164
+ * to know would trade it straight back: the project added after that
165
+ * registration is then the one nothing watches.
166
+ *
167
+ * An INLINE policy contributes no entry of its own, and needs none: the law is
168
+ * then a field on `archkeep.json`, which the list already carries
169
+ * unconditionally, so an edit to it invalidates through that entry. Spreading
170
+ * the object in instead would produce the glob `**\/[object Object]` — one
171
+ * that matches no file ever, while reading in the registration like a watched
172
+ * law. The file carrying the law would look covered and no notification would
173
+ * arrive: the silent direction, in the one list whose whole job is noticing
174
+ * that the law moved.
175
+ *
176
+ * `unresolved` is the state where the options THEMSELVES could not be read,
177
+ * and there the tsconfig half widens to the whole Moon chain regardless of
178
+ * provenance. The reason is the refusal that can put a session into this
179
+ * state: `../options.mjs`'s `readMoonOptions` throws when a Moon root carries
180
+ * TypeScript and neither chain entry, and its message tells the developer to
181
+ * add `tsconfig.base.json` OR rename their config to `tsconfig.json`. Take the
182
+ * second branch under a watch list built from `DEFAULT_OPTIONS` — which
183
+ * carries no `tsConfigSource` — and only `tsconfig.base.json` is watched: the
184
+ * file that ends the failure arrives unwatched, no notification fires, and the
185
+ * session republishes the same refusal on every open document until the editor
186
+ * restarts. That is the staleness the paragraph above refuses, reached through
187
+ * the one door that had no provenance to steer by, and it would make this
188
+ * server answer its own remedy with silence.
189
+ *
190
+ * @param {{boundaryConfig: string|object, tsConfig: string, tsConfigSource?: string}} options
191
+ * The session's resolved options. `tsConfigSource` is present only on a Moon
192
+ * root, where the name came off a chain rather than from a declaration.
193
+ * @param {{unresolved?: boolean}} [state] `unresolved: true` while
194
+ * `optionsFailure` is set — the session does not know which provider it has,
195
+ * because working that out is part of what failed, so it watches every name
196
+ * that could end the failure.
197
+ * @returns {readonly string[]}
198
+ */
199
+ export function watchedFilesFor(options, { unresolved = false } = {}) {
200
+ return Object.freeze([
201
+ ...(typeof options.boundaryConfig === "string" ? [options.boundaryConfig] : []),
202
+ ...(unresolved
203
+ ? [...new Set([...MOON_TSCONFIG_CHAIN, options.tsConfig])]
204
+ : options.tsConfigSource === MOON_TSCONFIG_SOURCE
205
+ ? MOON_TSCONFIG_CHAIN
206
+ : [options.tsConfig]),
207
+ PROJECT_CONFIG_FILE,
208
+ NX_CONFIG_FILE,
209
+ ARCHKEEP_MODEL_FILE,
210
+ PACKAGE_MANIFEST_FILE,
211
+ ...MFE_CONFIG_FILES,
212
+ ]);
213
+ }
214
+
215
+ /**
216
+ * The two facts that decide which project-model provider a workspace root's
217
+ * options are read from: does it carry `nx.json`, `archkeep.json`, or — a
218
+ * state `readWorkspaceOptions` below refuses — both.
219
+ *
220
+ * Mirrors `../../cli.mjs`'s own `markersAt`, not imported from it: that
221
+ * module is an executable entry point, not a library this one may depend on,
222
+ * and the check itself is two `existsSync` calls — cheap enough that a
223
+ * second, independent copy costs less than a new coupling between the two
224
+ * faces would.
225
+ *
226
+ * @param {string} root
227
+ * @returns {{hasNx: boolean, hasNative: boolean}}
228
+ */
229
+ function markersAt(root) {
230
+ return {
231
+ hasNx: existsSync(join(root, NX_CONFIG_FILE)),
232
+ hasNative: existsSync(join(root, ARCHKEEP_MODEL_FILE)),
233
+ };
234
+ }
235
+
236
+ /**
237
+ * The resolved options for a workspace root, read from whichever marker file
238
+ * it actually carries — the same choice `../../cli.mjs`'s `check` makes, so
239
+ * the CLI and the editor never disagree about which config filenames a given
240
+ * workspace uses.
241
+ *
242
+ * A native root's `boundaryConfig`/`tsConfig` live on `archkeep.json` itself
243
+ * (`../providers/native/model.mjs`'s `loadNativeModel`) rather than under an
244
+ * `nx.json` `plugins` table it does not have, so `readPluginOptions` — which
245
+ * only ever reads `nx.json` — would silently return the defaults for a
246
+ * native workspace that renamed either file. A root carrying both markers is
247
+ * refused outright, the same refusal `check` makes: which project model to
248
+ * judge against is a decision nobody made, not one this server can make for
249
+ * them by picking a provider.
250
+ *
251
+ * `boundaryConfig` can also be an inline policy OBJECT rather than a filename
252
+ * (`../providers/native/model.mjs`'s doc comment on `findNativeModelViolations`,
253
+ * "boundaryConfig"; `../../../../docs/reference/policy-schema.md`, "An inline policy, for
254
+ * archkeep.json"), and that object is returned as-is for the two places
255
+ * downstream that know what to do with it: `watchedFilesFor` omits it and
256
+ * leans on the `archkeep.json` entry it already has, and
257
+ * `./boundary-config.mjs` validates it instead of reading a file. Both are
258
+ * argued where they are implemented. What makes the pair sound is that this
259
+ * function is called again on every invalidation: the object it returns is
260
+ * whatever `archkeep.json` says NOW, so an edit to an inline law re-diagnoses
261
+ * exactly like an edit to a law in its own file.
262
+ *
263
+ * A MOON root has neither marker file and no plugin-options table of its own,
264
+ * so both names are convention there and `../options.mjs`'s `readMoonOptions`
265
+ * decides them — including the refusal when the tree carries files that
266
+ * resolve through a `paths` table and no config the chain names to read it
267
+ * from. That refusal reaches the developer the same way an unreadable
268
+ * `nx.json` does: `refreshOptions` catches it into `optionsFailure`, and every
269
+ * open document is published a diagnostic saying so rather than a verdict
270
+ * computed from a table that was never found.
271
+ *
272
+ * @param {string} root
273
+ * @returns {{boundaryConfig: string|object, tsConfig: string, tsConfigSource?: string}}
274
+ * `tsConfigSource` is present only on a Moon root — see `watchedFilesFor`,
275
+ * which is the one reader that acts on it.
276
+ * @throws {Error} when both markers are present, a Moon root carries both Moon
277
+ * directories, the marker present is unreadable or malformed, an Nx root's
278
+ * options name a `profiles` registry — a profile NAME is not a file this
279
+ * server could watch or parse — or a Moon root names no tsconfig the
280
+ * convention chain can find while carrying files that need one.
281
+ */
282
+ export function readWorkspaceOptions(root) {
283
+ const { hasNx, hasNative } = markersAt(root);
284
+ if (hasNx && hasNative) {
285
+ throw new Error(
286
+ `archkeep: ${root} declares both nx.json and archkeep.json — this server judges a ` +
287
+ `workspace against exactly one project model, the same refusal ../../cli.mjs's check ` +
288
+ `makes, and a tree carrying both is a decision nobody made rather than one this tool ` +
289
+ `can make for them. Remove whichever one is not the workspace's real source of truth ` +
290
+ `for projects and tags.`,
291
+ );
292
+ }
293
+ if (hasNative) {
294
+ const model = loadNativeModel(root, { readFile: (path) => readWorkspaceFile(root, path) });
295
+ return { boundaryConfig: model.boundaryConfig, tsConfig: model.tsConfig };
296
+ }
297
+ // A Moon root, checked only once neither marker file is there: a `.moon`
298
+ // beside `nx.json` keeps falling to `readPluginOptions` below exactly as it
299
+ // did, and `./workspace-index.mjs`'s `buildWorkspaceIndex` refuses the pair
300
+ // loudly through the one shared gate (`../commands/context.mjs`'s
301
+ // `requireSingleProjectModel`) rather than this function growing a second
302
+ // copy of that refusal. `moonMarkerAt` is the same dispatcher the index and
303
+ // the CLI read, so all three agree about which directory marks the tree —
304
+ // and its own refusal of a root carrying BOTH Moon spellings arrives here as
305
+ // an options failure, which `refreshOptions` publishes to every open
306
+ // document instead of diagnosing against a config nobody chose.
307
+ //
308
+ // Before this branch the server read `readPluginOptions` on a Moon tree,
309
+ // which only ever reads `nx.json`: it answered the DEFAULTS, so the editor
310
+ // resolved every TypeScript import against `tsconfig.base.json` whether or
311
+ // not the workspace had one. `readMoonOptions` is where that name becomes a
312
+ // chain, and it is the same function the CLI resolves from, so the two faces
313
+ // cannot come to hold different paths tables for one workspace.
314
+ const moonMarker = moonMarkerAt(root);
315
+ if (moonMarker !== null) {
316
+ // `listFiles` is a thunk and only spent on the branch that needs it —
317
+ // neither chain entry present — so an ordinary Moon session pays no git
318
+ // spawn here on top of the one the index already runs.
319
+ return readMoonOptions(root, { listFiles: () => listWorkspaceFiles(root) });
320
+ }
321
+ const options = readPluginOptions(root);
322
+ if (typeof options.profiles === "string") {
323
+ throw new Error(
324
+ `archkeep: ${root}'s nx.json options name a profiles registry (${options.profiles}) — a ` +
325
+ `valid form (../../cli.mjs's check resolves a policy by profile name from it), but not ` +
326
+ `one this language server can load yet: it only ever reads a policy FILE, and a profile ` +
327
+ `name is a selector, not a path it could watch or parse. Enforce by file instead — ` +
328
+ `remove the profiles option, or point boundaryConfig at an .mjs or .json file — see ` +
329
+ `../../../../docs/concepts/profiles.md.`,
330
+ );
331
+ }
332
+ return options;
333
+ }
334
+
335
+ /**
336
+ * The id both `client/registerCapability` and its matching unregister use. One
337
+ * constant because they have to be the same string: an unregister naming
338
+ * anything else leaves the old watcher in place, and the client then watches two
339
+ * sets at once.
340
+ */
341
+ const WATCHER_REGISTRATION_ID = "archkeep/watched-files";
342
+
343
+ /**
344
+ * What `initialize` promises. Every entry is something the server answers.
345
+ *
346
+ * `change` is `full`: the client re-sends the whole document and the server
347
+ * re-analyzes it. Incremental sync is not advertised because it is not
348
+ * implemented, and a server that advertised it and then mis-applied one ranged
349
+ * edit would put every later diagnostic on the wrong line — see
350
+ * `./protocol.mjs`.
351
+ *
352
+ * `save.includeText` is `false`: the server already holds the buffer the editor
353
+ * is showing, and re-reading the saved bytes would answer about a different
354
+ * text than the one on screen.
355
+ */
356
+ export const SERVER_CAPABILITIES = Object.freeze({
357
+ textDocumentSync: Object.freeze({
358
+ openClose: true,
359
+ change: TEXT_DOCUMENT_SYNC_KIND.full,
360
+ save: Object.freeze({ includeText: false }),
361
+ }),
362
+ });
363
+
364
+ /** POSIX-relative path of `absolutePath` inside `root`, or `null` when outside. */
365
+ function workspaceRelative(root, absolutePath) {
366
+ const rel = relative(root, absolutePath).split(sep).join(posix.sep);
367
+ if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) return null;
368
+ return rel;
369
+ }
370
+
371
+ /**
372
+ * Creates a server instance.
373
+ *
374
+ * @param {object} options
375
+ * @param {(message: object) => void} options.send Writes one JSON-RPC message
376
+ * to the client.
377
+ * @param {(code: number) => void} options.exit Ends the process. Injected so a
378
+ * test can observe the exit code instead of losing its own runner to it.
379
+ * @param {(text: string) => void} [options.log] Diagnostics about the server
380
+ * itself. Never stdout: stdout carries the protocol, and one stray line there
381
+ * desynchronises the stream for the rest of the session.
382
+ * @param {(options: object) => object} [options.buildIndex] Injected for tests.
383
+ * @param {(root: string, revision: number, boundaryConfig: string) => Promise<object>} [options.readConfig] Injected for tests.
384
+ * @param {(root: string) => object} [options.readOptions] Injected for tests.
385
+ * @returns {{handle: (message: object) => Promise<void>}}
386
+ */
387
+ export function createServer({
388
+ send,
389
+ exit,
390
+ log = () => {},
391
+ buildIndex = buildWorkspaceIndex,
392
+ readConfig = readBoundaryConfig,
393
+ readOptions = readWorkspaceOptions,
394
+ }) {
395
+ /** `starting` → `running` → `shuttingDown`. `exit` ends it from any of them. */
396
+ let phase = "starting";
397
+ let root = null;
398
+ /**
399
+ * The session's resolved plugin options, and — when `nx.json` could not be
400
+ * read or declared a bad one — the reason, published to every open document.
401
+ *
402
+ * The fallback is the DEFAULTS rather than nothing, and only for deciding what
403
+ * to WATCH: a session that watched no files could never notice the `nx.json`
404
+ * fix that would end the failure, so it would stay broken until the editor
405
+ * restarted. Nothing is ever diagnosed from these defaults — `currentResources`
406
+ * refuses while `optionsFailure` is set.
407
+ */
408
+ let options = { ...DEFAULT_OPTIONS };
409
+ let optionsFailure = null;
410
+ let shutdownRequested = false;
411
+ /** Bumped whenever something the verdict depends on changed on disk. */
412
+ let revision = 0;
413
+ /** `{revision, promise}`; the promise always RESOLVES, to an ok/error record. */
414
+ let resources = null;
415
+ /** Outgoing request ids, kept apart from the client's id space. */
416
+ let outgoingId = 0;
417
+ /** Whether the client can be asked to watch files, learned at `initialize`. */
418
+ let clientSupportsFileWatching = false;
419
+ /** The globs the client is currently registered for; `null` before the first. */
420
+ let registeredGlobs = null;
421
+ const documents = new Map();
422
+
423
+ const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
424
+ const fail = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
425
+ const notify = (method, params) => send({ jsonrpc: "2.0", method, params });
426
+
427
+ /**
428
+ * The index and config for the current revision, as an ok/error record rather
429
+ * than a rejected promise: a rejection cached across every open document
430
+ * would surface as an unhandled rejection long after the message that caused
431
+ * it, and the reason would be published to the developer anyway.
432
+ */
433
+ function currentResources() {
434
+ if (resources?.revision === revision) return resources.promise;
435
+ const at = revision;
436
+ const promise = (async () => {
437
+ // An unreadable `nx.json` is refused here rather than absorbed, because
438
+ // the options decide which file the law is read FROM: continuing on the
439
+ // defaults would diagnose every open document against a config the
440
+ // workspace may not use, and publish those verdicts as though they were
441
+ // the workspace's own.
442
+ if (optionsFailure !== null) throw new Error(optionsFailure);
443
+ const [index, config] = await Promise.all([
444
+ // `tsConfig` goes into the index because the index owns the workspace
445
+ // object the analyzer caches against; a rename therefore arrives with
446
+ // the rebuilt index rather than needing a second invalidation path.
447
+ Promise.resolve().then(() => buildIndex({ root, tsConfig: options.tsConfig })),
448
+ readConfig(root, at, options.boundaryConfig),
449
+ ]);
450
+ return { ok: true, index, config };
451
+ })().catch((cause) => ({ ok: false, reason: cause?.message ?? String(cause) }));
452
+ resources = { revision: at, promise };
453
+ return promise;
454
+ }
455
+
456
+ /**
457
+ * Re-reads the plugin options for the current root.
458
+ *
459
+ * Called at `initialize` and again whenever `nx.json` changes, because a
460
+ * change there can rename the config file — and a server still watching the
461
+ * old name would never see the next edit to the new one.
462
+ */
463
+ function refreshOptions() {
464
+ try {
465
+ options = readOptions(root);
466
+ optionsFailure = null;
467
+ } catch (cause) {
468
+ // The defaults are restored so the watcher list stays well-formed and
469
+ // `nx.json` keeps being watched; `optionsFailure` is what stops anything
470
+ // being diagnosed from them.
471
+ options = { ...DEFAULT_OPTIONS };
472
+ optionsFailure = cause?.message ?? String(cause);
473
+ log(`archkeep: ${optionsFailure}`);
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Publishes the verdict for one open document.
479
+ *
480
+ * The invariant check below is not defensive programming for its own sake. It
481
+ * is the second of the two guards described in this file's header: if any
482
+ * future edit to `diagnoseDocument` ever returns `analyzed: false` with
483
+ * nothing to show, this turns that bug into a visible diagnostic instead of
484
+ * into a file the editor paints clean.
485
+ */
486
+ async function publishDiagnostics(uri) {
487
+ const document = documents.get(uri);
488
+ if (!document) return;
489
+ const lines = documentLines(document.text);
490
+
491
+ const outcome = await diagnoseFor(document, lines);
492
+ let diagnostics = outcome.diagnostics;
493
+ if (!outcome.analyzed && diagnostics.length === 0) {
494
+ diagnostics = [
495
+ analysisFailedDiagnostic(
496
+ "the analysis reported that it did not complete, but produced nothing to show. " +
497
+ "Publishing this rather than an empty list, which would read as a clean file",
498
+ lines,
499
+ ),
500
+ ];
501
+ }
502
+ notify("textDocument/publishDiagnostics", { uri, diagnostics, version: document.version });
503
+ }
504
+
505
+ /** The diagnosis for one document, with every failure turned into something to show. */
506
+ async function diagnoseFor(document, lines) {
507
+ if (document.unavailable !== null) {
508
+ return {
509
+ analyzed: false,
510
+ diagnostics: [analysisFailedDiagnostic(document.unavailable, lines)],
511
+ };
512
+ }
513
+ const loaded = await currentResources();
514
+ if (!loaded.ok) {
515
+ return { analyzed: false, diagnostics: [analysisFailedDiagnostic(loaded.reason, lines)] };
516
+ }
517
+ return diagnoseDocument({
518
+ sourceFile: document.sourceFile,
519
+ text: document.text,
520
+ index: loaded.index,
521
+ config: loaded.config,
522
+ });
523
+ }
524
+
525
+ /**
526
+ * Removes the markers for a document that is no longer open.
527
+ *
528
+ * The one deliberate empty publish. It is a different statement from "this
529
+ * file is clean" — it says "I no longer speak for this file" — and it is
530
+ * spelled as its own function so it can never be reached by falling through
531
+ * the diagnosis path.
532
+ */
533
+ function clearDiagnostics(uri) {
534
+ notify("textDocument/publishDiagnostics", { uri, diagnostics: [] });
535
+ }
536
+
537
+ /**
538
+ * Drops the cached index and config and re-diagnoses every open document.
539
+ *
540
+ * Reached whenever the constraint table or the project list moved: every open
541
+ * file's verdict was computed against a tree that no longer exists, and
542
+ * leaving those markers up shows a verdict from a config that has been
543
+ * deleted — which is the same lie as showing no marker at all, told the other
544
+ * way round.
545
+ */
546
+ async function invalidateAndRepublish() {
547
+ revision += 1;
548
+ resources = null;
549
+ // Re-read before re-diagnosing: the change may have BEEN the options, and
550
+ // diagnosing first would answer one more time from the config the old
551
+ // options named.
552
+ refreshOptions();
553
+ log(
554
+ `archkeep: ${watchedFilesFor(options, { unresolved: optionsFailure !== null }).join("/")} changed; re-diagnosing open documents`,
555
+ );
556
+ // The watched set is derived from the options that may just have changed, so
557
+ // the client's registration is re-checked before anything is re-diagnosed.
558
+ // Cheap when nothing moved: `registerFileWatchers` compares the globs and
559
+ // returns.
560
+ registerFileWatchers();
561
+ for (const uri of documents.keys()) await publishDiagnostics(uri);
562
+ }
563
+
564
+ /**
565
+ * Records an open document, and — where it cannot be judged at all — WHY.
566
+ *
567
+ * `unavailable` is a sentence rather than a flag because it is published
568
+ * verbatim. A URI naming no file on disk and a file outside the workspace are
569
+ * different mistakes with different fixes, and telling a developer only that
570
+ * "something is wrong" costs them the diagnosis this server just made.
571
+ */
572
+ function openDocument({ uri, text, version }) {
573
+ const path = uriToPath(uri);
574
+ const sourceFile = path === null ? null : workspaceRelative(root, path);
575
+ const textMissing = typeof text !== "string";
576
+ documents.set(uri, {
577
+ uri,
578
+ text: textMissing ? "" : text,
579
+ version: version ?? null,
580
+ sourceFile,
581
+ unavailable: textMissing
582
+ ? `'${uri}' opened without any text, so its contents are unknown here — ` +
583
+ `analyzing the empty string this buffer would otherwise stand in for would ` +
584
+ `report a verdict about a file the server never saw`
585
+ : path === null
586
+ ? `'${uri}' names no file on disk — an untitled buffer or a virtual document — ` +
587
+ `so no project owns it and no boundary rule can be applied to it`
588
+ : sourceFile === null
589
+ ? `this file is outside the workspace root the server was initialized with (${root}), ` +
590
+ `so no project owns it and no constraint applies to it`
591
+ : null,
592
+ });
593
+ }
594
+
595
+ async function handleInitialize(id, params) {
596
+ if (phase !== "starting") {
597
+ return fail(id, ERROR_CODES.invalidRequest, "archkeep: already initialized");
598
+ }
599
+ // Precedence is the protocol's own, newest field first, with an explicit
600
+ // override ahead of all of them: an editor rooted at a subdirectory of the
601
+ // workspace would otherwise find no `module-boundaries.config.mjs` and no
602
+ // projects, and report every file clean for the best of reasons.
603
+ const optionRoot = params?.initializationOptions?.workspaceRoot;
604
+ root =
605
+ (typeof optionRoot === "string" && optionRoot !== "" ? optionRoot : null) ??
606
+ uriToPath(params?.workspaceFolders?.[0]?.uri) ??
607
+ uriToPath(params?.rootUri) ??
608
+ (typeof params?.rootPath === "string" ? params.rootPath : null) ??
609
+ process.cwd();
610
+ phase = "running";
611
+ refreshOptions();
612
+ // The always-on marker for an unreadable options state. Every open document
613
+ // gets the reason on its diagnostics, but a session with no document open
614
+ // yet — the editor pane on first launch — would otherwise show nothing at
615
+ // all, and a session that quietly stays empty is indistinguishable from one
616
+ // that is healthy. One `window/showMessage`, at the moment the failure is
617
+ // known, marks the session for the whole of its life; the per-document
618
+ // diagnostics then keep the marker in front of every open file.
619
+ if (optionsFailure !== null) {
620
+ notify("window/showMessage", { type: MESSAGE_TYPE.error, message: optionsFailure });
621
+ }
622
+ clientSupportsFileWatching =
623
+ params?.capabilities?.workspace?.didChangeWatchedFiles?.dynamicRegistration === true;
624
+ log(`archkeep: language server initialized for ${root}`);
625
+ reply(id, { capabilities: SERVER_CAPABILITIES, serverInfo: SERVER_INFO });
626
+ }
627
+
628
+ /**
629
+ * Asks the client to watch the files a verdict depends on.
630
+ *
631
+ * Only sent when the client said it supports dynamic registration, because
632
+ * that is the only way to ask: a client that watches nothing simply never
633
+ * sends `workspace/didChangeWatchedFiles`, and the server keeps answering
634
+ * from the config it read at startup until a document is saved. That is a
635
+ * real limitation of such a client, not a fallback this server can implement.
636
+ *
637
+ * Re-sent when the watched set changes, which it can: `nx.json`'s
638
+ * `boundaryConfig` names one of these files, so editing it retires one glob
639
+ * and adds another. The stale registration is unregistered first, under the
640
+ * fixed id both calls use — a client left holding both would go on reporting
641
+ * a filename this workspace no longer reads, and would report the new one
642
+ * nowhere at all.
643
+ */
644
+ function registerFileWatchers() {
645
+ const globs = watchedFilesFor(options, { unresolved: optionsFailure !== null }).map(
646
+ (file) => `**/${file}`,
647
+ );
648
+ if (!clientSupportsFileWatching) {
649
+ log(
650
+ "archkeep: the client does not support dynamic file watching, so a change to " +
651
+ `${watchedFilesFor(options, { unresolved: optionsFailure !== null }).join(" or ")} will not re-diagnose open files on its own`,
652
+ );
653
+ return;
654
+ }
655
+ if (registeredGlobs !== null) {
656
+ // Same list, same registration — re-sending it would leave the client with
657
+ // two watchers for every glob and this server with two notifications per
658
+ // edit.
659
+ if (
660
+ registeredGlobs.length === globs.length &&
661
+ registeredGlobs.every((glob, index) => glob === globs[index])
662
+ ) {
663
+ return;
664
+ }
665
+ send({
666
+ jsonrpc: "2.0",
667
+ id: `archkeep/${++outgoingId}`,
668
+ method: "client/unregisterCapability",
669
+ params: {
670
+ unregisterations: [
671
+ { id: WATCHER_REGISTRATION_ID, method: "workspace/didChangeWatchedFiles" },
672
+ ],
673
+ },
674
+ });
675
+ }
676
+ registeredGlobs = globs;
677
+ send({
678
+ jsonrpc: "2.0",
679
+ id: `archkeep/${++outgoingId}`,
680
+ method: "client/registerCapability",
681
+ params: {
682
+ registrations: [
683
+ {
684
+ id: WATCHER_REGISTRATION_ID,
685
+ method: "workspace/didChangeWatchedFiles",
686
+ registerOptions: {
687
+ watchers: globs.map((globPattern) => ({ globPattern })),
688
+ },
689
+ },
690
+ ],
691
+ },
692
+ });
693
+ }
694
+
695
+ /**
696
+ * Did any of these changes touch a file the verdict depends on?
697
+ *
698
+ * Matched against the workspace-relative path, not the basename: a watched
699
+ * entry can legitimately carry directory components of its own (a
700
+ * `boundaryConfig` of `configs/boundaries.mjs`, `registerFileWatchers`
701
+ * turning it into the glob `**\/configs/boundaries.mjs`), and a bare
702
+ * basename comparison never equals that entry no matter how the real file
703
+ * changes — the watcher registration would look correct while an edit to
704
+ * that exact file silently never re-diagnosed anything. The comparison
705
+ * below mirrors the glob it registered: an exact match on the
706
+ * workspace-relative path, or that path ending with `/` + the watched
707
+ * entry, which is what `**\/entry` means for a path at any depth — the
708
+ * same reach a flat entry (`project.json`, `nx.json`) needs to keep, since
709
+ * those legitimately live at any depth in the tree.
710
+ */
711
+ function touchesWatchedFile(changes) {
712
+ const watched = watchedFilesFor(options, { unresolved: optionsFailure !== null });
713
+ return (changes ?? []).some((change) => {
714
+ const path = uriToPath(change?.uri);
715
+ if (path === null) return false;
716
+ const rel = workspaceRelative(root, path);
717
+ if (rel === null) return false;
718
+ return watched.some((entry) => rel === entry || rel.endsWith(`/${entry}`));
719
+ });
720
+ }
721
+
722
+ async function dispatch(message) {
723
+ const { id, method, params } = message ?? {};
724
+ const isRequest = id !== undefined && id !== null;
725
+
726
+ // A response to one of this server's own requests — `client/registerCapability`
727
+ // is the only one it sends. Nothing is owed, and answering it would put a
728
+ // reply to a reply on the wire.
729
+ if (method === undefined && isRequest) return;
730
+
731
+ if (method === "exit") {
732
+ exit(shutdownRequested ? 0 : 1);
733
+ return;
734
+ }
735
+
736
+ if (phase === "shuttingDown") {
737
+ // The specification is explicit: after `shutdown`, a request errors and a
738
+ // notification is ignored. Answering normally would let a client keep
739
+ // using a server that has already released its state.
740
+ if (isRequest) {
741
+ fail(
742
+ id,
743
+ ERROR_CODES.invalidRequest,
744
+ `archkeep: '${method}' arrived after shutdown; the server is no longer serving requests`,
745
+ );
746
+ }
747
+ return;
748
+ }
749
+
750
+ if (method === "initialize") {
751
+ if (isRequest) await handleInitialize(id, params);
752
+ return;
753
+ }
754
+
755
+ if (phase === "starting") {
756
+ if (isRequest) {
757
+ fail(
758
+ id,
759
+ ERROR_CODES.serverNotInitialized,
760
+ `archkeep: '${method}' arrived before 'initialize'`,
761
+ );
762
+ }
763
+ return;
764
+ }
765
+
766
+ switch (method) {
767
+ case "initialized":
768
+ registerFileWatchers();
769
+ return;
770
+
771
+ case "shutdown":
772
+ phase = "shuttingDown";
773
+ shutdownRequested = true;
774
+ documents.clear();
775
+ resources = null;
776
+ if (isRequest) reply(id, null);
777
+ return;
778
+
779
+ case "textDocument/didOpen": {
780
+ const doc = params?.textDocument;
781
+ openDocument({ uri: doc?.uri, text: doc?.text, version: doc?.version });
782
+ await publishDiagnostics(doc?.uri);
783
+ return;
784
+ }
785
+
786
+ case "textDocument/didChange": {
787
+ const uri = params?.textDocument?.uri;
788
+ const document = documents.get(uri);
789
+ if (!document) return;
790
+ const changes = params?.contentChanges ?? [];
791
+ // Full sync is what this server advertised, so the last change with no
792
+ // `range` is meant to be the whole document. A ranged change means the
793
+ // client ignored the advertised sync kind; the stored text would then
794
+ // be a fiction and every position computed from it would be wrong, so
795
+ // the document is marked unanalyzable rather than analyzed from a text
796
+ // the server only thinks it has.
797
+ //
798
+ // The search for that "last full change" has to keep looking past it
799
+ // for anything ranged that follows: LSP applies `contentChanges`
800
+ // sequentially, so a ranged edit ordered AFTER the last full change is
801
+ // still part of the true final text, and treating the full change as
802
+ // the whole document would silently drop it — analyzing text this
803
+ // server does not actually have and publishing a verdict about it, the
804
+ // third, unnamed place an empty list would otherwise leave the
805
+ // workspace. A mixed batch is the same misbehaving-client case as a
806
+ // purely incremental one, not a third, quieter outcome.
807
+ let lastFullIndex = -1;
808
+ for (let i = changes.length - 1; i >= 0; i -= 1) {
809
+ if (changes[i]?.range === undefined) {
810
+ lastFullIndex = i;
811
+ break;
812
+ }
813
+ }
814
+ const full = lastFullIndex === -1 ? undefined : changes[lastFullIndex];
815
+ const trailingRanged =
816
+ lastFullIndex !== -1 &&
817
+ changes.slice(lastFullIndex + 1).some((change) => change?.range !== undefined);
818
+ if (full === undefined) {
819
+ document.unavailable =
820
+ `this buffer arrived as an incremental change, but the server advertised full ` +
821
+ `text synchronisation — its contents are unknown here, so every position this ` +
822
+ `server could report about it would be a guess`;
823
+ log(
824
+ `archkeep: ${uri} arrived as an incremental change, but the server ` +
825
+ `advertised full text synchronisation; its contents are now unknown`,
826
+ );
827
+ } else if (trailingRanged) {
828
+ document.unavailable =
829
+ `this buffer's change batch carried a full-document change followed by a ranged ` +
830
+ `one — LSP applies contentChanges sequentially, so the true final text includes ` +
831
+ `that trailing edit, and this server advertised full text synchronisation only, ` +
832
+ `so its contents are unknown here rather than the pre-edit text the full change ` +
833
+ `alone would produce`;
834
+ log(
835
+ `archkeep: ${uri} arrived with a full change followed by a ranged one; ` +
836
+ `its contents are now unknown`,
837
+ );
838
+ } else if (typeof full.text !== "string") {
839
+ // `contentChanges: [{}]` — a full change carrying no text. The empty
840
+ // string it would otherwise fall back to is not the file's contents
841
+ // but the ABSENCE of a statement about them, and analyzing that as
842
+ // the file would publish a verdict about text the buffer does not
843
+ // have. Same class as the incremental branch above: mark it
844
+ // unanalyzable, loudly, rather than analyze a fiction.
845
+ document.unavailable =
846
+ `this buffer's latest full change carried no text, so its contents are unknown ` +
847
+ `here — a change that was meant to replace the whole document arrived empty, and ` +
848
+ `every position this server could report about it would be a guess`;
849
+ log(
850
+ `archkeep: ${uri} arrived with a full change carrying no text; ` +
851
+ `its contents are now unknown`,
852
+ );
853
+ } else {
854
+ // The one path that RESUMES a verdict: a real full change replaces the
855
+ // text, which is exactly the situation an earlier `unavailable` reason
856
+ // stopped applying to. Only a document whose file exists in the
857
+ // workspace is resumable, though — the untitled-buffer and
858
+ // outside-workspace reasons are structural, fixed by no text change,
859
+ // and clearing them would let a `sourceFile === null` document reach
860
+ // diagnosis and publish a verdict no project owns.
861
+ document.text = full.text;
862
+ if (document.sourceFile !== null) document.unavailable = null;
863
+ }
864
+ document.version = params?.textDocument?.version ?? document.version;
865
+ await publishDiagnostics(uri);
866
+ return;
867
+ }
868
+
869
+ case "textDocument/didSave":
870
+ // Saving one of the two files a verdict depends on invalidates every
871
+ // OTHER file's verdict too. A client that watches files reports the
872
+ // same change through `didChangeWatchedFiles`, and re-diagnosing twice
873
+ // costs one index build; a client that cannot watch gets the reload it
874
+ // would otherwise never get, as long as the change was made in the
875
+ // editor rather than beside it.
876
+ if (touchesWatchedFile([{ uri: params?.textDocument?.uri }])) {
877
+ await invalidateAndRepublish();
878
+ return;
879
+ }
880
+ await publishDiagnostics(params?.textDocument?.uri);
881
+ return;
882
+
883
+ case "textDocument/didClose": {
884
+ const uri = params?.textDocument?.uri;
885
+ if (documents.delete(uri)) clearDiagnostics(uri);
886
+ return;
887
+ }
888
+
889
+ case "workspace/didChangeWatchedFiles":
890
+ if (!touchesWatchedFile(params?.changes)) return;
891
+ await invalidateAndRepublish();
892
+ return;
893
+
894
+ default:
895
+ if (isRequest) {
896
+ fail(
897
+ id,
898
+ ERROR_CODES.methodNotFound,
899
+ `archkeep: '${method}' is not implemented. This server publishes boundary ` +
900
+ `diagnostics over ${Object.keys(SERVER_CAPABILITIES).join(", ")} and answers nothing else.`,
901
+ );
902
+ }
903
+ }
904
+ }
905
+
906
+ // One message at a time, in arrival order. Diagnosing is asynchronous, and
907
+ // two overlapping runs would race to publish for the same URI — the editor
908
+ // would then show whichever finished last, which is not necessarily the one
909
+ // computed from the newest text.
910
+ let queue = Promise.resolve();
911
+
912
+ return {
913
+ handle(message) {
914
+ queue = queue
915
+ .then(() => dispatch(message))
916
+ .catch((cause) => {
917
+ log(`archkeep: ${cause?.stack ?? cause}`);
918
+ });
919
+ return queue;
920
+ },
921
+ };
922
+ }