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