@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,580 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tree a run judges: which projects exist, which files they own, and what
|
|
3
|
+
* analyzing all of them produces.
|
|
4
|
+
*
|
|
5
|
+
* This is the layer neither `analysis/` nor `rules/` is allowed to be. An
|
|
6
|
+
* analyzer is handed one file and never decides which files to visit
|
|
7
|
+
* (`analysis/contract.md`); a rule is handed records and never reads a file
|
|
8
|
+
* (`rules/README.md`). Somebody still has to answer both questions, and the
|
|
9
|
+
* answer touches the filesystem and spawns processes — so it lives here, once,
|
|
10
|
+
* behind injectable seams, rather than inside `../cli.mjs` where a spawned
|
|
11
|
+
* subprocess is the only way to test it.
|
|
12
|
+
*
|
|
13
|
+
* **Projects and tags come from a project-model provider, never from a walk of
|
|
14
|
+
* our own.** `createWorkspace` below takes a `graph` it does not build — the
|
|
15
|
+
* caller's answer to "what is a project and what is it tagged", derived from
|
|
16
|
+
* `project.json`, `nx.json` plugins and whatever inference Nx applies. A
|
|
17
|
+
* second reader of `project.json` files would be a second answer, and the two
|
|
18
|
+
* would disagree exactly where a plugin contributes an edge. For a workspace
|
|
19
|
+
* running under Nx, that graph comes from `./providers/nx.mjs`'s
|
|
20
|
+
* `readProjectGraph`, which spawns `nx graph --file=` rather than importing
|
|
21
|
+
* Nx: `nx` is not on this project's import list (project `AGENTS.md`), and
|
|
22
|
+
* `nx graph --file=` is a stable documented surface where Nx's internal module
|
|
23
|
+
* layout is not.
|
|
24
|
+
*
|
|
25
|
+
* **Files come from git.** `nx graph --file=` emits no file map, and the
|
|
26
|
+
* alternative — walking the tree — would need its own ignore rules that drift
|
|
27
|
+
* from `.gitignore` the first time a build directory is added. `git ls-files`
|
|
28
|
+
* is the same tracked-file set every resolver in this project already reasons
|
|
29
|
+
* about ("Resolvers read tracked files only", project `AGENTS.md`).
|
|
30
|
+
*/
|
|
31
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
32
|
+
import { dirname, isAbsolute, join, posix, relative, resolve } from "node:path";
|
|
33
|
+
|
|
34
|
+
import { containmentViolation } from "./containment.mjs";
|
|
35
|
+
import { analyzeFile, languageOf } from "./analysis/analyze.mjs";
|
|
36
|
+
import { fileFailure, projectOwning } from "./analysis/source-util.mjs";
|
|
37
|
+
import { UsageError } from "./errors.mjs";
|
|
38
|
+
import { parseNxJson } from "./nx-json.mjs";
|
|
39
|
+
import { NX_CONFIG_FILE } from "./options.mjs";
|
|
40
|
+
// `runProcess` and `environmentForTree` moved to `./process.mjs`, which
|
|
41
|
+
// imports nothing beyond `node:child_process` — see that file's header for
|
|
42
|
+
// why. Imported (not just re-exported) because `listTrackedFiles` below still
|
|
43
|
+
// spawns through `runProcess` itself; re-exported so every import site that
|
|
44
|
+
// predates the split keeps working unchanged.
|
|
45
|
+
import { environmentForTree, runProcess } from "./process.mjs";
|
|
46
|
+
|
|
47
|
+
export { environmentForTree, runProcess };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The workspace root at or above `from`, identified by a marker file.
|
|
51
|
+
*
|
|
52
|
+
* Nx's own root-finding rule, reproduced because the CLI has to agree with the
|
|
53
|
+
* `nx` it spawns about which tree is being judged. It is deliberately NOT
|
|
54
|
+
* derived from this file's own location: installed from a registry, this file
|
|
55
|
+
* sits inside the consumer's `node_modules` and the tree it judges is above
|
|
56
|
+
* that, so walking up from `import.meta.url` would find the wrong root — or,
|
|
57
|
+
* with a hoisted install, the right one by luck (same reason
|
|
58
|
+
* `loadBoundaryConfig` takes a root — see `config.mjs`).
|
|
59
|
+
*
|
|
60
|
+
* `markers` defaults to `nx.json` alone, so every existing caller keeps
|
|
61
|
+
* finding exactly the root it found before. A native-provider caller passes
|
|
62
|
+
* `[NX_CONFIG_FILE, ARCHKEEP_MODEL_FILE]` to recognise either root marker in
|
|
63
|
+
* one walk — see `../cli.mjs`, which is the only caller that needs to tell
|
|
64
|
+
* the two apart, and does so by checking which marker(s) the returned
|
|
65
|
+
* directory actually carries.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} from Absolute directory to start at.
|
|
68
|
+
* @param {string[]} [markers] Filenames or directory names whose presence
|
|
69
|
+
* marks a workspace root. `existsSync` works for both — a directory name
|
|
70
|
+
* like `.moon` is detected the same way a filename like `nx.json` is.
|
|
71
|
+
* @returns {string|null} Absolute path, or `null` when no ancestor has one.
|
|
72
|
+
*/
|
|
73
|
+
export function findWorkspaceRoot(from, markers = [NX_CONFIG_FILE]) {
|
|
74
|
+
let current = resolve(from);
|
|
75
|
+
for (;;) {
|
|
76
|
+
if (markers.some((marker) => existsSync(join(current, marker)))) return current;
|
|
77
|
+
const parent = dirname(current);
|
|
78
|
+
if (parent === current) return null;
|
|
79
|
+
current = parent;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every tracked file in the workspace, workspace-relative.
|
|
85
|
+
*
|
|
86
|
+
* `-z` because a path may contain a newline, and because git otherwise quotes
|
|
87
|
+
* and escapes any path outside plain ASCII — a quoted path would then be read
|
|
88
|
+
* as a filename that does not exist.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} workspaceRoot
|
|
91
|
+
* @param {{ run?: typeof runProcess }} [io]
|
|
92
|
+
* @returns {string[]}
|
|
93
|
+
*/
|
|
94
|
+
export function listTrackedFiles(workspaceRoot, { run = runProcess } = {}) {
|
|
95
|
+
const out = run("git", ["ls-files", "-z"], workspaceRoot);
|
|
96
|
+
return out.split("\0").filter((path) => path !== "");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The `Workspace` the analysis contract defines — `{ root, projects, filesOf,
|
|
101
|
+
* readFile, tsConfig }` — plus the per-project file index it is built from,
|
|
102
|
+
* which the caller needs to decide what to analyze.
|
|
103
|
+
*
|
|
104
|
+
* Files are attributed by longest-root-prefix match, which is `projectOwning`'s
|
|
105
|
+
* job and not a second copy of it: a project nested in another's directory owns
|
|
106
|
+
* its own files, and a first-match answer would hand them to the parent.
|
|
107
|
+
*
|
|
108
|
+
* `tsConfig` rides on the workspace rather than being passed alongside it for
|
|
109
|
+
* the reason `analysis/typescript.mjs` states: the parsed compiler options are
|
|
110
|
+
* cached in a `WeakMap` keyed on this object, so a filename travelling beside
|
|
111
|
+
* the key could disagree with the context stored under it. Omitted, the
|
|
112
|
+
* analyzer falls back to the Nx convention.
|
|
113
|
+
*
|
|
114
|
+
* @param {{ root: string, graph: object, files: string[], tsConfig?: string,
|
|
115
|
+
* read?: (path: string) => string|null }} input
|
|
116
|
+
* @returns {{ workspace: object, filesByProject: Map<string, string[]>, owned: {file: string, project: string}[] }}
|
|
117
|
+
*/
|
|
118
|
+
export function createWorkspace({ root, graph, files, tsConfig, read }) {
|
|
119
|
+
const projects = Object.values(graph.nodes).map((node) => ({
|
|
120
|
+
name: node.name,
|
|
121
|
+
// Nx spells a root-level project's root as the literal string ".",
|
|
122
|
+
// but `projectOwning` only treats "" as "matches every path" — a root
|
|
123
|
+
// of "." fails both `path !== root` and `path.startsWith("./")` for every
|
|
124
|
+
// real tracked path, so every root-level file would be silently unowned.
|
|
125
|
+
// Normalise here, at the data-ingestion boundary, so the predicate stays
|
|
126
|
+
// pure and both the Nx and native paths are fixed. cf. #32
|
|
127
|
+
root: node.data.root === "." ? "" : node.data.root,
|
|
128
|
+
}));
|
|
129
|
+
const filesByProject = new Map(projects.map((project) => [project.name, []]));
|
|
130
|
+
const owned = [];
|
|
131
|
+
for (const file of files) {
|
|
132
|
+
const project = projectOwning(projects, file);
|
|
133
|
+
// A file no project owns is outside the boundary system entirely — the rule
|
|
134
|
+
// engine returns nothing for it (`rules/index.mjs`), so it is dropped here
|
|
135
|
+
// rather than read and analyzed for a verdict that cannot exist.
|
|
136
|
+
if (!project) continue;
|
|
137
|
+
filesByProject.get(project.name).push(file);
|
|
138
|
+
owned.push({ file, project: project.name });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const readFile =
|
|
142
|
+
read ??
|
|
143
|
+
((path) => {
|
|
144
|
+
const abs = join(root, path);
|
|
145
|
+
// A tracked symlink whose realpath leaves the workspace is outside code
|
|
146
|
+
// reached as if it were the workspace's own source (`../AGENTS.md`'s
|
|
147
|
+
// empty-result invariant: bytes read into the verdict from outside the
|
|
148
|
+
// tree, reported clean). Returning null makes the file a whole-file
|
|
149
|
+
// failure — loud, never a silent read-and-judge (`containment.mjs`).
|
|
150
|
+
if (containmentViolation(root, abs) !== null) return null;
|
|
151
|
+
try {
|
|
152
|
+
return readFileSync(abs, "utf8");
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
workspace: {
|
|
160
|
+
root,
|
|
161
|
+
projects,
|
|
162
|
+
filesOf: (name) => filesByProject.get(name) ?? [],
|
|
163
|
+
readFile,
|
|
164
|
+
tsConfig,
|
|
165
|
+
},
|
|
166
|
+
filesByProject,
|
|
167
|
+
owned,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A file's workspace-relative path inside a project. `""` (the LSP index) and
|
|
173
|
+
* `"."` (Nx's graph output) both mean the workspace root itself, and neither
|
|
174
|
+
* may leak into the path: an in-memory reader keyed by exact path would answer
|
|
175
|
+
* `null` for `./package.json`.
|
|
176
|
+
*/
|
|
177
|
+
const fileUnder = (projectRoot, name) =>
|
|
178
|
+
projectRoot === "" || projectRoot === "." ? name : `${projectRoot}/${name}`;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Is the project at `projectRoot` a Module Federation remote? Upstream's own
|
|
182
|
+
* test, reproduced from `@nx/eslint-plugin`'s `appIsMFERemote` in its
|
|
183
|
+
* `runtime-lint-utils` (measured at 23.1.1): read
|
|
184
|
+
* `module-federation.config.js` beside the project root, fall back to the `.ts`
|
|
185
|
+
* spelling when the `.js` one yields nothing — upstream's `readFileIfExisting`
|
|
186
|
+
* answers `''` for a missing file and the `||` between the two reads treats an
|
|
187
|
+
* existing-but-empty `.js` exactly the same way, so this does too — then grep
|
|
188
|
+
* whichever text arrived for an `exposes:` key. A regex over the raw text,
|
|
189
|
+
* never a parse: upstream's pattern matches the key quoted or bare, anywhere in
|
|
190
|
+
* the file, and reproducing the pattern rather than improving on it is what
|
|
191
|
+
* keeps the two enforcers giving one answer.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} projectRoot Workspace-relative; `""` (the LSP index) and
|
|
194
|
+
* `"."` (Nx's graph output) both mean the workspace root itself.
|
|
195
|
+
* @param {(path: string) => string|null} readFile Workspace-relative reader.
|
|
196
|
+
* @returns {boolean}
|
|
197
|
+
*/
|
|
198
|
+
export function projectIsMFERemote(projectRoot, readFile) {
|
|
199
|
+
const config =
|
|
200
|
+
readFile(fileUnder(projectRoot, "module-federation.config.js")) ||
|
|
201
|
+
readFile(fileUnder(projectRoot, "module-federation.config.ts"));
|
|
202
|
+
if (!config) return false;
|
|
203
|
+
return /('|")?exposes('|")?:/.test(config);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Marks every app node with whether it is a Module Federation remote — the
|
|
208
|
+
* fact the `noImportsOfApps` exemption turns on (`rules/topology.mjs` →
|
|
209
|
+
* `appIsMFERemote` reads `data.mfeRemote`, and its absence fails closed, so an
|
|
210
|
+
* adapter that does not write the field reports every import of a real remote).
|
|
211
|
+
*
|
|
212
|
+
* The fact is NOT in `nx graph --file=` output — nothing in nx 23.1.1 emits an
|
|
213
|
+
* `mfeRemote` field anywhere (verified by searching its dist); upstream ESLint
|
|
214
|
+
* computes it per lint run off the filesystem — so both adapters compute it
|
|
215
|
+
* here, through the one predicate above, which is what keeps a CLI verdict and
|
|
216
|
+
* an LSP verdict on the same import from disagreeing.
|
|
217
|
+
*
|
|
218
|
+
* Only app nodes are marked: the one check that reads the field
|
|
219
|
+
* (`rules/index.mjs`) tests `type === "app"` first, exactly as upstream calls
|
|
220
|
+
* its helper only on that branch — and not probing two candidate paths per
|
|
221
|
+
* library keeps the read count from scaling with a tree's project count.
|
|
222
|
+
*
|
|
223
|
+
* @param {Record<string, object>} nodes Graph nodes, mutated in place.
|
|
224
|
+
* @param {(path: string) => string|null} readFile Workspace-relative reader.
|
|
225
|
+
*/
|
|
226
|
+
export function annotateMFERemotes(nodes, readFile) {
|
|
227
|
+
for (const node of Object.values(nodes)) {
|
|
228
|
+
if (node.type !== "app") continue;
|
|
229
|
+
node.data.mfeRemote = projectIsMFERemote(node.data.root, readFile);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The parsed `package.json` of the project at `projectRoot`, or `null` when
|
|
235
|
+
* there is nothing to measure.
|
|
236
|
+
*
|
|
237
|
+
* Upstream reads this file through `readFileIfExisting`, which answers `''`
|
|
238
|
+
* for a missing file, and then tests that answer for truthiness — so an
|
|
239
|
+
* existing-but-empty manifest and an absent one are the same non-answer there,
|
|
240
|
+
* and they are here. The parser is `parseNxJson`, because upstream hands the
|
|
241
|
+
* text to `@nx/devkit`'s `parseJson` and a manifest carrying a trailing comma
|
|
242
|
+
* or a comment is a manifest upstream reads.
|
|
243
|
+
*
|
|
244
|
+
* One deliberate divergence, in the loud direction: on a manifest neither
|
|
245
|
+
* parser can read, upstream's `parseJson` throws mid-lint. Here the answer is
|
|
246
|
+
* `null` — the fact was not measured, the field stays absent, and absence
|
|
247
|
+
* fails closed in the rule layer (`rules/topology.mjs`), so the broken
|
|
248
|
+
* manifest costs extra reports and never a waived violation. An editor's
|
|
249
|
+
* language server re-reads on every keystroke, and a manifest is malformed for
|
|
250
|
+
* exactly as long as someone is typing inside it.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} projectRoot Workspace-relative; `""` and `"."` both mean the
|
|
253
|
+
* workspace root itself.
|
|
254
|
+
* @param {(path: string) => string|null} readFile Workspace-relative reader.
|
|
255
|
+
* @returns {object|null}
|
|
256
|
+
*/
|
|
257
|
+
function readPackageManifest(projectRoot, readFile) {
|
|
258
|
+
const text = readFile(fileUnder(projectRoot, "package.json"));
|
|
259
|
+
if (!text) return null;
|
|
260
|
+
try {
|
|
261
|
+
return parseNxJson(text);
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The secondary entry points a project's `package.json` `exports` map
|
|
269
|
+
* declares, as the `{path, file}` pairs `rules/topology.mjs` walks — or `null`
|
|
270
|
+
* when the manifest itself could not be measured (`readPackageManifest`), so
|
|
271
|
+
* the caller keeps the graph field absent rather than claiming an empty list.
|
|
272
|
+
*
|
|
273
|
+
* Port of `@nx/eslint-plugin`'s `getPackageEntryPoints` + `parseExports` in
|
|
274
|
+
* its `runtime-lint-utils` (measured at 23.1.1), the only files/fields
|
|
275
|
+
* upstream consults for this fact: `<projectRoot>/package.json`, field
|
|
276
|
+
* `exports`, and nothing else — `project.json` is never read for it, and the
|
|
277
|
+
* `ng-package.json` fallback lives inside upstream's `getEntryPoint` walk, the
|
|
278
|
+
* part `rules/topology.mjs` declares it does not reproduce. A manifest with no
|
|
279
|
+
* `exports` answers `[]` exactly as upstream does.
|
|
280
|
+
*
|
|
281
|
+
* @param {string} projectRoot Workspace-relative; `""` and `"."` both mean the
|
|
282
|
+
* workspace root itself.
|
|
283
|
+
* @param {(path: string) => string|null} readFile Workspace-relative reader.
|
|
284
|
+
* @returns {{path: string, file: string}[]|null}
|
|
285
|
+
*/
|
|
286
|
+
export function packageEntryPoints(projectRoot, readFile) {
|
|
287
|
+
const manifest = readPackageManifest(projectRoot, readFile);
|
|
288
|
+
if (manifest === null) return null;
|
|
289
|
+
if (!manifest.exports) return [];
|
|
290
|
+
const entryPaths = [];
|
|
291
|
+
parseExportsInto(manifest.exports, projectRoot, entryPaths);
|
|
292
|
+
return entryPaths;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Upstream's `parseExports`, reproduced quirk for quirk rather than repaired,
|
|
297
|
+
* because the walk in `rules/topology.mjs` compares against exactly the pairs
|
|
298
|
+
* upstream builds (each measured against the installed 23.1.1 by calling it):
|
|
299
|
+
*
|
|
300
|
+
* - A string at the top level, or under the `"."` key, is the MAIN entry point
|
|
301
|
+
* and yields nothing — only secondary entry points are collected.
|
|
302
|
+
* - A conditional-exports object is detected by `import || require || default
|
|
303
|
+
* || node` but resolved as `default || import || require || node` — the two
|
|
304
|
+
* orders differ, so `{import, default}` follows `default`. The test is
|
|
305
|
+
* truthiness, so a key present with value `""` does not count.
|
|
306
|
+
* - Any other object recurses per key with the KEY as the new base path —
|
|
307
|
+
* which discards the parent key (`{"./sub": {"./deep": f}}` yields
|
|
308
|
+
* `<root>/deep`), and turns a lone non-conditional key like `types` into an
|
|
309
|
+
* entry at `<root>/types`. An array walks the same branch, one entry per
|
|
310
|
+
* index (`<root>/0`, `<root>/1`).
|
|
311
|
+
* - `path` and `file` are `joinPathFragments(projectRoot, …)`; `posix.join`
|
|
312
|
+
* computes the same answer on the workspace-relative `/`-separated paths
|
|
313
|
+
* both adapters feed it, including preserving a key's trailing slash — the
|
|
314
|
+
* one shape whose walk can match, see `./conformance/README.md`
|
|
315
|
+
* ("`getEntryPoint`'s directory branch is dead upstream" — corrected).
|
|
316
|
+
*
|
|
317
|
+
* @param {unknown} exports The `exports` value, any shape a manifest can hold.
|
|
318
|
+
* @param {string} projectRoot
|
|
319
|
+
* @param {{path: string, file: string}[]} entryPaths Mutated in place.
|
|
320
|
+
* @param {string} [basePath]
|
|
321
|
+
*/
|
|
322
|
+
function parseExportsInto(exports, projectRoot, entryPaths, basePath = ".") {
|
|
323
|
+
if (exports === null) return;
|
|
324
|
+
if (typeof exports === "string") {
|
|
325
|
+
if (basePath === ".") return;
|
|
326
|
+
entryPaths.push({
|
|
327
|
+
path: posix.join(projectRoot, basePath),
|
|
328
|
+
file: posix.join(projectRoot, exports),
|
|
329
|
+
});
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const table = /** @type {Record<string, unknown>} */ (exports);
|
|
333
|
+
if (table.import || table.require || table.default || table.node) {
|
|
334
|
+
parseExportsInto(
|
|
335
|
+
table.default || table.import || table.require || table.node,
|
|
336
|
+
projectRoot,
|
|
337
|
+
entryPaths,
|
|
338
|
+
basePath,
|
|
339
|
+
);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
for (const [key, value] of Object.entries(table)) {
|
|
343
|
+
parseExportsInto(value, projectRoot, entryPaths, key);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The external packages the project at `projectRoot` may treat as DIRECT
|
|
349
|
+
* dependencies — the fact `noTransitiveDependencies` turns on — or `null` when
|
|
350
|
+
* neither manifest could be measured, so the caller keeps the field absent.
|
|
351
|
+
*
|
|
352
|
+
* Port of `@nx/eslint-plugin`'s `isDirectDependency` in its
|
|
353
|
+
* `runtime-lint-utils` (measured at 23.1.1), which answers
|
|
354
|
+
* `packageExistsInPackageJson(name, '.') ||
|
|
355
|
+
* packageExistsInPackageJson(name, source.data.root)` — the workspace root's
|
|
356
|
+
* `package.json` and the source project's own, as one union, which is why one
|
|
357
|
+
* list per node is enough for `rules/topology.mjs` to reproduce the `||`.
|
|
358
|
+
* `packageExistsInPackageJson` itself is reproduced quirk for quirk:
|
|
359
|
+
*
|
|
360
|
+
* - Only `dependencies`, `peerDependencies` and `devDependencies` are read.
|
|
361
|
+
* `optionalDependencies` is NOT — upstream's own `getAllDependencies` in its
|
|
362
|
+
* `package-json-utils` includes it, but the boundary rule never calls that.
|
|
363
|
+
* - The test is `dependencies[packageName]` — truthiness of the VALUE — so a
|
|
364
|
+
* dependency declared as `"pkg": ""` is invisible to upstream, and so it is
|
|
365
|
+
* invisible here.
|
|
366
|
+
*
|
|
367
|
+
* @param {string} projectRoot Workspace-relative; `""` and `"."` both mean the
|
|
368
|
+
* workspace root itself, whose manifest is then read once, not unioned with
|
|
369
|
+
* itself.
|
|
370
|
+
* @param {(path: string) => string|null} readFile Workspace-relative reader.
|
|
371
|
+
* @returns {string[]|null}
|
|
372
|
+
*/
|
|
373
|
+
export function declaredPackages(projectRoot, readFile) {
|
|
374
|
+
const own = readPackageManifest(projectRoot, readFile);
|
|
375
|
+
const atWorkspaceRoot = projectRoot === "" || projectRoot === ".";
|
|
376
|
+
const workspace = atWorkspaceRoot ? own : readPackageManifest(".", readFile);
|
|
377
|
+
if (own === null && workspace === null) return null;
|
|
378
|
+
const names = new Set();
|
|
379
|
+
for (const manifest of workspace === own ? [own] : [workspace, own]) {
|
|
380
|
+
if (manifest === null) continue;
|
|
381
|
+
for (const section of ["dependencies", "peerDependencies", "devDependencies"]) {
|
|
382
|
+
const packages = manifest[section];
|
|
383
|
+
if (!packages) continue;
|
|
384
|
+
for (const [name, version] of Object.entries(packages)) {
|
|
385
|
+
if (version) names.add(name);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return [...names];
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Writes the two `package.json` facts the rule layer reads as optional graph
|
|
394
|
+
* fields — `data.entryPoints` (the secondary-entry-point exemptions) and
|
|
395
|
+
* `data.declaredPackages` (`noTransitiveDependencies`) — onto every project
|
|
396
|
+
* node, from the same manifests upstream reads per lint run.
|
|
397
|
+
*
|
|
398
|
+
* Neither fact is in `nx graph --file=` output — nothing in nx 23.1.1 writes
|
|
399
|
+
* an `entryPoints` or `declaredPackages` field onto a graph node (verified by
|
|
400
|
+
* searching its dist: `declaredPackages` appears nowhere, and every
|
|
401
|
+
* `entryPoints` hit is the internal `entryPointsToProjectMap` its import
|
|
402
|
+
* resolver builds and discards); upstream ESLint computes both per lint run
|
|
403
|
+
* off the filesystem — so both adapters compute them here, through the two
|
|
404
|
+
* functions above, which is what keeps a CLI verdict and an LSP verdict on the
|
|
405
|
+
* same import from disagreeing (the arrangement `annotateMFERemotes`
|
|
406
|
+
* established).
|
|
407
|
+
*
|
|
408
|
+
* Every project node is annotated, not one type: upstream reads the SOURCE
|
|
409
|
+
* project's manifest for `belongsToDifferentEntryPoint` and
|
|
410
|
+
* `isDirectDependency`, and the TARGET project's for its lazy-load check, so
|
|
411
|
+
* any node can be asked for either fact.
|
|
412
|
+
*
|
|
413
|
+
* On these two adapters' graphs the fields mean "measured from
|
|
414
|
+
* `package.json`", so a node whose manifest answers nothing has the field
|
|
415
|
+
* DELETED rather than left as whatever the node carried: `nx graph --file=`
|
|
416
|
+
* copies arbitrary `project.json` keys into `data` verbatim, and a stale
|
|
417
|
+
* `entryPoints` or `declaredPackages` riding in from config would WAIVE
|
|
418
|
+
* violations on a claim nobody measured — the silent direction. An absent
|
|
419
|
+
* manifest therefore keeps (or makes) the field absent, and absence keeps
|
|
420
|
+
* failing closed downstream.
|
|
421
|
+
*
|
|
422
|
+
* @param {Record<string, object>} nodes Graph nodes, mutated in place.
|
|
423
|
+
* @param {(path: string) => string|null} readFile Workspace-relative reader.
|
|
424
|
+
*/
|
|
425
|
+
export function annotatePackageFacts(nodes, readFile) {
|
|
426
|
+
// One read per distinct manifest per pass: every project's answer unions the
|
|
427
|
+
// workspace root's manifest, and n reads of one unchanged file are n − 1
|
|
428
|
+
// more than the fact needs.
|
|
429
|
+
const seen = new Map();
|
|
430
|
+
const readOnce = (path) => {
|
|
431
|
+
if (!seen.has(path)) seen.set(path, readFile(path));
|
|
432
|
+
return seen.get(path);
|
|
433
|
+
};
|
|
434
|
+
for (const node of Object.values(nodes)) {
|
|
435
|
+
const entryPoints = packageEntryPoints(node.data.root, readOnce);
|
|
436
|
+
if (entryPoints === null) delete node.data.entryPoints;
|
|
437
|
+
else node.data.entryPoints = entryPoints;
|
|
438
|
+
const declared = declaredPackages(node.data.root, readOnce);
|
|
439
|
+
if (declared === null) delete node.data.declaredPackages;
|
|
440
|
+
else node.data.declaredPackages = declared;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The tracked files a scoped run covers, given the paths a user named.
|
|
446
|
+
*
|
|
447
|
+
* A path may be absolute or relative to `cwd`, and may name a file or a
|
|
448
|
+
* directory; a directory selects everything under it. No paths means the whole
|
|
449
|
+
* workspace, which is the gate's mode — a scoped run is a local pre-check and
|
|
450
|
+
* cannot be more, because the cycle and lazy-load rules judge the file graph as
|
|
451
|
+
* a whole and the engine's index describes what was analyzed rather than what
|
|
452
|
+
* exists (`rules/index.mjs` → `createFileDependencyIndex`).
|
|
453
|
+
*
|
|
454
|
+
* @param {string[]} files Workspace-relative tracked files to select from —
|
|
455
|
+
* typically already narrowed to the files a project owns.
|
|
456
|
+
* @param {string[]} paths As typed on the command line.
|
|
457
|
+
* @param {{ root: string, cwd: string, tracked?: string[] }} location `tracked`
|
|
458
|
+
* is the wider, ownership-independent universe a named path is checked
|
|
459
|
+
* against before `files` is filtered — defaults to `files` itself. The two
|
|
460
|
+
* differ for a caller like `check`, whose `files` is already narrowed to
|
|
461
|
+
* project-owned files: a path into a real, tracked-but-unowned area (a
|
|
462
|
+
* `docs/` directory, a root `README.md`) would match zero of `files` even
|
|
463
|
+
* though it names a legitimate, merely-empty slice — not a usage mistake.
|
|
464
|
+
* Testing the raw path against the untrimmed tracked set is what tells that
|
|
465
|
+
* apart from a path git has never heard of at all: a typo, the wrong `cwd`,
|
|
466
|
+
* or a file that was created but never `git add`ed — the case that made
|
|
467
|
+
* `archkeep check` exit 0 clean on a file whose committed twin, byte for
|
|
468
|
+
* byte, reported a real violation.
|
|
469
|
+
* @returns {string[]} A subset of `files`.
|
|
470
|
+
* @throws {UsageError} when a path lies outside the workspace, or matches no
|
|
471
|
+
* tracked file at all — silently selecting nothing would report a clean tree
|
|
472
|
+
* for a run that inspected none of it.
|
|
473
|
+
*/
|
|
474
|
+
export function selectFiles(files, paths, { root, cwd, tracked = files }) {
|
|
475
|
+
if (paths.length === 0) return files;
|
|
476
|
+
const withinPrefix = (file, prefix) =>
|
|
477
|
+
prefix === "" || file === prefix || file.startsWith(`${prefix}/`);
|
|
478
|
+
const prefixes = paths.map((path) => {
|
|
479
|
+
const absolute = isAbsolute(path) ? path : resolve(cwd, path);
|
|
480
|
+
const rel = relative(root, absolute);
|
|
481
|
+
if (rel === "") return "";
|
|
482
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
483
|
+
throw new UsageError(
|
|
484
|
+
`archkeep: '${path}' is outside the workspace at ${root} — ` +
|
|
485
|
+
`there is nothing there this tool could check`,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
if (!tracked.some((file) => withinPrefix(file, rel))) {
|
|
489
|
+
throw new UsageError(
|
|
490
|
+
`archkeep: '${path}' matches no tracked file at ${root} — check the ` +
|
|
491
|
+
`path and the working directory, or 'git add' it first if it is new`,
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
return rel;
|
|
495
|
+
});
|
|
496
|
+
return files.filter((file) => prefixes.some((prefix) => withinPrefix(file, prefix)));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Analyzes every file that has an analyzer, in the order given.
|
|
501
|
+
*
|
|
502
|
+
* A file whose extension no analyzer claims is skipped before it is read —
|
|
503
|
+
* `analyzeFile` would return the empty envelope for it anyway, and most of a
|
|
504
|
+
* tracked tree is Markdown, JSON and images. A file that cannot be READ is a
|
|
505
|
+
* failure record, never a throw: one unreadable file must not blank a run, or a
|
|
506
|
+
* report empty because the tool tripped and a report empty because the tree is
|
|
507
|
+
* clean print the same thing (`analysis/contract.md`).
|
|
508
|
+
*
|
|
509
|
+
* `analyzedFiles` carries the same count as a list, additively: a caller that
|
|
510
|
+
* ran this over a wider set than it ultimately reports on (the native
|
|
511
|
+
* provider's whole-tree analysis, scoped down for a `check <path>` run —
|
|
512
|
+
* `cli.mjs`) needs to know WHICH files were analyzed, not just how many, to
|
|
513
|
+
* recompute a scope-correct count without analyzing the tree a second time.
|
|
514
|
+
*
|
|
515
|
+
* @param {object} workspace The `Workspace` from `createWorkspace`.
|
|
516
|
+
* @param {string[]} files Workspace-relative paths to consider.
|
|
517
|
+
* @param {{ analyze?: typeof analyzeFile }} [io] Injectable analyzer.
|
|
518
|
+
* @returns {{ imports: object[], failures: object[], analyzed: number, analyzedFiles: string[] }}
|
|
519
|
+
*/
|
|
520
|
+
export function analyzeWorkspace(workspace, files, { analyze = analyzeFile } = {}) {
|
|
521
|
+
const imports = [];
|
|
522
|
+
const failures = [];
|
|
523
|
+
const analyzedFiles = [];
|
|
524
|
+
for (const sourceFile of files) {
|
|
525
|
+
if (languageOf(sourceFile) === null) continue;
|
|
526
|
+
const text = workspace.readFile(sourceFile);
|
|
527
|
+
if (text === null) {
|
|
528
|
+
failures.push(fileFailure(sourceFile, "could not be read"));
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
analyzedFiles.push(sourceFile);
|
|
532
|
+
const result = analyze({ sourceFile, text, workspace });
|
|
533
|
+
imports.push(...result.imports);
|
|
534
|
+
failures.push(...result.failures);
|
|
535
|
+
}
|
|
536
|
+
return { imports, failures, analyzed: analyzedFiles.length, analyzedFiles };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** The three polyglot manifests `polyglotManifests` looks for. */
|
|
540
|
+
const POLYGLOT_MANIFEST_NAMES = ["go.mod", "Cargo.toml", "pyproject.toml"];
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Tracked Go, Rust and Python manifests that sit under some project's root —
|
|
544
|
+
* the fact the unregistered-Nx-plugin gap turns on
|
|
545
|
+
* (`./commands/context.mjs`'s `pluginGap.manifests` is where a caller reads
|
|
546
|
+
* it, and `./options.mjs`'s `pluginIsRegistered` is the other half of that
|
|
547
|
+
* gap). A workspace running under Nx draws no edge for any of these three
|
|
548
|
+
* languages unless this plugin is registered in `nx.json` — Nx parses only
|
|
549
|
+
* TypeScript and JavaScript imports natively (`../../../AGENTS.md`, "for the
|
|
550
|
+
* other three both go quiet") — so a tracked manifest with no registered
|
|
551
|
+
* plugin is exactly the silent hole that invariant refuses. This function
|
|
552
|
+
* only names the manifests; it does not decide whether the plugin is
|
|
553
|
+
* registered. The pair the gap turns on is wired in twice today:
|
|
554
|
+
* `resolveCommandContext` reads both into its `pluginGap`, which every
|
|
555
|
+
* descriptive command refuses on, while `check` renders the same fact as a
|
|
556
|
+
* `coverageGaps` degraded-coverage note rather than a refusal
|
|
557
|
+
* (`../cli.mjs`) — the checker's own analysis covers what the graph does
|
|
558
|
+
* not, so a note is the right level there.
|
|
559
|
+
*
|
|
560
|
+
* Root matching mirrors `projectOwning`'s longest-prefix attribution of a
|
|
561
|
+
* source file: a project rooted at the workspace root (`root: ""` or `"."`)
|
|
562
|
+
* matches every tracked file, and a project rooted elsewhere matches only its
|
|
563
|
+
* own directory or a path beneath it — never a sibling that merely shares a
|
|
564
|
+
* prefix (`apps/foo-bar/go.mod` does not match a project rooted at
|
|
565
|
+
* `apps/foo`).
|
|
566
|
+
*
|
|
567
|
+
* @param {string[]} tracked Every tracked file, workspace-relative.
|
|
568
|
+
* @param {{name: string, root: string}[]} projects
|
|
569
|
+
* @returns {string[]} The matching manifest paths, in `tracked`'s order.
|
|
570
|
+
*/
|
|
571
|
+
export function polyglotManifests(tracked, projects) {
|
|
572
|
+
const roots = projects.map((project) => project.root);
|
|
573
|
+
return tracked.filter((file) => {
|
|
574
|
+
const base = file.slice(file.lastIndexOf("/") + 1);
|
|
575
|
+
if (!POLYGLOT_MANIFEST_NAMES.includes(base)) return false;
|
|
576
|
+
return roots.some(
|
|
577
|
+
(root) => root === "" || root === "." || file === root || file.startsWith(`${root}/`),
|
|
578
|
+
);
|
|
579
|
+
});
|
|
580
|
+
}
|