@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
package/src/options.mjs
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin's options: what a workspace may tell this tool about itself.
|
|
3
|
+
*
|
|
4
|
+
* Two filenames, and one optional third, and nothing else. All three are Nx
|
|
5
|
+
* CONVENTIONS rather than fixed contracts — a workspace is free to name them
|
|
6
|
+
* otherwise, and when it does, every answer this tool gives about that workspace
|
|
7
|
+
* is silently wrong: a `tsconfig.base.json` under another name means no path
|
|
8
|
+
* alias resolves, a boundary config under another name means the enforcer
|
|
9
|
+
* cannot find the law and says so at best, reads a stale one at worst, and a
|
|
10
|
+
* profiles registry under another name means the `check` command cannot find
|
|
11
|
+
* the named laws it was asked to enforce. That is the difference between this
|
|
12
|
+
* triple and the manifests next door: `go.mod`, `Cargo.toml`,
|
|
13
|
+
* `pyproject.toml`, `nx.json` and `project.json` are named by their own
|
|
14
|
+
* toolchains and cannot be renamed by a workspace decision, so they stay
|
|
15
|
+
* hardcoded at their single use site: a literal belongs inline only where it is
|
|
16
|
+
* intrinsic to a fixed external contract and appears in exactly one place.
|
|
17
|
+
*
|
|
18
|
+
* ## Where the options come from, and why there is no config file for them
|
|
19
|
+
*
|
|
20
|
+
* `nx.json → plugins` already carries a per-plugin `options` object, and Nx
|
|
21
|
+
* threads it into every hook (`CreateDependencies<T> = (options, context) =>`).
|
|
22
|
+
* A workspace that registers this plugin has already written the entry; adding
|
|
23
|
+
* two keys to it costs nothing and adds no file. Inventing an
|
|
24
|
+
* `archkeep.config.mjs` instead would add a file when the workspace
|
|
25
|
+
* already carries the same options in `nx.json` — and it would need its own
|
|
26
|
+
* filename option to find itself, which is the joke that gives the game away.
|
|
27
|
+
*
|
|
28
|
+
* ```json
|
|
29
|
+
* "plugins": [
|
|
30
|
+
* {
|
|
31
|
+
* "plugin": "@ecoma-io/archkeep/nx",
|
|
32
|
+
* "options": { "boundaryConfig": "module-boundaries.config.mjs", "tsConfig": "tsconfig.base.json" }
|
|
33
|
+
* }
|
|
34
|
+
* ]
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* A Moon workspace can write neither key: Moon's own configuration carries no
|
|
38
|
+
* plugin-options table, and the `archkeep.json` that would state them is
|
|
39
|
+
* refused beside `.moon/`. What it gets instead is convention — and the one
|
|
40
|
+
* name whose convention is an ordered CHAIN rather than a single default is
|
|
41
|
+
* argued at `MOON_TSCONFIG_CHAIN` below, with `readMoonOptions` as the reader.
|
|
42
|
+
*
|
|
43
|
+
* ## What is deliberately NOT an option: the language list
|
|
44
|
+
*
|
|
45
|
+
* The obvious next key is `languages: ["go", "rust"]`, and it must not exist.
|
|
46
|
+
* Switching a language off there is indistinguishable, in every report this
|
|
47
|
+
* tool prints, from that language having no violations — which is the exact
|
|
48
|
+
* silence the whole project exists to end (`AGENTS.md`: an empty result is a
|
|
49
|
+
* claim, not a shrug). A workspace with no Go pays nothing for Go support
|
|
50
|
+
* already: each resolver keys off the manifests that exist, so it finds none
|
|
51
|
+
* and does nothing. There is no cost to buy off, and the option would only sell
|
|
52
|
+
* a way to turn enforcement off where nobody would see it happen.
|
|
53
|
+
*
|
|
54
|
+
* ## Why an unknown key throws
|
|
55
|
+
*
|
|
56
|
+
* `./config.mjs` takes the same posture with `depConstraints`, for the same
|
|
57
|
+
* reason. A `tsconfigBase` typed for `tsConfig` that quietly fell back to the
|
|
58
|
+
* default would produce a full green run against a rule nobody wrote. The
|
|
59
|
+
* failure has to arrive at the first `nx` invocation, naming the key.
|
|
60
|
+
*
|
|
61
|
+
* ## The one field here that no workspace writes: `boundaryConfigDeclared`
|
|
62
|
+
*
|
|
63
|
+
* Merging the declaration over the default is what makes every reader
|
|
64
|
+
* downstream simple — one string, always present, never a fallback to
|
|
65
|
+
* re-derive. It also destroys the only fact that separates two workspaces
|
|
66
|
+
* whose resolved options are byte-identical: one that never named a boundary
|
|
67
|
+
* law and takes `DEFAULT_OPTIONS.boundaryConfig` by Nx convention, and one
|
|
68
|
+
* that named `policy-we-declared.mjs` and then renamed or deleted it. A
|
|
69
|
+
* command that may tolerate the first must not tolerate the second, and with
|
|
70
|
+
* the provenance merged away it cannot tell them apart — measured on `graph`,
|
|
71
|
+
* which answered exit 0 with no `policy` field on a native tree declaring a
|
|
72
|
+
* `boundaryConfig` that was not there, byte-identical to a tree that never
|
|
73
|
+
* had a law.
|
|
74
|
+
*
|
|
75
|
+
* So the bit rides out alongside the two names: `true` when the caller's own
|
|
76
|
+
* object carried a `boundaryConfig` key, `false` when the value came from
|
|
77
|
+
* `DEFAULT_OPTIONS`. It is deliberately NOT a member of `DEFAULT_OPTIONS`,
|
|
78
|
+
* because that object is also the known-key roster the unknown-key check
|
|
79
|
+
* above reads: adding it there would make `boundaryConfigDeclared: true` a
|
|
80
|
+
* spelling a consumer could write into `nx.json`, which is a workspace
|
|
81
|
+
* asserting its own provenance — the one claim this field exists to compute
|
|
82
|
+
* rather than accept. Written into a `plugins[].options` table it throws like
|
|
83
|
+
* any other unknown key.
|
|
84
|
+
*
|
|
85
|
+
* `./providers/native/model.mjs` computes the same bit for `archkeep.json`'s
|
|
86
|
+
* own `boundaryConfig` field (including the inline-policy spelling, which
|
|
87
|
+
* never reaches `resolveOptions` at all) and `./commands/context.mjs` is what
|
|
88
|
+
* carries it onto `CommandContext.options` for all three providers — each
|
|
89
|
+
* argued where it is written, not restated here.
|
|
90
|
+
*/
|
|
91
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
92
|
+
import { join } from "node:path";
|
|
93
|
+
|
|
94
|
+
import { languageOf } from "./analysis/registry.mjs";
|
|
95
|
+
|
|
96
|
+
import { containmentViolation } from "./containment.mjs";
|
|
97
|
+
import { parseNxJson } from "./nx-json.mjs";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The Nx conventions, so a workspace that follows them writes no options at
|
|
101
|
+
* all. Frozen: a caller that mutated this would move the default for every
|
|
102
|
+
* later reader in the process.
|
|
103
|
+
*/
|
|
104
|
+
export const DEFAULT_OPTIONS = Object.freeze({
|
|
105
|
+
/** The workspace's module-boundary law — the table ESLint reads too. */
|
|
106
|
+
boundaryConfig: "module-boundaries.config.mjs",
|
|
107
|
+
/** Where the workspace's shared `compilerOptions` and `paths` live. */
|
|
108
|
+
tsConfig: "tsconfig.base.json",
|
|
109
|
+
/**
|
|
110
|
+
* The workspace's named-profile registry, when it uses one — see
|
|
111
|
+
* `./governance/profile-registry.mjs`. Absent (`undefined`) means the
|
|
112
|
+
* workspace enforces by file, exactly as before; present, the value of
|
|
113
|
+
* `boundaryConfig` becomes a profile NAME selected from this registry (the
|
|
114
|
+
* check command's "select by name instead of by file" — documented in
|
|
115
|
+
* `../../../docs/concepts/profiles.md`).
|
|
116
|
+
*/
|
|
117
|
+
profiles: undefined,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/** The file Nx reads to learn a workspace exists — and where the options live. */
|
|
121
|
+
export const NX_CONFIG_FILE = "nx.json";
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Merges a raw options object over the defaults, refusing anything it does not
|
|
125
|
+
* recognise.
|
|
126
|
+
*
|
|
127
|
+
* Pure, and takes the object rather than a path, for the reason every gate in
|
|
128
|
+
* this repository takes its facts as arguments: a function that reads a file
|
|
129
|
+
* AND decides something has to be split before it can be tested, and the split
|
|
130
|
+
* is the improvement. `readPluginOptions` below is the only half that touches
|
|
131
|
+
* a filesystem.
|
|
132
|
+
*
|
|
133
|
+
* @param {object|undefined|null} rawOptions Whatever `nx.json` carried, or
|
|
134
|
+
* whatever Nx handed the hook. Absent is legal and means "all defaults".
|
|
135
|
+
* @returns {{boundaryConfig: string, tsConfig: string, profiles?: string,
|
|
136
|
+
* boundaryConfigDeclared: boolean}} `boundaryConfigDeclared` is the
|
|
137
|
+
* provenance of `boundaryConfig` and nothing else — `true` only when
|
|
138
|
+
* `rawOptions` carried the key itself. See the module header, "The one
|
|
139
|
+
* field here that no workspace writes".
|
|
140
|
+
* @throws {Error} on an unknown key, a non-object, or a value that is not a
|
|
141
|
+
* non-empty string. An empty string would build `<root>/` and read a
|
|
142
|
+
* directory as a config, which fails somewhere far from the typo.
|
|
143
|
+
*/
|
|
144
|
+
export function resolveOptions(rawOptions) {
|
|
145
|
+
if (rawOptions === undefined || rawOptions === null) {
|
|
146
|
+
return { ...DEFAULT_OPTIONS, boundaryConfigDeclared: false };
|
|
147
|
+
}
|
|
148
|
+
if (typeof rawOptions !== "object" || Array.isArray(rawOptions)) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`archkeep: plugin options must be an object, got ${Array.isArray(rawOptions) ? "an array" : typeof rawOptions}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// `known` stays exactly `DEFAULT_OPTIONS`' keys, which is what keeps
|
|
155
|
+
// `boundaryConfigDeclared` an OUTPUT: it is not in that object, so a
|
|
156
|
+
// `plugins[].options` table naming it hits the unknown-key throw below.
|
|
157
|
+
const known = Object.keys(DEFAULT_OPTIONS);
|
|
158
|
+
const resolved = {
|
|
159
|
+
...DEFAULT_OPTIONS,
|
|
160
|
+
boundaryConfigDeclared: Object.hasOwn(rawOptions, "boundaryConfig"),
|
|
161
|
+
};
|
|
162
|
+
for (const [key, value] of Object.entries(rawOptions)) {
|
|
163
|
+
if (!known.includes(key)) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`archkeep: unknown plugin option '${key}' — expected one of ${known.join(", ")}. ` +
|
|
166
|
+
`Refused rather than ignored: an option that falls back to its default in silence is a ` +
|
|
167
|
+
`green run against a rule nobody wrote.`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (typeof value !== "string" || value === "") {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`archkeep: plugin option '${key}' must be a non-empty string, got ` +
|
|
173
|
+
`${value === "" ? "an empty string" : typeof value}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
resolved[key] = value;
|
|
177
|
+
}
|
|
178
|
+
return resolved;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* This plugin's own entry in an `nx.json` `plugins` array, in either form Nx
|
|
183
|
+
* accepts: a bare string, or `{plugin, options}`.
|
|
184
|
+
*
|
|
185
|
+
* Matched on the specifier's TAIL rather than by equality, because the same
|
|
186
|
+
* plugin is named three legitimate ways — `@ecoma-io/archkeep/nx` once it
|
|
187
|
+
* resolves from the registry, `./packages/archkeep/nx.mjs` inside this
|
|
188
|
+
* repository, and a path with or without the `.mjs` in a workspace that
|
|
189
|
+
* vendored it. Requiring one spelling would make the options invisible in the
|
|
190
|
+
* other two, and invisible options mean the defaults — silently.
|
|
191
|
+
*
|
|
192
|
+
* The bare package specifier (`@ecoma-io/archkeep`, with no `/nx`) does NOT
|
|
193
|
+
* match: that resolves to the engine entry, which exports neither `name` nor
|
|
194
|
+
* `createDependencies`, so Nx would not have loaded a plugin at all — an entry
|
|
195
|
+
* this function accepted there would claim options for a plugin Nx never ran.
|
|
196
|
+
*
|
|
197
|
+
* @param {unknown} entry One element of `nx.json`'s `plugins`.
|
|
198
|
+
* @returns {boolean}
|
|
199
|
+
*/
|
|
200
|
+
function namesThisPlugin(entry) {
|
|
201
|
+
const specifier =
|
|
202
|
+
typeof entry === "string" ? entry : /** @type {{ plugin?: unknown }} */ (entry)?.plugin;
|
|
203
|
+
if (typeof specifier !== "string") return false;
|
|
204
|
+
const withoutExt = specifier.replace(/\.mjs$/u, "");
|
|
205
|
+
return withoutExt === "archkeep/nx" || withoutExt.endsWith("/archkeep/nx");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* `nx.json`'s parsed contents, or `null` when the workspace has none.
|
|
210
|
+
*
|
|
211
|
+
* The one place this file spawns a read and a parse, so `readPluginOptions`
|
|
212
|
+
* and `readWorkspaceLayout` below — two independent readers of the same file
|
|
213
|
+
* — cannot disagree about whether it is readable: both go through here, and
|
|
214
|
+
* both therefore throw the identical `cannot read <path>` message on the same
|
|
215
|
+
* malformed input rather than each carrying its own copy that could drift.
|
|
216
|
+
*
|
|
217
|
+
* @param {string} workspaceRoot
|
|
218
|
+
* @param {(path: string) => string|null} readFile
|
|
219
|
+
* @returns {object|null}
|
|
220
|
+
* @throws {Error} when the file exists but `parseNxJson` cannot read it.
|
|
221
|
+
*/
|
|
222
|
+
function readNxJsonOrNull(workspaceRoot, readFile) {
|
|
223
|
+
const path = `${String(workspaceRoot).replace(/\/$/u, "")}/${NX_CONFIG_FILE}`;
|
|
224
|
+
// A tracked symlink at `nx.json` whose realpath leaves the workspace would
|
|
225
|
+
// hand outside bytes in as the workspace's own registration — a whole
|
|
226
|
+
// options read, and every verdict downstream of it, built on
|
|
227
|
+
// attacker-controlled input and reported clean. Refusing turns that silent
|
|
228
|
+
// read into a loud "cannot read" throw (`../containment.mjs`'s
|
|
229
|
+
// `containmentViolation`, the read-side G-10 closure). The check applies to
|
|
230
|
+
// every read of a real root: an injected in-memory reader a test drives is
|
|
231
|
+
// keyed by a fixture path (`/w`, `/fixture`) that does not exist on disk, so
|
|
232
|
+
// `existsSync(workspaceRoot)` is what keeps that seam untrodden, not a sentinel
|
|
233
|
+
// on the reader — `pluginIsRegistered`'s real-fs `readFileAbsolute` is a
|
|
234
|
+
// different function from `readFileOrNull` and must be contained too.
|
|
235
|
+
const violation = existsSync(workspaceRoot) ? containmentViolation(workspaceRoot, path) : null;
|
|
236
|
+
if (violation !== null) throw new Error(`archkeep: cannot read ${path}: ${violation}`);
|
|
237
|
+
const text = readFile(path);
|
|
238
|
+
if (text === null) return null;
|
|
239
|
+
try {
|
|
240
|
+
return parseNxJson(text);
|
|
241
|
+
} catch (cause) {
|
|
242
|
+
throw new Error(`archkeep: cannot read ${path}: ${cause?.message ?? cause}`, {
|
|
243
|
+
cause,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The resolved options a workspace root declares.
|
|
250
|
+
*
|
|
251
|
+
* The read is injectable, which is what keeps the one filesystem-touching
|
|
252
|
+
* function here testable without a tree on disk: `readFile` takes an absolute
|
|
253
|
+
* path and answers `null` for a file that is not there, so a test states an
|
|
254
|
+
* `nx.json` as a string and every branch below is exercised over it. The
|
|
255
|
+
* default reader is the only code in this module that reaches outside the
|
|
256
|
+
* process.
|
|
257
|
+
*
|
|
258
|
+
* A workspace with no `nx.json`, no `plugins` array, or no entry for this
|
|
259
|
+
* plugin gets the defaults. That is not the silent fallback the unknown-key
|
|
260
|
+
* check refuses: the CLI and the language server are both usable in a tree that
|
|
261
|
+
* never registered the plugin at all, and defaulting there is the Nx convention
|
|
262
|
+
* being what it is. A `plugins` entry that IS present and carries a bad option
|
|
263
|
+
* still throws.
|
|
264
|
+
*
|
|
265
|
+
* @param {string} workspaceRoot Absolute path of the tree being judged — never
|
|
266
|
+
* derived from this file's own location, for the reason `./config.mjs` gives.
|
|
267
|
+
* @param {{readFile?: (path: string) => string|null}} [io] Injectable read, so
|
|
268
|
+
* `resolveOptions`'s callers can be driven over a tree that is not on disk.
|
|
269
|
+
* @returns {{boundaryConfig: string, tsConfig: string, profiles?: string,
|
|
270
|
+
* boundaryConfigDeclared: boolean}} The three no-registration states below
|
|
271
|
+
* all answer `boundaryConfigDeclared: false`, because none of them is a
|
|
272
|
+
* workspace naming a law — see the module header.
|
|
273
|
+
* @throws {Error} when `nx.json` is unparseable, or its options are malformed.
|
|
274
|
+
* Loud on purpose, and the same posture as everywhere else here: a tool that
|
|
275
|
+
* could not read its own configuration must not answer as though it had.
|
|
276
|
+
*/
|
|
277
|
+
export function readPluginOptions(workspaceRoot, { readFile = readFileOrNull } = {}) {
|
|
278
|
+
const nxJson = readNxJsonOrNull(workspaceRoot, readFile);
|
|
279
|
+
// The three "nothing registered here" exits go through `resolveOptions`
|
|
280
|
+
// rather than spreading `DEFAULT_OPTIONS` directly, so the shape this
|
|
281
|
+
// function returns is produced in exactly one place. A spread would have to
|
|
282
|
+
// remember `boundaryConfigDeclared: false` three separate times, and the
|
|
283
|
+
// one that forgot it would answer `undefined` — a provenance nobody wrote,
|
|
284
|
+
// read downstream as whichever direction that caller's `??`/`!==` happened
|
|
285
|
+
// to fall.
|
|
286
|
+
if (nxJson === null) return resolveOptions(undefined);
|
|
287
|
+
|
|
288
|
+
const plugins = Array.isArray(nxJson?.plugins) ? nxJson.plugins : [];
|
|
289
|
+
const entry = plugins.find(namesThisPlugin);
|
|
290
|
+
if (entry === undefined) return resolveOptions(undefined);
|
|
291
|
+
return resolveOptions(typeof entry === "string" ? undefined : entry.options);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The tsconfig filenames a Moon workspace is read against, in the order they
|
|
296
|
+
* are tried — first one that exists wins.
|
|
297
|
+
*
|
|
298
|
+
* Moon is the one provider with nowhere to state the name. Nx states it in
|
|
299
|
+
* `nx.json` → `plugins[].options.tsConfig` and a native root states it on
|
|
300
|
+
* `archkeep.json`'s own `tsConfig` field; Moon's own configuration carries no
|
|
301
|
+
* plugin-options table, and a `archkeep.json` beside `.moon/` is refused
|
|
302
|
+
* outright (`./commands/context.mjs`'s `requireSingleProjectModel`), so every
|
|
303
|
+
* door to a stated name is shut. What is left is convention, and one name is
|
|
304
|
+
* not enough of it: measured on a 94-project Vue Moon workspace whose `paths`
|
|
305
|
+
* table lives in `tsconfig.json` with no `tsconfig.base.json` beside it, a
|
|
306
|
+
* provider fixed at the first name alone read the absent file, fell back to
|
|
307
|
+
* `ts.resolveModuleName`'s compiler defaults, resolved every internal
|
|
308
|
+
* specifier to nothing, and reported several hundred findings on a tree with
|
|
309
|
+
* no architecture violation in it.
|
|
310
|
+
*
|
|
311
|
+
* The order is the point, not the membership: `tsconfig.base.json` is the Nx
|
|
312
|
+
* convention this tool's default already names, and a workspace carrying both
|
|
313
|
+
* files means the base one — `tsconfig.json` there is the editor's own
|
|
314
|
+
* per-root config, which typically `extends` it. "Whichever we find" would
|
|
315
|
+
* make the answer depend on the order two names happen to be written in.
|
|
316
|
+
*
|
|
317
|
+
* Extending the chain is a compatibility decision, not a lookup detail: a
|
|
318
|
+
* third name added here changes the verdict of an unchanged workspace that
|
|
319
|
+
* carries it. `../../../docs/integrations/moon.md`'s Configuration section
|
|
320
|
+
* owns the consumer-facing statement of both the chain and what it does not
|
|
321
|
+
* solve.
|
|
322
|
+
*
|
|
323
|
+
* The first entry is `DEFAULT_OPTIONS.tsConfig` itself rather than a second
|
|
324
|
+
* spelling of the same string: the two must never disagree about which name
|
|
325
|
+
* the convention starts at.
|
|
326
|
+
*/
|
|
327
|
+
export const MOON_TSCONFIG_CHAIN = Object.freeze([DEFAULT_OPTIONS.tsConfig, "tsconfig.json"]);
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The `tsConfigSource` a Moon workspace's options carry — the provenance
|
|
331
|
+
* field beside `tsConfig`, the same shape `./commands/graph.mjs`'s
|
|
332
|
+
* `workspaceLayoutSource` is to `workspaceLayout`.
|
|
333
|
+
*
|
|
334
|
+
* It exists because the name alone is not the whole fact. Two machines
|
|
335
|
+
* checking out the same Moon workspace, one of them with an untracked
|
|
336
|
+
* `tsconfig.json` in it, resolve different `paths` tables and report
|
|
337
|
+
* different verdicts; with only the resolved name carried, a reader cannot
|
|
338
|
+
* tell a name the workspace stated from a name this tool picked off a chain.
|
|
339
|
+
* Nx and native options carry no such field — there the name IS stated — so
|
|
340
|
+
* its presence is itself the "this was convention, not a declaration" fact.
|
|
341
|
+
*
|
|
342
|
+
* `./lsp/server.mjs`'s `watchedFilesFor` reads it for a second reason argued
|
|
343
|
+
* there: the chain is ordered, so the file that would WIN can change without
|
|
344
|
+
* the file that is currently chosen ever being touched.
|
|
345
|
+
*/
|
|
346
|
+
export const MOON_TSCONFIG_SOURCE = "moon-convention";
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The extensions whose absence of a tsconfig is genuinely ambiguous — the
|
|
350
|
+
* only files that make `readMoonOptions` refuse.
|
|
351
|
+
*
|
|
352
|
+
* Two exclusions, both deliberate, and the second is the reason this is a
|
|
353
|
+
* list of EXTENSIONS rather than the obvious list of languages:
|
|
354
|
+
*
|
|
355
|
+
* - Go, Rust and Python resolve through their own manifests and never read a
|
|
356
|
+
* tsconfig at all, so a Moon workspace of those three alone must not be
|
|
357
|
+
* refused for lacking a file nothing in it would have read.
|
|
358
|
+
* - **`.js`, `.jsx`, `.mjs` and `.cjs` are absent even though
|
|
359
|
+
* `LANGUAGE_BY_EXTENSION` calls all four `typescript`**, because that table
|
|
360
|
+
* answers "which analyzer reads this" and the question here is a different
|
|
361
|
+
* one: "does a missing tsconfig mean we failed to find the paths table, or
|
|
362
|
+
* that there is no paths table?" For a `.ts` file the answer is ambiguous —
|
|
363
|
+
* `tsc` cannot run without a config, so one almost certainly exists
|
|
364
|
+
* somewhere and not finding it is evidence of a miss. For a plain-JS
|
|
365
|
+
* workspace it is not ambiguous at all: JavaScript needs no tsconfig, most
|
|
366
|
+
* such trees have never had one, and every relative and package specifier
|
|
367
|
+
* in them resolves correctly against the compiler defaults today. Refusing
|
|
368
|
+
* those would turn a run that is currently CORRECT into exit 3 — a
|
|
369
|
+
* regression wearing a hardening's clothes, which is the one thing this
|
|
370
|
+
* guard must not be.
|
|
371
|
+
*
|
|
372
|
+
* `.vue` is included because the Vue analyzer hands its `<script>` block to
|
|
373
|
+
* the TypeScript one (`./analysis/vue.mjs`), which resolves it against the
|
|
374
|
+
* same table.
|
|
375
|
+
*
|
|
376
|
+
* This is a second copy of extension knowledge that `./analysis/registry.mjs`
|
|
377
|
+
* otherwise owns, so the filter below runs `languageOf` FIRST and this list
|
|
378
|
+
* only ever narrows what the registry already claimed. An entry naming an
|
|
379
|
+
* extension the registry does not claim therefore cannot widen the refusal —
|
|
380
|
+
* it simply never matches — and `./options.test.mjs` pins that, probing
|
|
381
|
+
* extensions from outside the registry as well as inside it, because a probe
|
|
382
|
+
* set drawn only from the registry's own keys could not tell the two cases
|
|
383
|
+
* apart.
|
|
384
|
+
*/
|
|
385
|
+
const TSCONFIG_RESOLVED_EXTENSIONS = Object.freeze([".ts", ".tsx", ".mts", ".cts", ".vue"]);
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The resolved options for a Moon workspace root.
|
|
389
|
+
*
|
|
390
|
+
* Every field is convention: Moon has no place to state either name, so this
|
|
391
|
+
* function is where both are decided rather than read. `boundaryConfig` is
|
|
392
|
+
* the default outright and `boundaryConfigDeclared` is therefore `false` — a
|
|
393
|
+
* fact about Moon, not a fallback — while `tsConfig` walks
|
|
394
|
+
* `MOON_TSCONFIG_CHAIN` and reports which entry answered through
|
|
395
|
+
* `tsConfigSource`.
|
|
396
|
+
*
|
|
397
|
+
* **Neither candidate present, in a workspace with files that resolve
|
|
398
|
+
* through a `paths` table, THROWS.** That is a change of behaviour and the
|
|
399
|
+
* point of this function: before it, such a tree was judged against
|
|
400
|
+
* TypeScript's compiler defaults, where an aliased specifier resolves to
|
|
401
|
+
* nothing, every internal import reads as a boundary crossing, and the report
|
|
402
|
+
* is a wall of findings with no line anywhere saying the paths table was
|
|
403
|
+
* never found. A tool that could not resolve the workspace's own imports must
|
|
404
|
+
* not answer as though it had (`../../../AGENTS.md`, "The invariant everything
|
|
405
|
+
* is judged against") — so the run stops, naming both candidate names and the
|
|
406
|
+
* files that needed one.
|
|
407
|
+
*
|
|
408
|
+
* A Moon workspace with no such file is NOT refused: it would be refused for
|
|
409
|
+
* lacking a config nothing in it reads. That covers Go, Rust and Python — and
|
|
410
|
+
* also a plain-JavaScript tree, because `TSCONFIG_RESOLVED_EXTENSIONS` is
|
|
411
|
+
* narrower than the language table on purpose; its own comment argues why.
|
|
412
|
+
* There the chain's first entry is carried as the name, so the watcher list
|
|
413
|
+
* `./lsp/server.mjs` derives still covers the file that would change the
|
|
414
|
+
* answer if it arrived.
|
|
415
|
+
*
|
|
416
|
+
* @param {string} workspaceRoot Absolute path of the tree being judged.
|
|
417
|
+
* @param {{exists?: (path: string) => boolean, listFiles: () => string[]}} io
|
|
418
|
+
* `exists` is plain filesystem existence, the same test every other marker
|
|
419
|
+
* is read by, injectable so a test drives the chain with no tree on disk.
|
|
420
|
+
* `listFiles` is REQUIRED and a thunk rather than an array: it is called
|
|
421
|
+
* only on the one branch that needs it — neither candidate present — so a
|
|
422
|
+
* workspace that carries one pays nothing for the question, and this module
|
|
423
|
+
* never grows an import of `./workspace.mjs`, which imports it back.
|
|
424
|
+
* @returns {{boundaryConfig: string, tsConfig: string, tsConfigSource: string,
|
|
425
|
+
* boundaryConfigDeclared: boolean}}
|
|
426
|
+
* @throws {Error} when no chain entry exists and the tracked files include a
|
|
427
|
+
* language that resolves through the `paths` table.
|
|
428
|
+
*/
|
|
429
|
+
export function readMoonOptions(workspaceRoot, { exists = existsSync, listFiles }) {
|
|
430
|
+
const found = MOON_TSCONFIG_CHAIN.find((name) => exists(join(workspaceRoot, name)));
|
|
431
|
+
if (found === undefined) {
|
|
432
|
+
// Two conditions, and the first is what keeps this list strictly NARROWER
|
|
433
|
+
// than the analyzer registry rather than merely different from it.
|
|
434
|
+
// `languageOf` owns the matching rule — last dot of the basename — so a
|
|
435
|
+
// path whose whole basename is `.ts` resolves to `null` there and is
|
|
436
|
+
// skipped here too, instead of triggering a refusal for a file no
|
|
437
|
+
// analyzer would ever read.
|
|
438
|
+
const needing = listFiles().filter(
|
|
439
|
+
(file) =>
|
|
440
|
+
languageOf(file) !== null &&
|
|
441
|
+
TSCONFIG_RESOLVED_EXTENSIONS.some((extension) => file.endsWith(extension)),
|
|
442
|
+
);
|
|
443
|
+
if (needing.length > 0) {
|
|
444
|
+
throw new Error(
|
|
445
|
+
`archkeep: ${workspaceRoot} is a Moon workspace carrying ${needing.length} file` +
|
|
446
|
+
`${needing.length === 1 ? "" : "s"} that resolve` +
|
|
447
|
+
`${needing.length === 1 ? "s" : ""} through a TypeScript paths table ` +
|
|
448
|
+
`(${needing.slice(0, 3).join(", ")}${needing.length > 3 ? ", …" : ""}), and none of ` +
|
|
449
|
+
`${MOON_TSCONFIG_CHAIN.join(" or ")} is there to read it from. Moon carries no ` +
|
|
450
|
+
`plugin-options table to name one under and a archkeep.json beside .moon is refused, ` +
|
|
451
|
+
`so those names are the whole convention. Refused rather than judged: read against ` +
|
|
452
|
+
`the compiler defaults instead, every aliased import resolves to nothing and the run ` +
|
|
453
|
+
`reports a boundary crossing for each one — a wall of findings on a workspace that ` +
|
|
454
|
+
`may have no violation in it at all. Add ${MOON_TSCONFIG_CHAIN[0]} at the workspace ` +
|
|
455
|
+
`root, or rename the config that already holds the paths table to one of those two ` +
|
|
456
|
+
`names. The Moon integration guide in this tool's documentation ` +
|
|
457
|
+
`covers both routes.`,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
boundaryConfig: DEFAULT_OPTIONS.boundaryConfig,
|
|
463
|
+
// No candidate and nothing that would have read one: the chain's first
|
|
464
|
+
// entry is the name carried, so the file whose ARRIVAL would change the
|
|
465
|
+
// answer is the file the watcher list already covers.
|
|
466
|
+
tsConfig: found ?? MOON_TSCONFIG_CHAIN[0],
|
|
467
|
+
tsConfigSource: MOON_TSCONFIG_SOURCE,
|
|
468
|
+
boundaryConfigDeclared: false,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The two keys `nx.json`'s `workspaceLayout` may carry — the same pair
|
|
474
|
+
* `./providers/native/model.mjs`'s `WORKSPACE_LAYOUT_KEYS` validates for
|
|
475
|
+
* `archkeep.json`'s identically-named field, kept as two separate constants
|
|
476
|
+
* because the two files sit on opposite sides of the layer boundary
|
|
477
|
+
* `packages/archkeep/AGENTS.md` draws (`src/options.mjs` owns what a workspace
|
|
478
|
+
* may tell this tool about ITSELF via Nx's own config; `src/providers/native/`
|
|
479
|
+
* owns the `archkeep.json` dialect) — a shared constant would import one layer
|
|
480
|
+
* into the other for two frozen strings.
|
|
481
|
+
*/
|
|
482
|
+
const WORKSPACE_LAYOUT_KEYS = Object.freeze(["appsDir", "libsDir"]);
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* `nx.json`'s `workspaceLayout`, exactly as declared — never merged with a
|
|
486
|
+
* default and never required to be complete. A caller downstream has to be
|
|
487
|
+
* able to tell "declared nothing" from "declared the default", which is why
|
|
488
|
+
* this returns `null` for the former and the declared object verbatim
|
|
489
|
+
* (partial or not) for the latter, rather than folding either case into
|
|
490
|
+
* `./rules/specifiers.mjs`'s `DEFAULT_WORKSPACE_LAYOUT`. Whether a PARTIAL
|
|
491
|
+
* declaration is usable is a different, narrower question — this reader only
|
|
492
|
+
* answers "is what's here well-formed" — and `requireCompleteWorkspaceLayout`
|
|
493
|
+
* below answers the narrower one for the two callers that need it.
|
|
494
|
+
*
|
|
495
|
+
* @param {string} workspaceRoot
|
|
496
|
+
* @param {{readFile?: (path: string) => string|null}} [io]
|
|
497
|
+
* @returns {{appsDir?: string, libsDir?: string}|null} `null` when the
|
|
498
|
+
* workspace has no `nx.json`, or its `nx.json` declares no `workspaceLayout`
|
|
499
|
+
* key.
|
|
500
|
+
* @throws {Error} when `workspaceLayout` is present but is not a plain
|
|
501
|
+
* object, names a key other than `appsDir`/`libsDir`, or gives either key a
|
|
502
|
+
* non-string or empty-string value. Reading any of those as "no layout
|
|
503
|
+
* declared" would silence `noRelativeOrAbsoluteImportsAcrossLibraries` on
|
|
504
|
+
* exactly the workspace whose configuration is broken — the same reasoning
|
|
505
|
+
* `./providers/native/model.mjs`'s `workspaceLayoutViolations` already
|
|
506
|
+
* applies to `archkeep.json`'s identically-shaped field.
|
|
507
|
+
*/
|
|
508
|
+
export function readWorkspaceLayout(workspaceRoot, { readFile = readFileOrNull } = {}) {
|
|
509
|
+
const nxJson = readNxJsonOrNull(workspaceRoot, readFile);
|
|
510
|
+
if (nxJson === null) return null;
|
|
511
|
+
const declared = /** @type {{workspaceLayout?: unknown}} */ (nxJson)?.workspaceLayout;
|
|
512
|
+
if (declared === undefined) return null;
|
|
513
|
+
if (typeof declared !== "object" || declared === null || Array.isArray(declared)) {
|
|
514
|
+
throw new Error(
|
|
515
|
+
`archkeep: nx.json's workspaceLayout must be an object, got ` +
|
|
516
|
+
`${Array.isArray(declared) ? "an array" : typeof declared}`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
for (const key of Object.keys(declared)) {
|
|
520
|
+
if (!WORKSPACE_LAYOUT_KEYS.includes(key)) {
|
|
521
|
+
throw new Error(
|
|
522
|
+
`archkeep: nx.json's workspaceLayout.${key} is not a workspaceLayout field — expected ` +
|
|
523
|
+
`one of ${WORKSPACE_LAYOUT_KEYS.join(", ")}`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
const value = /** @type {Record<string, unknown>} */ (declared)[key];
|
|
527
|
+
if (typeof value !== "string" || value === "") {
|
|
528
|
+
throw new Error(
|
|
529
|
+
`archkeep: nx.json's workspaceLayout.${key} must be a non-empty string, got ` +
|
|
530
|
+
`${value === "" ? "an empty string" : typeof value}`,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return /** @type {{appsDir?: string, libsDir?: string}} */ (declared);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Narrows a `readWorkspaceLayout` result to "usable by the rule engine":
|
|
539
|
+
* either nothing declared (`null`, unchanged) or BOTH `appsDir` and `libsDir`
|
|
540
|
+
* present. `./rules/specifiers.mjs`'s `isAbsoluteImportIntoAnotherProject`
|
|
541
|
+
* reads both keys off one object with no per-key fallback — a workspace
|
|
542
|
+
* declaring only `libsDir` would silently evaluate `appsDir` as `undefined`
|
|
543
|
+
* and never match a real `apps/` import, the exact silent degradation
|
|
544
|
+
* `AGENTS.md`'s invariant rules out.
|
|
545
|
+
*
|
|
546
|
+
* This is also the parity point between the two providers:
|
|
547
|
+
* `./providers/native/model.mjs`'s `workspaceLayoutViolations` already
|
|
548
|
+
* refuses an incomplete `workspaceLayout` for `archkeep.json` — it never
|
|
549
|
+
* merges a partial declaration onto a default, it rejects the whole file — so
|
|
550
|
+
* `./providers/nx.mjs` and `./lsp/workspace-index.mjs` both call this
|
|
551
|
+
* function rather than each deciding independently, and the same declared
|
|
552
|
+
* object is accepted or refused identically by both.
|
|
553
|
+
*
|
|
554
|
+
* @param {{appsDir?: string, libsDir?: string}|null} declared
|
|
555
|
+
* @returns {{appsDir: string, libsDir: string}|null}
|
|
556
|
+
* @throws {Error} when `declared` is non-null but missing `appsDir` or
|
|
557
|
+
* `libsDir`.
|
|
558
|
+
*/
|
|
559
|
+
export function requireCompleteWorkspaceLayout(declared) {
|
|
560
|
+
if (declared === null) return null;
|
|
561
|
+
const missing = WORKSPACE_LAYOUT_KEYS.filter((key) => !(key in declared));
|
|
562
|
+
if (missing.length === 0) {
|
|
563
|
+
return /** @type {{appsDir: string, libsDir: string}} */ (declared);
|
|
564
|
+
}
|
|
565
|
+
throw new Error(
|
|
566
|
+
`archkeep: nx.json's workspaceLayout declares ${Object.keys(declared).join(", ") || "nothing"} ` +
|
|
567
|
+
`but is missing ${missing.join(", ")} — declare both appsDir and libsDir, or neither.`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Whether `workspaceRoot`'s `nx.json` registers THIS plugin — the other half
|
|
573
|
+
* of the unregistered-plugin gap `./workspace.mjs`'s `polyglotManifests`
|
|
574
|
+
* names the manifests for. A workspace can carry a tracked `go.mod` and never
|
|
575
|
+
* have told Nx to look, and that tree's `nx affected` under-selects with no
|
|
576
|
+
* warning at all — measured, the same silence `AGENTS.md`'s empty-result
|
|
577
|
+
* invariant refuses. This function only answers the yes/no; it is exported
|
|
578
|
+
* and tested on its own, and it is not currently consulted by `check`'s
|
|
579
|
+
* refusal logic — a later caller wires the two facts together.
|
|
580
|
+
*
|
|
581
|
+
* Shares `readPluginOptions`'s reader convention deliberately: `readFile`
|
|
582
|
+
* takes an absolute path and answers `null` for a file that is not there, the
|
|
583
|
+
* same default as that function, so a caller that already has one reader for
|
|
584
|
+
* this workspace's `nx.json` uses it for both calls.
|
|
585
|
+
*
|
|
586
|
+
* @param {string} workspaceRoot Absolute path of the tree being judged.
|
|
587
|
+
* @param {{readFile?: (path: string) => string|null}} [io]
|
|
588
|
+
* @returns {boolean} `false` for no `nx.json`, no `plugins` array, or no
|
|
589
|
+
* entry naming this plugin — the same three no-registration states
|
|
590
|
+
* `readPluginOptions` treats as "use the defaults". `true` for either form
|
|
591
|
+
* `namesThisPlugin` accepts: a bare string, or `{plugin, options}`.
|
|
592
|
+
* @throws {Error} when `nx.json` exists but cannot be parsed — the same
|
|
593
|
+
* posture as `readPluginOptions`: a tool that could not read its own
|
|
594
|
+
* configuration must not answer as though it had.
|
|
595
|
+
*/
|
|
596
|
+
export function pluginIsRegistered(workspaceRoot, { readFile = readFileOrNull } = {}) {
|
|
597
|
+
const nxJson = readNxJsonOrNull(workspaceRoot, readFile);
|
|
598
|
+
if (nxJson === null) return false;
|
|
599
|
+
|
|
600
|
+
const plugins = Array.isArray(nxJson?.plugins) ? nxJson.plugins : [];
|
|
601
|
+
return plugins.some(namesThisPlugin);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** A file's contents, or `null` when it does not exist or cannot be read. */
|
|
605
|
+
function readFileOrNull(path) {
|
|
606
|
+
try {
|
|
607
|
+
return readFileSync(path, "utf8");
|
|
608
|
+
} catch {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
}
|