@ecoma-io/archkeep 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,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
+ }