@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,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace's boundary law, read by a process that outlives edits to it.
|
|
3
|
+
*
|
|
4
|
+
* `../config.mjs` already loads and validates that file, and this module reuses
|
|
5
|
+
* its pure validators (`policyKeyViolations`, `policyFrom`) rather than
|
|
6
|
+
* restating what a well-formed policy looks like — one answer to "is this
|
|
7
|
+
* table well-formed", shared by every dialect and every face. What it does NOT
|
|
8
|
+
* reuse is `loadBoundaryConfig`/`loadBoundaryConfigFile` themselves, and for
|
|
9
|
+
* the `.mjs`/`.js` dialect the reason is one line of ESM semantics: `import()`
|
|
10
|
+
* memoises a module URL for the life of the process. A CLI run imports the
|
|
11
|
+
* config once and exits, so memoisation is invisible there. A language server
|
|
12
|
+
* runs for hours across edits to that very file, and a second `import()` of
|
|
13
|
+
* the same URL would hand back the constraint table as it was when the editor
|
|
14
|
+
* opened — the editor would then re-diagnose every file against a config that
|
|
15
|
+
* no longer exists, which is precisely the failure re-diagnosing on a config
|
|
16
|
+
* change is meant to prevent.
|
|
17
|
+
*
|
|
18
|
+
* So the `.mjs`/`.js` URL carries a revision the caller controls. The `.json`
|
|
19
|
+
* dialect needs no such trick — `readFile` reads whatever is on disk at the
|
|
20
|
+
* moment it is called, with no module cache in the way — so `revision` is
|
|
21
|
+
* accepted for both dialects (one entry point, one signature) but only spent
|
|
22
|
+
* on the one that needs it. The inline form spends it least of all: it arrives
|
|
23
|
+
* as an object `./server.mjs` already re-read from `archkeep.json`, so there is
|
|
24
|
+
* no read here to make stale.
|
|
25
|
+
*
|
|
26
|
+
* A THIRD dialect exists — `../config.mjs`'s ESLint flat-config reader
|
|
27
|
+
* (`../eslint-config.mjs`), which the CLI and Nx-plugin faces both read
|
|
28
|
+
* (`docs/concepts/policies.md`) — and this server does not read it yet. That
|
|
29
|
+
* reader resolves the workspace's own installed `@nx/eslint-plugin` and has
|
|
30
|
+
* no notion of a revisioned `import()` to defeat this process's module cache
|
|
31
|
+
* across edits; wiring both mechanisms together belongs to the milestone that
|
|
32
|
+
* actually adds live ESLint-dialect support to the editor, not to this one.
|
|
33
|
+
* Until then, an `eslint.config.*` or legacy `.eslintrc*` `boundaryConfig` is
|
|
34
|
+
* refused BY NAME below — on basename, before the extension dispatch ever
|
|
35
|
+
* runs — rather than reaching `readModulePolicy`'s bare `import()` and
|
|
36
|
+
* failing on an unrelated "not a module object" a reader could not connect
|
|
37
|
+
* back to "this is an ESLint config".
|
|
38
|
+
*/
|
|
39
|
+
import { existsSync } from "node:fs";
|
|
40
|
+
import { readFile as readFileFromDisk } from "node:fs/promises";
|
|
41
|
+
import { basename, extname, resolve } from "node:path";
|
|
42
|
+
import { pathToFileURL } from "node:url";
|
|
43
|
+
|
|
44
|
+
import { containmentViolation } from "../containment.mjs";
|
|
45
|
+
import {
|
|
46
|
+
ESLINT_FLAT_CONFIG_BASENAME,
|
|
47
|
+
LEGACY_ESLINTRC_BASENAME,
|
|
48
|
+
policyFrom,
|
|
49
|
+
policyKeyViolations,
|
|
50
|
+
} from "../config.mjs";
|
|
51
|
+
import { ARCHKEEP_MODEL_FILE } from "../providers/native/model.mjs";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Loads and validates the `.mjs`/`.js` dialect through a revisioned `import()`
|
|
55
|
+
* — see this module's header for why the revision exists.
|
|
56
|
+
*
|
|
57
|
+
* The top-level exports carry the same key law `../config.mjs`'s
|
|
58
|
+
* `loadModulePolicy` applies: an export beyond the four this loader reads is
|
|
59
|
+
* refused by name, through the same `policyKeyViolations` the `.json` arm and
|
|
60
|
+
* the CLI both use — a config the CLI refuses must not load clean in the editor
|
|
61
|
+
* and re-paint every open file against a typo'd, no-op law.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} path Absolute path of the config file.
|
|
64
|
+
* @param {string|number} revision Busts the ESM module cache across edits.
|
|
65
|
+
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }>}
|
|
66
|
+
* @throws {Error} when the file is missing, unloadable, or malformed.
|
|
67
|
+
*/
|
|
68
|
+
async function readModulePolicy(path, revision) {
|
|
69
|
+
const url = `${pathToFileURL(path).href}?revision=${encodeURIComponent(String(revision))}`;
|
|
70
|
+
let module;
|
|
71
|
+
try {
|
|
72
|
+
module = await import(url);
|
|
73
|
+
} catch (cause) {
|
|
74
|
+
throw new Error(`archkeep: cannot load ${path}: ${cause?.message ?? cause}`, { cause });
|
|
75
|
+
}
|
|
76
|
+
return policyFrom(module, path, policyKeyViolations(module, { allowSchema: false }));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Loads and validates the `.json` dialect: plain `JSON.parse`, never JSONC —
|
|
81
|
+
* `../config.mjs`'s `loadJsonPolicy` documents why, and this is the same
|
|
82
|
+
* check, reached through the same two exported validators rather than a
|
|
83
|
+
* second copy of either.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} path Absolute path of the config file.
|
|
86
|
+
* @param {(path: string, encoding: "utf8") => Promise<string>} readFile
|
|
87
|
+
* Injected so a test can drive this without a real file — see
|
|
88
|
+
* `readBoundaryConfig` below.
|
|
89
|
+
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }>}
|
|
90
|
+
* @throws {Error} when the file is missing, unreadable, not valid JSON, or
|
|
91
|
+
* malformed — either by `../config.mjs`'s `findBoundaryConfigViolations` or
|
|
92
|
+
* by carrying a top-level key none of those rules knows about.
|
|
93
|
+
*/
|
|
94
|
+
async function readJsonPolicy(path, readFile) {
|
|
95
|
+
let text;
|
|
96
|
+
try {
|
|
97
|
+
text = await readFile(path, "utf8");
|
|
98
|
+
} catch (cause) {
|
|
99
|
+
throw new Error(`archkeep: cannot load ${path}: ${cause?.message ?? cause}`, { cause });
|
|
100
|
+
}
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(text);
|
|
104
|
+
} catch (cause) {
|
|
105
|
+
throw new Error(`archkeep: cannot load ${path}: ${cause?.message ?? cause}`, { cause });
|
|
106
|
+
}
|
|
107
|
+
return policyFrom(parsed, path, policyKeyViolations(parsed, { allowSchema: true }));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Loads and validates the boundary config at `workspaceRoot`.
|
|
112
|
+
*
|
|
113
|
+
* Basename is tested FIRST, exactly the same two patterns and the same order
|
|
114
|
+
* `../config.mjs`'s `loadBoundaryConfigFile` uses, and for the identical
|
|
115
|
+
* reason: both an `eslint.config.*` name and a legacy `.eslintrc*` name are
|
|
116
|
+
* `.mjs`/`.js`-extensioned (or extensionless) often enough that reaching the
|
|
117
|
+
* extension dispatch first would either half-work or fail on a message that
|
|
118
|
+
* never mentions ESLint. Only once neither basename matches does the
|
|
119
|
+
* extension decide between `.mjs`/`.js` (`readModulePolicy`) and `.json`
|
|
120
|
+
* (`readJsonPolicy`); anything else is refused by name, in a message that
|
|
121
|
+
* does not contain the words "cannot load" — a `boundaryConfig` misspelt to a
|
|
122
|
+
* `.yaml` or `.toml` extension is a naming mistake, not a missing or
|
|
123
|
+
* unreadable file, and the two must read as different problems. This server
|
|
124
|
+
* used to only ever recognise the `.mjs`/`.js` spelling — a `.json`
|
|
125
|
+
* `boundaryConfig` reached `readModulePolicy`'s bare `import()`, which Node
|
|
126
|
+
* refuses for JSON with `ERR_IMPORT_ATTRIBUTE_MISSING`, a message that names
|
|
127
|
+
* an import-attributes problem rather than the missing dialect support that
|
|
128
|
+
* was the real cause.
|
|
129
|
+
*
|
|
130
|
+
* The ESLint dialect itself is refused rather than read — see this module's
|
|
131
|
+
* header for why this server does not (yet) reuse `../eslint-config.mjs`.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} workspaceRoot Absolute path of the tree being judged — never
|
|
134
|
+
* derived from this file's own location, for the reason `../config.mjs`
|
|
135
|
+
* states: pointed at a consumer's tree, the tool's own directory and the
|
|
136
|
+
* workspace's root are in different trees.
|
|
137
|
+
* @param {string|number} revision Anything that changes when the file should be
|
|
138
|
+
* re-read. Spent only by the `.mjs`/`.js` arm — see this module's header.
|
|
139
|
+
* @param {string|object} boundaryConfig The config's filename in this
|
|
140
|
+
* workspace, resolved from the plugin's options by the server that owns the
|
|
141
|
+
* session — or, on a native root that carries its law on `archkeep.json`
|
|
142
|
+
* itself, that inline policy object, validated and returned with no file
|
|
143
|
+
* read at all (the first branch of the body says why none is needed).
|
|
144
|
+
* @param {{readFile?: (path: string, encoding: "utf8") => Promise<string>}} [io]
|
|
145
|
+
* Injectable read, used only by the `.json` dialect, defaulting to
|
|
146
|
+
* `node:fs/promises`'s `readFile`.
|
|
147
|
+
* @returns {Promise<{depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[]}>}
|
|
148
|
+
* `suppressions` is `[]` when the config declares none; `fitness` and
|
|
149
|
+
* `customRules` are present only when the policy declares them, the same
|
|
150
|
+
* absent-is-a-decision shape `../config.mjs`'s `policyFrom` returns to
|
|
151
|
+
* every other face. This server READS both and evaluates neither — a
|
|
152
|
+
* fitness function and a custom rule are per-run workspace judgments, not
|
|
153
|
+
* per-file diagnostics — so what it owes them is to load them and to fail
|
|
154
|
+
* loudly on a row it cannot read.
|
|
155
|
+
* @throws {Error} when `boundaryConfig` names the ESLint flat-config dialect
|
|
156
|
+
* or a legacy `.eslintrc*` file (this reader does not read either yet — see
|
|
157
|
+
* above), or — for a dialect it does read — when the file is missing,
|
|
158
|
+
* unloadable, or malformed, or names an extension neither dialect reads, or
|
|
159
|
+
* — for the inline form — when the policy object is malformed. The same
|
|
160
|
+
* contract `loadBoundaryConfigFile` has, for the same reason: an enforcer
|
|
161
|
+
* that starts with no rules enforces nothing and says nothing.
|
|
162
|
+
*/
|
|
163
|
+
export async function readBoundaryConfig(
|
|
164
|
+
workspaceRoot,
|
|
165
|
+
revision,
|
|
166
|
+
boundaryConfig,
|
|
167
|
+
{ readFile = readFileFromDisk } = {},
|
|
168
|
+
) {
|
|
169
|
+
// An inline policy is DATA, not a path, so every mechanic below it is moot:
|
|
170
|
+
// there is no name to resolve, nothing to contain, no dialect to dispatch on,
|
|
171
|
+
// and no module cache for `revision` to defeat. `./server.mjs`'s
|
|
172
|
+
// `readWorkspaceOptions` has already re-read this object out of
|
|
173
|
+
// `archkeep.json` for the current revision, and that file is watched
|
|
174
|
+
// unconditionally by `watchedFilesFor`, so an edit to the law arrives here as
|
|
175
|
+
// a different object rather than as a file this function would have to
|
|
176
|
+
// re-read.
|
|
177
|
+
//
|
|
178
|
+
// It is still validated rather than trusted. `../providers/native/model.mjs`
|
|
179
|
+
// checked it at load, which makes this the second pass over a table small
|
|
180
|
+
// enough to review by eye — cheap, and the alternative is a face that
|
|
181
|
+
// enforces whatever its caller hands it. `allowSchema` matches the `.json`
|
|
182
|
+
// arm below because the inline form accepts `$schema` for the same
|
|
183
|
+
// editor-validation reason a policy file does.
|
|
184
|
+
if (typeof boundaryConfig !== "string") {
|
|
185
|
+
return policyFrom(
|
|
186
|
+
boundaryConfig,
|
|
187
|
+
`the inline policy on ${ARCHKEEP_MODEL_FILE}'s boundaryConfig at ${workspaceRoot}`,
|
|
188
|
+
policyKeyViolations(boundaryConfig, { allowSchema: true }),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const path = `${workspaceRoot.replace(/\/$/, "")}/${boundaryConfig}`;
|
|
192
|
+
// Resolved ONCE, and the IDENTICAL string feeds the containment check and
|
|
193
|
+
// the read below. The law name is tree-derived (`nx.json`/`archkeep.json`
|
|
194
|
+
// options), so a tracked symlink in an intermediate component of it would
|
|
195
|
+
// hand outside constraint rows in as the workspace's law — the same read
|
|
196
|
+
// escape `../config.mjs`'s `loadBoundaryConfig` now refuses; this is that
|
|
197
|
+
// check held on the language-server face. Resolving first is the
|
|
198
|
+
// resolve-first contract `../containment.mjs`'s `containsDotDot` refusal
|
|
199
|
+
// exists for: a `..` in the raw name would be normalised away for the check
|
|
200
|
+
// while the read still followed it (`../containment.mjs`, read-side G-10).
|
|
201
|
+
const resolved = resolve(path);
|
|
202
|
+
if (existsSync(workspaceRoot)) {
|
|
203
|
+
const violation = containmentViolation(workspaceRoot, resolved);
|
|
204
|
+
if (violation !== null) {
|
|
205
|
+
throw new Error(`archkeep: cannot load ${path}: ${violation}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const name = basename(resolved);
|
|
209
|
+
if (ESLINT_FLAT_CONFIG_BASENAME.test(name) || LEGACY_ESLINTRC_BASENAME.test(name)) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`archkeep: ${path} names an ESLint config (${name}) as boundaryConfig — the language ` +
|
|
212
|
+
"server reads only the .mjs/.js and .json policy-file dialects for now, not ESLint's " +
|
|
213
|
+
"flat-config dialect the CLI and Nx-plugin faces also read " +
|
|
214
|
+
"(../../../../docs/reference/policy-schema.md). Point boundaryConfig at an .mjs, .js, or .json " +
|
|
215
|
+
"boundary-law file to use it from the editor.",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
const extension = extname(resolved);
|
|
219
|
+
if (extension === ".mjs" || extension === ".js") return readModulePolicy(resolved, revision);
|
|
220
|
+
if (extension === ".json") return readJsonPolicy(resolved, readFile);
|
|
221
|
+
throw new Error(
|
|
222
|
+
`archkeep: ${path} names an unsupported boundaryConfig extension '${extension || "(none)"}' — ` +
|
|
223
|
+
`expected .mjs, .js, or .json`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One document in, the diagnostics an editor should show for it out.
|
|
3
|
+
*
|
|
4
|
+
* ## The single invariant this module exists to hold
|
|
5
|
+
*
|
|
6
|
+
* **An empty diagnostic list must mean "no violation", and nothing else.**
|
|
7
|
+
*
|
|
8
|
+
* An editor draws no marker for a file whose diagnostics are `[]`, and a
|
|
9
|
+
* developer reads no marker as "checked, clean". So every way this pipeline can
|
|
10
|
+
* fail to reach a verdict has to end in a diagnostic instead of in an empty
|
|
11
|
+
* list: a config that will not load, a workspace index that cannot be built, a
|
|
12
|
+
* language whose analyzer does not exist yet, an analyzer that threw, a rule
|
|
13
|
+
* engine that threw, a parse failure the analyzer recorded as data.
|
|
14
|
+
*
|
|
15
|
+
* The return type is what makes that checkable rather than merely intended.
|
|
16
|
+
* `analyzed` is `true` on exactly ONE path — the one where the analyzer
|
|
17
|
+
* returned, the rule engine returned, AND the tree they were compared against
|
|
18
|
+
* was read whole — and the caller is expected to refuse to publish an empty
|
|
19
|
+
* list unless it is `true`. Two guards for one invariant is deliberate: this
|
|
20
|
+
* module makes the promise, and `./server.mjs` verifies it before the bytes
|
|
21
|
+
* leave the process.
|
|
22
|
+
*
|
|
23
|
+
* ## The third way to reach a wrong verdict, which is not a failure at all
|
|
24
|
+
*
|
|
25
|
+
* The two guards above watch a pipeline that ran. They cannot see a pipeline
|
|
26
|
+
* that ran correctly over the wrong tree: a `project.json` the index could not
|
|
27
|
+
* read takes its project out of the graph, an import into it then resolves as
|
|
28
|
+
* an external package, the rule engine's npm branch returns before any tag
|
|
29
|
+
* check runs, and every function on the path returns normally with nothing to
|
|
30
|
+
* report. `analyzed: true` would be an honest answer from a pipeline handed bad
|
|
31
|
+
* input, and it would publish `[]` over a real violation. So the input is
|
|
32
|
+
* checked too: `indexGaps` reports what the index could not read, and while it
|
|
33
|
+
* reports anything this module does not call a document analyzed.
|
|
34
|
+
*
|
|
35
|
+
* ## The one empty-and-clean case, stated so it is not mistaken for a hole
|
|
36
|
+
*
|
|
37
|
+
* A file whose extension no analyzer claims — `README.md`, `project.json`, an
|
|
38
|
+
* `.svg` — returns `analyzed: true` with no diagnostics. That is the analysis
|
|
39
|
+
* contract's own answer ("an unknown extension is a no-op, not an error"), and
|
|
40
|
+
* it is a real verdict: a file with no imports this tool can see crosses no
|
|
41
|
+
* boundary. The same is true of a file inside no project, which the rule engine
|
|
42
|
+
* places outside the boundary system entirely.
|
|
43
|
+
*/
|
|
44
|
+
import { analyzeFile } from "../analysis/analyze.mjs";
|
|
45
|
+
import { projectOwning } from "../analysis/source-util.mjs";
|
|
46
|
+
import { declaredEdgeViolationsForCheck } from "../commands/edge-constraints.mjs";
|
|
47
|
+
import { evaluate } from "../rules/index.mjs";
|
|
48
|
+
|
|
49
|
+
import {
|
|
50
|
+
analysisFailedDiagnostic,
|
|
51
|
+
documentLines,
|
|
52
|
+
failureDiagnostic,
|
|
53
|
+
incompleteIndexDiagnostic,
|
|
54
|
+
violationDiagnostic,
|
|
55
|
+
} from "./diagnostics.mjs";
|
|
56
|
+
import { indexGaps } from "./workspace-index.mjs";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The diagnostics for one document, and whether a verdict was actually reached.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} request
|
|
62
|
+
* @param {string} request.sourceFile Workspace-relative path of the document.
|
|
63
|
+
* @param {string} request.text Its current contents — the editor's buffer, not
|
|
64
|
+
* what is on disk. Diagnosing the saved file would answer a question nobody
|
|
65
|
+
* asked while the developer is looking at their unsaved edit.
|
|
66
|
+
* @param {{workspace: object, graph: object, skippedProjects?: object[], fileFailures?: object[], importSites?: object[], nativeMarker?: boolean, nativeModelFailure?: string|null, moonModelFailure?: string|null, nxModelFailure?: string|null, workspaceLayoutFailure?: string|null}} request.index
|
|
67
|
+
* From `./workspace-index.mjs`. `importSites` is the whole tree's retained
|
|
68
|
+
* analysis output — the evidence half of the run below; absent (an index
|
|
69
|
+
* built before it existed) reads as none, which degrades evidence, never a
|
|
70
|
+
* verdict.
|
|
71
|
+
* @param {{depConstraints: object[], options: object}} request.config
|
|
72
|
+
* @returns {{analyzed: boolean, diagnostics: object[]}} `analyzed: false`
|
|
73
|
+
* always comes with at least one diagnostic.
|
|
74
|
+
*/
|
|
75
|
+
export function diagnoseDocument({ sourceFile, text, index, config }) {
|
|
76
|
+
const lines = documentLines(text);
|
|
77
|
+
|
|
78
|
+
// What the tree was missing when it was indexed. First in the list and first
|
|
79
|
+
// in the function, because it qualifies every other line the document gets:
|
|
80
|
+
// the rules below ran, and they ran against this.
|
|
81
|
+
const gaps = indexGaps(index);
|
|
82
|
+
const prelude = gaps.length === 0 ? [] : [incompleteIndexDiagnostic(gaps, lines)];
|
|
83
|
+
const wholeTree = gaps.length === 0;
|
|
84
|
+
|
|
85
|
+
let analysis;
|
|
86
|
+
try {
|
|
87
|
+
analysis = analyzeFile({ sourceFile, text, workspace: index.workspace });
|
|
88
|
+
} catch (cause) {
|
|
89
|
+
// The dispatcher throws for a language its extension table claims and no
|
|
90
|
+
// analyzer implements. That is the scaffold staying loud, and it must stay
|
|
91
|
+
// loud here too rather than becoming a green file.
|
|
92
|
+
return {
|
|
93
|
+
analyzed: false,
|
|
94
|
+
diagnostics: [...prelude, analysisFailedDiagnostic(reasonOf(cause), lines)],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Recorded failures come next, and they are published whether or not the
|
|
99
|
+
// rule pass below succeeds: they are the part of the file that was NOT
|
|
100
|
+
// judged, and a reader needs that before they read what was.
|
|
101
|
+
const diagnostics = [
|
|
102
|
+
...prelude,
|
|
103
|
+
...analysis.failures.map((failure) => failureDiagnostic(failure, lines)),
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
// The engine derives its evidence index from exactly the records it is
|
|
107
|
+
// handed (`../rules/index.mjs`'s `createContext`), so this run is handed
|
|
108
|
+
// more than one document's worth: the whole tree's retained disk sites
|
|
109
|
+
// (`./workspace-index.mjs` keeps them on the index for this) MINUS this
|
|
110
|
+
// document's own — its stale disk copy, which the live buffer below replaces
|
|
111
|
+
// — plus the fresh records for the buffer text. Evidence rules then cite the
|
|
112
|
+
// same backing files `lattice check` cites: without the retained sites,
|
|
113
|
+
// `noImportsOfLazyLoadedLibraries`' file list and `noCircularDependencies`'
|
|
114
|
+
// per-hop lists came out empty in the editor whenever the backing import
|
|
115
|
+
// lived in a file nobody had open, while `check` printed them — two faces of
|
|
116
|
+
// one analysis disagreeing about the same tree.
|
|
117
|
+
const combinedSites = [
|
|
118
|
+
...(index.importSites ?? []).filter((site) => site.sourceFile !== sourceFile),
|
|
119
|
+
...analysis.imports,
|
|
120
|
+
];
|
|
121
|
+
// Computed once, before the run: a violation about a file that is neither
|
|
122
|
+
// this document nor one of the files handed to the engine means the engine
|
|
123
|
+
// and this caller disagree about which tree they are discussing (guarded
|
|
124
|
+
// below). No caching beyond what the declared-edge fold further down already
|
|
125
|
+
// does: `evaluate()` already ran once per diagnosis, and the added cost of
|
|
126
|
+
// this change is exactly the larger array it now receives.
|
|
127
|
+
const handedFiles = new Set(combinedSites.map((site) => site.sourceFile));
|
|
128
|
+
|
|
129
|
+
let violations;
|
|
130
|
+
try {
|
|
131
|
+
violations = evaluate(combinedSites, index.graph, config);
|
|
132
|
+
} catch (cause) {
|
|
133
|
+
diagnostics.push(analysisFailedDiagnostic(reasonOf(cause), lines));
|
|
134
|
+
return { analyzed: false, diagnostics };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const violation of violations) {
|
|
138
|
+
if (violation.sourceFile === sourceFile) {
|
|
139
|
+
diagnostics.push(violationDiagnostic(violation, lines));
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
// A violation about another HANDED file belongs to that file's own
|
|
143
|
+
// diagnosis and is dropped here — the engine judged every site it was
|
|
144
|
+
// given, so foreign-file verdicts are expected output now. One naming a
|
|
145
|
+
// file that was NOT handed over is different: no site produced it, so
|
|
146
|
+
// engine and caller are discussing different trees, and the verdict for
|
|
147
|
+
// this file cannot be trusted.
|
|
148
|
+
if (!handedFiles.has(violation.sourceFile)) {
|
|
149
|
+
diagnostics.push(
|
|
150
|
+
analysisFailedDiagnostic(
|
|
151
|
+
`the rule engine returned a violation for '${violation.sourceFile}' while judging ` +
|
|
152
|
+
`'${sourceFile}'; the verdict for this file cannot be trusted`,
|
|
153
|
+
lines,
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
return { analyzed: false, diagnostics };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The edges `evaluate()` structurally cannot reach. An `implicit` edge — an
|
|
161
|
+
// Nx/`archkeep.json` `implicitDependencies` declaration — has no import site
|
|
162
|
+
// behind it, so it never becomes an `importSites` record for the rule engine
|
|
163
|
+
// to iterate. The CLI judges exactly those edges itself
|
|
164
|
+
// (`../commands/edge-constraints.mjs`'s `declaredEdgeViolationsForCheck`,
|
|
165
|
+
// `cli.mjs check`), and without the same fold here the editor would paint a
|
|
166
|
+
// file clean while `check` exits 1 over the same declared edge — the
|
|
167
|
+
// boundary rule that never runs, dressed as a clean tree. Only the violations
|
|
168
|
+
// whose SOURCE project owns this document are reported: a declared edge names
|
|
169
|
+
// a project, not a file, and the whole-file range below is the project-level
|
|
170
|
+
// finding this file is the stand-in for.
|
|
171
|
+
const sourceOwner = projectOwning(index.workspace?.projects ?? [], sourceFile)?.name;
|
|
172
|
+
if (sourceOwner !== undefined) {
|
|
173
|
+
// `declaredEdgeViolationsForCheck` rebuilds reachability and walks every
|
|
174
|
+
// dependency list — an O(V+E) cost that would otherwise be paid on every
|
|
175
|
+
// `didChange` keystroke of every open document. The index is revision-keyed
|
|
176
|
+
// (a fresh object per rebuilt index), so a WeakMap on index identity is a
|
|
177
|
+
// cache that is correct by construction — same index, same graph — and the
|
|
178
|
+
// constraint table rides the same revision (`config` is rebuilt alongside
|
|
179
|
+
// the index), so the pair `(index, depConstraints)` is stable until the
|
|
180
|
+
// tree moves, then cold again.
|
|
181
|
+
let entry = declaredEdgeViolationCache.get(index);
|
|
182
|
+
if (entry === undefined || entry.depConstraints !== config.depConstraints) {
|
|
183
|
+
entry = {
|
|
184
|
+
depConstraints: config.depConstraints,
|
|
185
|
+
violations: declaredEdgeViolationsForCheck(index.graph, config.depConstraints),
|
|
186
|
+
};
|
|
187
|
+
declaredEdgeViolationCache.set(index, entry);
|
|
188
|
+
}
|
|
189
|
+
for (const violation of entry.violations) {
|
|
190
|
+
if (violation.source !== sourceOwner) continue;
|
|
191
|
+
diagnostics.push(violationDiagnostic({ ...violation, line: null, column: null }, lines));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { analyzed: wholeTree, diagnostics };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** One `declaredEdgeViolationsForCheck` computation per `(index, depConstraints)` pair. */
|
|
199
|
+
const declaredEdgeViolationCache = new WeakMap();
|
|
200
|
+
|
|
201
|
+
/** An Error's message, or whatever was thrown, as text a reader can act on. */
|
|
202
|
+
const reasonOf = (cause) => cause?.message ?? String(cause);
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analysis records and rule verdicts, rendered as LSP `Diagnostic` objects.
|
|
3
|
+
*
|
|
4
|
+
* ## The coordinate conversion is the whole reason this module exists
|
|
5
|
+
*
|
|
6
|
+
* The analysis contract is **1-based** in both axes, because that is what a
|
|
7
|
+
* `file:line:column` terminal report wants (`../analysis/contract.md`). The
|
|
8
|
+
* Language Server Protocol is **0-based** in both axes. One subtraction stands
|
|
9
|
+
* between the two, and getting it wrong does not fail loudly: every diagnostic
|
|
10
|
+
* still appears, one line above or below the import it is about, and every
|
|
11
|
+
* developer who follows it edits the wrong line. So the conversion lives in one
|
|
12
|
+
* exported function with its own tests rather than inline at three call sites.
|
|
13
|
+
*
|
|
14
|
+
* The column axis needs no re-encoding to go with the subtraction. The contract
|
|
15
|
+
* counts UTF-16 code units from the start of the line (`../analysis/source-util.mjs`),
|
|
16
|
+
* and `positionEncoding` defaults to `utf-16` in the protocol, so the two axes
|
|
17
|
+
* already measure the same unit. A server that negotiated `utf-8` positions
|
|
18
|
+
* would have to re-encode here; this one advertises none, which leaves the
|
|
19
|
+
* default in force.
|
|
20
|
+
*
|
|
21
|
+
* ## Two kinds of diagnostic, and why the difference is visible in `code`
|
|
22
|
+
*
|
|
23
|
+
* A **violation** carries the upstream `messageId` as its `code`, which is what
|
|
24
|
+
* lets a reader match this server's verdict against the one
|
|
25
|
+
* `@nx/enforce-module-boundaries` gives for the same import.
|
|
26
|
+
*
|
|
27
|
+
* A **failure** — a file that could not be parsed, read, or resolved — carries
|
|
28
|
+
* `ANALYSIS_FAILURE_CODE`, deliberately not one of `MESSAGE_IDS`. The
|
|
29
|
+
* distinction is the point: a consumer must be able to tell "a rule found
|
|
30
|
+
* something" from "no rule could look", and a shared code would collapse them.
|
|
31
|
+
*/
|
|
32
|
+
import { MESSAGE_IDS } from "../rules/messages.mjs";
|
|
33
|
+
|
|
34
|
+
import { DIAGNOSTIC_SEVERITY, SERVER_INFO } from "./protocol.mjs";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The `source` field on every diagnostic this server publishes — what an editor
|
|
38
|
+
* prints beside the message to say which tool spoke. Taken from the server's
|
|
39
|
+
* own identity so the two can never disagree.
|
|
40
|
+
*/
|
|
41
|
+
export const DIAGNOSTIC_SOURCE = SERVER_INFO.name;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The `code` on a diagnostic that reports the ABSENCE of a verdict rather than
|
|
45
|
+
* a verdict. Named to be impossible to mistake for a rule id, and asserted
|
|
46
|
+
* against `MESSAGE_IDS` at load so a future upstream id cannot silently collide
|
|
47
|
+
* with it.
|
|
48
|
+
*/
|
|
49
|
+
export const ANALYSIS_FAILURE_CODE = "analysisFailure";
|
|
50
|
+
|
|
51
|
+
if (MESSAGE_IDS.includes(ANALYSIS_FAILURE_CODE)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`archkeep: '${ANALYSIS_FAILURE_CODE}' is now an upstream messageId, so a ` +
|
|
54
|
+
`diagnostic reporting a failed analysis is indistinguishable from a rule verdict. ` +
|
|
55
|
+
`Rename the failure code.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A document's lines, as the position axes count them.
|
|
61
|
+
*
|
|
62
|
+
* Split on `\n` alone: the contract puts a `\r` at the END of the preceding
|
|
63
|
+
* line and never at the start of the next one, so a CRLF file reports the same
|
|
64
|
+
* columns as an LF one. Splitting on both would shift every column on every
|
|
65
|
+
* line of a CRLF file by nothing and every line index by one — the same
|
|
66
|
+
* off-by-one this module exists to prevent, arriving through the back door.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} text
|
|
69
|
+
* @returns {string[]}
|
|
70
|
+
*/
|
|
71
|
+
export function documentLines(text) {
|
|
72
|
+
return text.split("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const clamp = (value, low, high) => Math.max(low, Math.min(value, high));
|
|
76
|
+
|
|
77
|
+
/** Quote characters a specifier can be written between, in any language here. */
|
|
78
|
+
const QUOTES = new Set(['"', "'", "`"]);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The LSP range covering the import at a 1-based `(line, column)`.
|
|
82
|
+
*
|
|
83
|
+
* Three things happen here, and each is a decision:
|
|
84
|
+
*
|
|
85
|
+
* 1. **The subtraction.** 1-based in, 0-based out, both axes.
|
|
86
|
+
* 2. **The width comes from the document, not from the record.** The analyzers
|
|
87
|
+
* point at the start of the written specifier — which for TypeScript and Go
|
|
88
|
+
* is the OPENING QUOTE and for Rust and Python is the first character of the
|
|
89
|
+
* path itself. Reading the character actually at the start settles which,
|
|
90
|
+
* instead of this module carrying a per-language table that would have to be
|
|
91
|
+
* updated with every new analyzer.
|
|
92
|
+
* 3. **Everything is clamped to the line.** A record can outrun its line
|
|
93
|
+
* legitimately: Rust collapses a `use` path wrapped across several lines
|
|
94
|
+
* into one specifier. A range past the end of a line is out of spec, and
|
|
95
|
+
* clients differ on whether they drop such a diagnostic or clip it — a
|
|
96
|
+
* dropped diagnostic is silence, which this server may not produce.
|
|
97
|
+
*
|
|
98
|
+
* @param {{line: number|null, column: number|null, specifier?: string}} at
|
|
99
|
+
* @param {string[]} lines From `documentLines`.
|
|
100
|
+
* @returns {{start: {line: number, character: number}, end: {line: number, character: number}}}
|
|
101
|
+
*/
|
|
102
|
+
export function rangeAt(at, lines) {
|
|
103
|
+
// A failure about the file as a whole carries no position (`contract.md`
|
|
104
|
+
// fixes it as an explicit `null`). It gets the first line, whole: a
|
|
105
|
+
// zero-width range at the origin renders as an invisible caret in most
|
|
106
|
+
// editors, and an invisible report of "this file was not checked" is the one
|
|
107
|
+
// outcome this server is written to avoid.
|
|
108
|
+
if (at.line === null || at.line === undefined) {
|
|
109
|
+
return {
|
|
110
|
+
start: { line: 0, character: 0 },
|
|
111
|
+
end: { line: 0, character: lines[0]?.length ?? 0 },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const line = clamp(at.line - 1, 0, Math.max(lines.length - 1, 0));
|
|
116
|
+
const text = lines[line] ?? "";
|
|
117
|
+
const startCharacter = clamp((at.column ?? 1) - 1, 0, text.length);
|
|
118
|
+
const specifier = at.specifier ?? "";
|
|
119
|
+
const quoted = QUOTES.has(text[startCharacter]);
|
|
120
|
+
const width = quoted ? specifier.length + 2 : specifier.length;
|
|
121
|
+
const endCharacter = clamp(startCharacter + width, startCharacter, text.length);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
start: { line, character: startCharacter },
|
|
125
|
+
end: { line, character: endCharacter },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* One boundary violation as a diagnostic.
|
|
131
|
+
*
|
|
132
|
+
* Severity is `error`, matching what `@nx/enforce-module-boundaries` reports
|
|
133
|
+
* for the same import in this workspace's ESLint config. A boundary violation
|
|
134
|
+
* downgraded to a warning here would make the same import red in a JS file and
|
|
135
|
+
* yellow in a Go one, for no reason a reader could discover.
|
|
136
|
+
*
|
|
137
|
+
* @param {object} violation A `Violation` from `../rules/`.
|
|
138
|
+
* @param {string[]} lines
|
|
139
|
+
* @returns {object} LSP `Diagnostic`.
|
|
140
|
+
*/
|
|
141
|
+
export function violationDiagnostic(violation, lines) {
|
|
142
|
+
return {
|
|
143
|
+
range: rangeAt(violation, lines),
|
|
144
|
+
severity: DIAGNOSTIC_SEVERITY.error,
|
|
145
|
+
code: violation.messageId,
|
|
146
|
+
source: DIAGNOSTIC_SOURCE,
|
|
147
|
+
message: violation.message,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* One analysis failure as a diagnostic.
|
|
153
|
+
*
|
|
154
|
+
* Severity is `warning` rather than `error`, and the reason is what the two
|
|
155
|
+
* words mean to a reader: an error says "this import is wrong", a warning here
|
|
156
|
+
* says "this file's imports were not all judged". Neither is silence, which is
|
|
157
|
+
* the only property that must hold. The message says so in words, because a
|
|
158
|
+
* severity alone does not tell a developer that the verdict is missing.
|
|
159
|
+
*
|
|
160
|
+
* @param {object} failure An `AnalysisFailure` from `../analysis/`.
|
|
161
|
+
* @param {string[]} lines
|
|
162
|
+
* @returns {object} LSP `Diagnostic`.
|
|
163
|
+
*/
|
|
164
|
+
export function failureDiagnostic(failure, lines) {
|
|
165
|
+
return {
|
|
166
|
+
range: rangeAt(failure, lines),
|
|
167
|
+
severity: DIAGNOSTIC_SEVERITY.warning,
|
|
168
|
+
code: ANALYSIS_FAILURE_CODE,
|
|
169
|
+
source: DIAGNOSTIC_SOURCE,
|
|
170
|
+
message:
|
|
171
|
+
`Module boundaries were not fully checked here: ${failure.reason}. ` +
|
|
172
|
+
`Imports this server could not read are not covered by the verdict below.`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* How many gaps one diagnostic names before it counts the rest.
|
|
178
|
+
*
|
|
179
|
+
* A message is read or it is skipped, and a list of forty paths is skipped. Five
|
|
180
|
+
* is enough to start on and short enough to finish; the count that follows says
|
|
181
|
+
* the rest are there.
|
|
182
|
+
*/
|
|
183
|
+
const NAMED_GAP_LIMIT = 5;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A diagnostic for a verdict computed against an INCOMPLETE project graph.
|
|
187
|
+
*
|
|
188
|
+
* Different from `analysisFailedDiagnostic` in exactly the way that matters to
|
|
189
|
+
* a reader: a rule pass did run over this document and what it found is below.
|
|
190
|
+
* What is missing is part of the tree it was compared against, so the verdict
|
|
191
|
+
* can be short a violation it had no way to see. Severity is `warning` for the
|
|
192
|
+
* same reason `failureDiagnostic` is — "not all judged", not "this is wrong".
|
|
193
|
+
*
|
|
194
|
+
* One diagnostic carries every gap. One per gap would scale the marker count
|
|
195
|
+
* with the breakage, and a developer who has just broken one `project.json`
|
|
196
|
+
* would meet a wall of identical warnings on a file that has nothing to do
|
|
197
|
+
* with it.
|
|
198
|
+
*
|
|
199
|
+
* @param {string[]} gaps From `./workspace-index.mjs`' `indexGaps`.
|
|
200
|
+
* @param {string[]} lines
|
|
201
|
+
* @returns {object} LSP `Diagnostic`.
|
|
202
|
+
*/
|
|
203
|
+
export function incompleteIndexDiagnostic(gaps, lines) {
|
|
204
|
+
const named = gaps.slice(0, NAMED_GAP_LIMIT);
|
|
205
|
+
const remaining = gaps.length - named.length;
|
|
206
|
+
return {
|
|
207
|
+
range: rangeAt({ line: null, column: null }, lines),
|
|
208
|
+
severity: DIAGNOSTIC_SEVERITY.warning,
|
|
209
|
+
code: ANALYSIS_FAILURE_CODE,
|
|
210
|
+
source: DIAGNOSTIC_SOURCE,
|
|
211
|
+
message:
|
|
212
|
+
`Module boundaries were checked against an INCOMPLETE view of this workspace, so the ` +
|
|
213
|
+
`verdict for this file can be missing violations it had no way to see: ` +
|
|
214
|
+
`${named.join("; ")}${remaining > 0 ? `; and ${remaining} more` : ""}. ` +
|
|
215
|
+
`Fix what is named here and every open file is re-checked against the whole tree.`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A diagnostic for a document the server could not analyze AT ALL — the config
|
|
221
|
+
* would not load, the workspace index could not be built, an analyzer threw.
|
|
222
|
+
*
|
|
223
|
+
* This is the diagnostic that makes "no diagnostics" mean "no violations". Its
|
|
224
|
+
* severity is `error`: the file's boundary verdict is not merely incomplete, it
|
|
225
|
+
* is absent, and an editor showing nothing would be showing a clean file.
|
|
226
|
+
*
|
|
227
|
+
* @param {string} reason
|
|
228
|
+
* @param {string[]} lines
|
|
229
|
+
* @returns {object} LSP `Diagnostic`.
|
|
230
|
+
*/
|
|
231
|
+
export function analysisFailedDiagnostic(reason, lines) {
|
|
232
|
+
return {
|
|
233
|
+
range: rangeAt({ line: null, column: null }, lines),
|
|
234
|
+
severity: DIAGNOSTIC_SEVERITY.error,
|
|
235
|
+
code: ANALYSIS_FAILURE_CODE,
|
|
236
|
+
source: DIAGNOSTIC_SOURCE,
|
|
237
|
+
message:
|
|
238
|
+
`Module boundaries could not be checked for this file: ${reason}. ` +
|
|
239
|
+
`This is NOT a clean result — no rule ran.`,
|
|
240
|
+
};
|
|
241
|
+
}
|