@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.
- package/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- 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
|
+
}
|