@ecoma-io/archkeep 0.13.0

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