@ecoma-io/archkeep 0.15.0 → 0.16.1
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/README.md +3 -3
- package/cli.mjs +126 -4
- package/commands.mjs +6 -0
- package/lsp.mjs +15 -2
- package/package.json +6 -2
- package/src/analysis/analyze.mjs +15 -0
- package/src/analysis/contract.md +36 -18
- package/src/analysis/csharp.mjs +514 -0
- package/src/analysis/dotnet/csproj.mjs +380 -0
- package/src/analysis/dotnet/mask.mjs +178 -0
- package/src/analysis/dotnet/namespaces.mjs +172 -0
- package/src/analysis/dotnet/resolve.mjs +89 -0
- package/src/analysis/go.mjs +303 -5
- package/src/analysis/java.mjs +329 -0
- package/src/analysis/jvm/gradle.mjs +545 -0
- package/src/analysis/jvm/mask.mjs +170 -0
- package/src/analysis/jvm/maven.mjs +612 -0
- package/src/analysis/jvm/packages.mjs +209 -0
- package/src/analysis/jvm/resolve.mjs +139 -0
- package/src/analysis/kotlin.mjs +210 -0
- package/src/analysis/manifest-util.mjs +30 -0
- package/src/analysis/python.mjs +3 -2
- package/src/analysis/registry.mjs +11 -0
- package/src/analysis/rust.mjs +171 -17
- package/src/analysis/source-util.mjs +155 -6
- package/src/analysis/typescript.mjs +9 -2
- package/src/commands/context.mjs +84 -14
- package/src/commands/provenance.mjs +7 -44
- package/src/commands/rules.mjs +775 -0
- package/src/governance/profile-registry.mjs +0 -1
- package/src/graph/create-dependencies.mjs +138 -15
- package/src/lsp/diagnose.mjs +1 -1
- package/src/lsp/server.mjs +97 -1
- package/src/lsp/workspace-index.mjs +106 -15
- package/src/options.mjs +30 -7
- package/src/process.mjs +10 -1
- package/src/providers/moon.mjs +287 -36
- package/src/providers/native/differential.fixtures.mjs +32 -6
- package/src/providers/native/discover.mjs +83 -4
- package/src/providers/native/graph.mjs +58 -0
- package/src/providers/native/model.mjs +59 -1
- package/src/rules/index.mjs +21 -6
- package/src/rules/reachability.mjs +2 -0
- package/src/rules/tags.mjs +7 -5
- package/src/rules/topology.mjs +5 -3
- package/src/workspace.mjs +115 -23
|
@@ -232,3 +232,61 @@ export function buildNativeGraph({
|
|
|
232
232
|
...(exemptedFiles && exemptedFiles.length > 0 ? { exemptedFiles } : {}),
|
|
233
233
|
};
|
|
234
234
|
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Folds manifest-resolver records — `{source, target, sourceFile, type}` from
|
|
238
|
+
* `../../graph/create-dependencies.mjs`'s `resolveDeclaredManifestEdges` —
|
|
239
|
+
* into an already-built graph's `dependencies`, in place. The callers are the
|
|
240
|
+
* graph assemblies that have no plugin host to draw these edges for them:
|
|
241
|
+
* this module's own `buildGraph` (the CLI's native branch and this package's
|
|
242
|
+
* language-server index), the Moon branch of both, and the language server's
|
|
243
|
+
* Nx-shaped index. On the CLI's Nx branch the polyglot plugin registers into
|
|
244
|
+
* Nx's own graph computation instead — `resolvePolyglotDependencies` there,
|
|
245
|
+
* these same resolvers — so that face arrives with the edges already drawn.
|
|
246
|
+
*
|
|
247
|
+
* Deduplication is by `(source, target, type)`, the same key `buildDependencies`
|
|
248
|
+
* dedupes import sites by and the same key the JSON envelope flattens on, so a
|
|
249
|
+
* dependency witnessed by BOTH tracks — a written `using` and the
|
|
250
|
+
* `<ProjectReference>` for it, a Kotlin import and the Gradle `project(":x")`
|
|
251
|
+
* — carries one record, exactly as one witnessed by two imports does. The
|
|
252
|
+
* resolvers iterate only the workspace's own projects and resolve every target
|
|
253
|
+
* against that same project list, so source and target are nodes of the graph
|
|
254
|
+
* this fold serves by construction; no membership check is taken here, because
|
|
255
|
+
* one that skipped would be the silent direction — an edge dropped for not
|
|
256
|
+
* being a node is a finding unreported, where a mismatch between the merged
|
|
257
|
+
* records and the graph's nodes is a caller bug that should surface.
|
|
258
|
+
*
|
|
259
|
+
* @param {{nodes: Record<string, object>, dependencies?: Record<string, {source: string, target: string, type: string}[]>}} graph
|
|
260
|
+
* Mutated in place.
|
|
261
|
+
* @param {{source: string, target: string, sourceFile?: string, type: string}[]} records
|
|
262
|
+
* @returns {{nodes: Record<string, object>, dependencies: Record<string, {source: string, target: string, type: string}[]>}} The same graph.
|
|
263
|
+
*/
|
|
264
|
+
export function mergeDeclaredEdges(graph, records) {
|
|
265
|
+
// Null-prototype for the same reason `buildDependencies` above uses one:
|
|
266
|
+
// every key is a project name this package does not control.
|
|
267
|
+
const dependencies = graph.dependencies ?? Object.create(null);
|
|
268
|
+
graph.dependencies = dependencies;
|
|
269
|
+
const seen = new Set();
|
|
270
|
+
for (const list of Object.values(dependencies)) {
|
|
271
|
+
for (const edge of list ?? []) {
|
|
272
|
+
seen.add(JSON.stringify([edge.source, edge.target, edge.type]));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
for (const record of records) {
|
|
276
|
+
const key = JSON.stringify([record.source, record.target, record.type]);
|
|
277
|
+
if (seen.has(key)) continue;
|
|
278
|
+
seen.add(key);
|
|
279
|
+
(dependencies[record.source] ??= []).push({
|
|
280
|
+
source: record.source,
|
|
281
|
+
target: record.target,
|
|
282
|
+
type: record.type,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
// The cast is the post-condition the signature promises: the fold above
|
|
286
|
+
// guarantees `dependencies` exists on the graph it returns, but the
|
|
287
|
+
// parameter's declared type — which admits a graph arriving without one —
|
|
288
|
+
// is what `graph` carries here.
|
|
289
|
+
return /** @type {{nodes: Record<string, object>, dependencies: Record<string, {source: string, target: string, type: string}[]>}} */ (
|
|
290
|
+
graph
|
|
291
|
+
);
|
|
292
|
+
}
|
|
@@ -195,10 +195,66 @@ export const DEFAULT_MANIFEST_NAMES = Object.freeze([
|
|
|
195
195
|
"go.mod",
|
|
196
196
|
"Cargo.toml",
|
|
197
197
|
"pyproject.toml",
|
|
198
|
+
// Maven: every tracked root pom.xml anchors a project (ADR 0005). Identity
|
|
199
|
+
// is `(groupId, artifactId)` read by `../../analysis/jvm/maven.mjs`; the
|
|
200
|
+
// name follows the same precedence as every other inferred manifest, so
|
|
201
|
+
// pom-discovered projects land on their directory basename unless a
|
|
202
|
+
// declared row names them.
|
|
203
|
+
"pom.xml",
|
|
204
|
+
// Gradle: settings.gradle / settings.gradle.kts anchors a Gradle build
|
|
205
|
+
// (ADR 0005 Decision 2). Identity is the root project name and included
|
|
206
|
+
// projects from the settings file; edges are read from build.gradle /
|
|
207
|
+
// build.gradle.kts by `../../analysis/jvm/gradle.mjs`. The settings file
|
|
208
|
+
// is the discovery manifest — it defines the reactor structure — while
|
|
209
|
+
// the build files hold the dependency declarations.
|
|
210
|
+
"settings.gradle",
|
|
211
|
+
"settings.gradle.kts",
|
|
212
|
+
// .NET/C#: every tracked root .csproj anchors a project (ADR 0006). Identity
|
|
213
|
+
// is the project name read by `../../analysis/dotnet/csproj.mjs`; the
|
|
214
|
+
// name follows the same precedence as every other inferred manifest, so
|
|
215
|
+
// csproj-discovered projects land on their directory basename unless a
|
|
216
|
+
// declared row names them.
|
|
217
|
+
"*.csproj",
|
|
198
218
|
]);
|
|
199
219
|
|
|
200
220
|
const PROJECT_TYPES = ["app", "lib", "e2e"];
|
|
201
221
|
|
|
222
|
+
/**
|
|
223
|
+
* The deliberate default for `projects.infer.exclude` — the anchor-exclusion
|
|
224
|
+
* half of the phantom-project policy (issue #371): a tracked manifest inside a
|
|
225
|
+
* directory that is documentation or test data about the workspace, rather
|
|
226
|
+
* than a part of it, never anchors an inferred project, because inference over
|
|
227
|
+
* it would judge a phantom as real. `docs/`, `fixtures/` and `__fixtures__/`
|
|
228
|
+
* as whole path segments are the complete set: those three names mean "data
|
|
229
|
+
* about the tree" in every convention this package has measured, and a name
|
|
230
|
+
* like `examples/` was deliberately left OFF it — example projects are
|
|
231
|
+
* commonly real, built, governed code, so excluding them by name would be the
|
|
232
|
+
* same over-broad-by-name error the `obj`/`bin` half of the same issue
|
|
233
|
+
* repudiated (`./discover.mjs`'s `isDotnetGeneratedOutput`).
|
|
234
|
+
*
|
|
235
|
+
* Two facts make this exclusion safe rather than a silent hole:
|
|
236
|
+
*
|
|
237
|
+
* - `projects.declared` is exempt — a workspace with a real project under one
|
|
238
|
+
* of these paths declares it, and declaration is the authoritative channel
|
|
239
|
+
* inference never touches.
|
|
240
|
+
* - A dropped anchor's analyzable files do not vanish silently: they surface
|
|
241
|
+
* through `./coverage.mjs`'s unclaimed-file judgment as whole-file failures
|
|
242
|
+
* until the workspace either declares the project or records a reasoned
|
|
243
|
+
* `coverage.exempt` row — which is the loud, explicit opt-in for "this
|
|
244
|
+
* directory is fixture data".
|
|
245
|
+
*
|
|
246
|
+
* An explicit `exclude` list REPLACES this default (the `tsc` convention for
|
|
247
|
+
* the same field): a workspace that names its own list takes over the whole
|
|
248
|
+
* decision, `exclude: []` included — that spelling is the documented opt-out.
|
|
249
|
+
*
|
|
250
|
+
* @see DEFAULT_MANIFEST_NAMES
|
|
251
|
+
*/
|
|
252
|
+
export const DEFAULT_INFER_EXCLUDE = Object.freeze([
|
|
253
|
+
"**/docs/**",
|
|
254
|
+
"**/fixtures/**",
|
|
255
|
+
"**/__fixtures__/**",
|
|
256
|
+
]);
|
|
257
|
+
|
|
202
258
|
/** @type {(value: unknown) => value is Record<string, unknown>} */
|
|
203
259
|
const isPlainObject = (value) =>
|
|
204
260
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -702,7 +758,9 @@ export function normalizeNativeModel(raw) {
|
|
|
702
758
|
: {
|
|
703
759
|
manifests: rawInfer.manifests ?? DEFAULT_MANIFEST_NAMES,
|
|
704
760
|
include: rawInfer.include ?? ["**"],
|
|
705
|
-
|
|
761
|
+
// Replaces, never merges: an explicit list takes over the whole
|
|
762
|
+
// decision — `DEFAULT_INFER_EXCLUDE`'s doc comment owns the why.
|
|
763
|
+
exclude: rawInfer.exclude ?? DEFAULT_INFER_EXCLUDE,
|
|
706
764
|
},
|
|
707
765
|
},
|
|
708
766
|
projectRules: /** @type {unknown[]} */ (raw.projectRules ?? []).map((row) => {
|
package/src/rules/index.mjs
CHANGED
|
@@ -176,13 +176,18 @@ const isProjectGraphProjectNode = (node) =>
|
|
|
176
176
|
*/
|
|
177
177
|
function spellingOf(site) {
|
|
178
178
|
const spelling = site.spelling;
|
|
179
|
-
if (
|
|
179
|
+
if (
|
|
180
|
+
typeof spelling?.path !== "boolean" ||
|
|
181
|
+
typeof spelling?.relative !== "boolean" ||
|
|
182
|
+
typeof spelling?.namesOnly !== "boolean"
|
|
183
|
+
) {
|
|
180
184
|
throw new Error(
|
|
181
185
|
`archkeep: ${site.sourceFile}:${site.line}:${site.column} imports ` +
|
|
182
186
|
`'${site.specifier}' in a record carrying no \`spelling\` — the analysis contract ` +
|
|
183
|
-
`requires \`{ path, relative }\` on every import site, because whether a
|
|
184
|
-
`is a path
|
|
185
|
-
`only the analyzer
|
|
187
|
+
`requires \`{ path, relative, namesOnly }\` on every import site, because whether a ` +
|
|
188
|
+
`specifier is a path, whether it stays inside its own project, and whether its ` +
|
|
189
|
+
`language has any path spelling at all are per-language questions only the analyzer ` +
|
|
190
|
+
`can answer. See src/analysis/contract.md.`,
|
|
186
191
|
);
|
|
187
192
|
}
|
|
188
193
|
return spelling;
|
|
@@ -585,8 +590,18 @@ function* candidateGroupsFor(site, ctx) {
|
|
|
585
590
|
if (!sourceProject) return;
|
|
586
591
|
|
|
587
592
|
// Relative and absolute paths are judged on their TEXT, before any resolution:
|
|
588
|
-
// the projects can be correct and the spelling still be the violation.
|
|
589
|
-
|
|
593
|
+
// the projects can be correct and the spelling still be the violation. The
|
|
594
|
+
// absolute half is a JavaScript-family convention — a bare `libs/x` deep
|
|
595
|
+
// import, a `/libs/x` absolute path — so it stands down entirely where the
|
|
596
|
+
// analyzer declared the language has no path spelling at all
|
|
597
|
+
// (`spelling.namesOnly`): a Go module path or a C# namespace beginning
|
|
598
|
+
// `libs/` is a name, and the name is the only spelling the language has
|
|
599
|
+
// (#376). The edge such an import resolves to is still judged by every check
|
|
600
|
+
// below — only this spelling check stands down, so gating it too broadly
|
|
601
|
+
// (on `spelling.path`, which is false for the bare JS form too) would trade
|
|
602
|
+
// this loud bug for a silent one against ESLint.
|
|
603
|
+
const absoluteIntoAnotherProject =
|
|
604
|
+
!spellingOf(site).namesOnly && isAbsoluteImportIntoAnotherProject(imp, ctx.workspaceLayout);
|
|
590
605
|
let targetProject = absoluteIntoAnotherProject
|
|
591
606
|
? graph.nodes[findProjectForPath(imp, mappings)]
|
|
592
607
|
: graph.nodes[getTargetProjectBasedOnRelativeImport(imp, site.sourceFile, mappings)];
|
|
@@ -140,6 +140,8 @@ export function getPath(reach, graph, sourceProjectName, targetProjectName) {
|
|
|
140
140
|
if (current === targetProjectName) break;
|
|
141
141
|
if (!adjList[current]) break;
|
|
142
142
|
adjList[current]
|
|
143
|
+
.slice()
|
|
144
|
+
.sort()
|
|
143
145
|
.filter((adj) => visited.indexOf(adj) === -1)
|
|
144
146
|
.filter((adj) => matrix[adj]?.[targetProjectName])
|
|
145
147
|
.forEach((adj) => {
|
package/src/rules/tags.mjs
CHANGED
|
@@ -165,11 +165,13 @@ export function orphanedNotDependOnTags(depConstraints, graph) {
|
|
|
165
165
|
* are what the message prints so a reader can see the hop that did it.
|
|
166
166
|
*/
|
|
167
167
|
export function findDependenciesWithTags(targetProject, tags, graph, reach) {
|
|
168
|
-
const reachable = Object.keys(graph.nodes)
|
|
169
|
-
(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
168
|
+
const reachable = Object.keys(graph.nodes)
|
|
169
|
+
.sort()
|
|
170
|
+
.filter(
|
|
171
|
+
(projectName) =>
|
|
172
|
+
pathExists(reach, targetProject.name, projectName) &&
|
|
173
|
+
tags.some((tag) => hasTag(graph.nodes[projectName], tag)),
|
|
174
|
+
);
|
|
173
175
|
return reachable.map((project) =>
|
|
174
176
|
targetProject.name === project
|
|
175
177
|
? [targetProject]
|
package/src/rules/topology.mjs
CHANGED
|
@@ -254,13 +254,15 @@ export function circularViolation({
|
|
|
254
254
|
targetProjectName: targetProject.name,
|
|
255
255
|
path: circularPath.reduce((acc, v) => `${acc} -> ${v.name}`, sourceProject.name),
|
|
256
256
|
filePaths: circularFilePath
|
|
257
|
+
.map((files) => files.filter((f) => typeof f === "string"))
|
|
257
258
|
.map((files) =>
|
|
258
259
|
files.length > 1
|
|
259
260
|
? `[${files.map((f) => `\n${spacer}${spacer}${f}`).join(",")}\n${spacer}]`
|
|
260
261
|
: // Upstream indexes `files[0]` unguarded, printing `undefined` for a
|
|
261
|
-
// hop with no file to blame. Ours can legitimately have none
|
|
262
|
-
//
|
|
263
|
-
|
|
262
|
+
// hop with no file to blame. Ours can legitimately have none —
|
|
263
|
+
// a manifest-declared edge carries no import site — and a blank
|
|
264
|
+
// bullet names nothing, so the hop says what it is instead.
|
|
265
|
+
(files[0] ?? "(no source file — a manifest declares this dependency)"),
|
|
264
266
|
)
|
|
265
267
|
.reduce((acc, files) => `${acc}\n- ${files}`, `- ${sourceFile}`),
|
|
266
268
|
},
|
package/src/workspace.mjs
CHANGED
|
@@ -28,11 +28,12 @@
|
|
|
28
28
|
* is the same tracked-file set every resolver in this project already reasons
|
|
29
29
|
* about ("Resolvers read tracked files only", project `AGENTS.md`).
|
|
30
30
|
*/
|
|
31
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
31
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
32
32
|
import { dirname, isAbsolute, join, posix, relative, resolve } from "node:path";
|
|
33
33
|
|
|
34
34
|
import { containmentViolation } from "./containment.mjs";
|
|
35
35
|
import { analyzeFile, languageOf } from "./analysis/analyze.mjs";
|
|
36
|
+
import { basenameMatches } from "./analysis/manifest-util.mjs";
|
|
36
37
|
import { fileFailure, projectOwning } from "./analysis/source-util.mjs";
|
|
37
38
|
import { UsageError } from "./errors.mjs";
|
|
38
39
|
import { parseNxJson } from "./nx-json.mjs";
|
|
@@ -57,29 +58,109 @@ export { environmentForTree, runProcess };
|
|
|
57
58
|
* with a hoisted install, the right one by luck (same reason
|
|
58
59
|
* `loadBoundaryConfig` takes a root — see `config.mjs`).
|
|
59
60
|
*
|
|
61
|
+
* The walk is bounded by the enclosing git repository: a marker above the top
|
|
62
|
+
* level `git rev-parse --show-toplevel` names for `from` is tooling state,
|
|
63
|
+
* not this workspace's root. The case that made the bound necessary is
|
|
64
|
+
* `~/.moon` — moonrepo's user-level state directory, present on every machine
|
|
65
|
+
* moonrepo has ever run on (its own documentation puts the shared cache at
|
|
66
|
+
* `~/.moon/cache/shared`), which a walk reading only directory existence
|
|
67
|
+
* climbed to from any unmarked directory under the home directory, selecting
|
|
68
|
+
* `$HOME` as a "Moon workspace" and failing to load
|
|
69
|
+
* `$HOME/module-boundaries.config.mjs` instead of refusing (#339). The bound
|
|
70
|
+
* is inclusive: a marker ON the top level is the ordinary case, a repository
|
|
71
|
+
* that is itself the workspace. With no enclosing repository — or no `git` to
|
|
72
|
+
* ask — there is no bound, and the walk climbs as far as it did before one
|
|
73
|
+
* existed; the shape of the Moon markers is what holds the line there.
|
|
74
|
+
*
|
|
60
75
|
* `markers` defaults to `nx.json` alone, so every existing caller keeps
|
|
61
76
|
* finding exactly the root it found before. A native-provider caller passes
|
|
62
77
|
* `[NX_CONFIG_FILE, ARCHKEEP_MODEL_FILE]` to recognise either root marker in
|
|
63
78
|
* one walk — see `../cli.mjs`, which is the only caller that needs to tell
|
|
64
79
|
* the two apart, and does so by checking which marker(s) the returned
|
|
65
|
-
* directory actually carries.
|
|
80
|
+
* directory actually carries. A Moon marker names the directory's
|
|
81
|
+
* `workspace.yml` — the file moonrepo itself requires of a workspace — never
|
|
82
|
+
* the directory alone (`../providers/moon.mjs`'s `MOON_WORKSPACE_MARKER`):
|
|
83
|
+
* a bare `.moon` is the user-level state directory again, and directory
|
|
84
|
+
* presence alone is exactly what selected `$HOME`.
|
|
66
85
|
*
|
|
67
86
|
* @param {string} from Absolute directory to start at.
|
|
68
|
-
* @param {string[]} [markers] Filenames or
|
|
69
|
-
* marks a workspace root. `existsSync` works for
|
|
70
|
-
* like `.moon` is detected the same way a filename like
|
|
87
|
+
* @param {string[]} [markers] Filenames or relative paths whose presence
|
|
88
|
+
* marks a workspace root. `existsSync` works for all of them — a relative
|
|
89
|
+
* path like `.moon/workspace.yml` is detected the same way a filename like
|
|
90
|
+
* `nx.json` is.
|
|
91
|
+
* @param {{gitTopLevel?: (from: string) => string|null}} [io] The git seam:
|
|
92
|
+
* how the walk asks for the enclosing repository's top level, injectable
|
|
93
|
+
* for the same reason every spawn here is. The default runs
|
|
94
|
+
* `git rev-parse --show-toplevel` through `runProcess` — so ambient
|
|
95
|
+
* `GIT_DIR`-style redirects are stripped, the boundary describing the tree
|
|
96
|
+
* at `from` rather than whatever repository a hook exported — and answers
|
|
97
|
+
* `null` when git cannot (no repository encloses `from`, or git is absent).
|
|
98
|
+
* `null` is a missing boundary, never a refusal: the walk then climbs
|
|
99
|
+
* unbounded, exactly as it did before the boundary existed.
|
|
71
100
|
* @returns {string|null} Absolute path, or `null` when no ancestor has one.
|
|
72
101
|
*/
|
|
73
|
-
export function findWorkspaceRoot(
|
|
102
|
+
export function findWorkspaceRoot(
|
|
103
|
+
from,
|
|
104
|
+
markers = [NX_CONFIG_FILE],
|
|
105
|
+
{ gitTopLevel = gitTopLevelOf } = {},
|
|
106
|
+
) {
|
|
107
|
+
const ceiling = gitTopLevel(resolve(from));
|
|
74
108
|
let current = resolve(from);
|
|
75
109
|
for (;;) {
|
|
76
110
|
if (markers.some((marker) => existsSync(join(current, marker)))) return current;
|
|
111
|
+
// Inclusive on purpose: the marker check above already ran for the top
|
|
112
|
+
// level itself, so reaching the ceiling with no marker means no ancestor
|
|
113
|
+
// within the repository is a workspace root — and every ancestor beyond
|
|
114
|
+
// it is outside the tree `git ls-files` would answer for.
|
|
115
|
+
if (ceiling !== null && sameDirectory(current, ceiling)) return null;
|
|
77
116
|
const parent = dirname(current);
|
|
78
117
|
if (parent === current) return null;
|
|
79
118
|
current = parent;
|
|
80
119
|
}
|
|
81
120
|
}
|
|
82
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The top level of the git repository enclosing `from` — the ceiling
|
|
124
|
+
* `findWorkspaceRoot` above stops its walk at.
|
|
125
|
+
*
|
|
126
|
+
* `null` when none does or when git cannot answer (absent binary, bare
|
|
127
|
+
* repository, unreadable `.git`): a boundary that cannot be measured is a
|
|
128
|
+
* boundary absent, and the walk degrades to its previous unbounded climb
|
|
129
|
+
* rather than refusing a root it never looked at. A repository git genuinely
|
|
130
|
+
* cannot read is caught loudly one call later by `listTrackedFiles`, whose
|
|
131
|
+
* own spawn has no `null` answer.
|
|
132
|
+
*/
|
|
133
|
+
function gitTopLevelOf(from) {
|
|
134
|
+
try {
|
|
135
|
+
// `stderr: "ignore"` because a directory no repository encloses is the
|
|
136
|
+
// COMMON case this probe must answer quietly — git's `fatal: not a git
|
|
137
|
+
// repository` belongs nowhere near a user's terminal for it.
|
|
138
|
+
return runProcess("git", ["rev-parse", "--show-toplevel"], from, undefined, {
|
|
139
|
+
stderr: "ignore",
|
|
140
|
+
}).trim();
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Do `a` and `b` name the same directory? `git rev-parse --show-toplevel`
|
|
148
|
+
* answers a fully resolved path while the walk holds the spelling it was
|
|
149
|
+
* given — on macOS every `os.tmpdir()` path starts `/var/folders/…`, whose
|
|
150
|
+
* real spelling is `/private/var/folders/…`, and a plain `===` would let the
|
|
151
|
+
* ceiling silently never bind there. Doubt answers false: a boundary that
|
|
152
|
+
* fails to bind is the old walk, while one that binds wrongly refuses a real
|
|
153
|
+
* workspace.
|
|
154
|
+
*/
|
|
155
|
+
const sameDirectory = (a, b) => {
|
|
156
|
+
if (a === b) return true;
|
|
157
|
+
try {
|
|
158
|
+
return realpathSync(a) === realpathSync(b);
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
83
164
|
/**
|
|
84
165
|
* Every tracked file in the workspace, workspace-relative.
|
|
85
166
|
*
|
|
@@ -536,26 +617,34 @@ export function analyzeWorkspace(workspace, files, { analyze = analyzeFile } = {
|
|
|
536
617
|
return { imports, failures, analyzed: analyzedFiles.length, analyzedFiles };
|
|
537
618
|
}
|
|
538
619
|
|
|
539
|
-
/** The
|
|
540
|
-
const POLYGLOT_MANIFEST_NAMES = [
|
|
620
|
+
/** The polyglot manifests `polyglotManifests` looks for. */
|
|
621
|
+
const POLYGLOT_MANIFEST_NAMES = [
|
|
622
|
+
"go.mod",
|
|
623
|
+
"Cargo.toml",
|
|
624
|
+
"pyproject.toml",
|
|
625
|
+
"pom.xml",
|
|
626
|
+
"settings.gradle",
|
|
627
|
+
"settings.gradle.kts",
|
|
628
|
+
"*.csproj",
|
|
629
|
+
];
|
|
541
630
|
|
|
542
631
|
/**
|
|
543
|
-
* Tracked Go, Rust and
|
|
544
|
-
* the fact the unregistered-Nx-plugin gap turns on
|
|
632
|
+
* Tracked Go, Rust, Python, Maven and .NET manifests that sit under some project's
|
|
633
|
+
* root — the fact the unregistered-Nx-plugin gap turns on
|
|
545
634
|
* (`./commands/context.mjs`'s `pluginGap.manifests` is where a caller reads
|
|
546
635
|
* 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
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
636
|
+
* gap). A workspace running under Nx draws no edge for any of these languages
|
|
637
|
+
* unless this plugin is registered in `nx.json` — Nx parses only TypeScript
|
|
638
|
+
* and JavaScript imports natively (`../../../AGENTS.md`, "for the other
|
|
639
|
+
* three both go quiet") — so a tracked manifest with no registered plugin is
|
|
640
|
+
* exactly the silent hole that invariant refuses. This function only names
|
|
641
|
+
* the manifests; it does not decide whether the plugin is registered. The
|
|
642
|
+
* pair the gap turns on is wired in twice today: `resolveCommandContext`
|
|
643
|
+
* reads both into its `pluginGap`, which every descriptive command refuses
|
|
644
|
+
* on, while `check` renders the same fact as a `coverageGaps`
|
|
645
|
+
* degraded-coverage note rather than a refusal (`../cli.mjs`) — the checker's
|
|
646
|
+
* own analysis covers what the graph does not, so a note is the right level
|
|
647
|
+
* there.
|
|
559
648
|
*
|
|
560
649
|
* Root matching mirrors `projectOwning`'s longest-prefix attribution of a
|
|
561
650
|
* source file: a project rooted at the workspace root (`root: ""` or `"."`)
|
|
@@ -572,7 +661,10 @@ export function polyglotManifests(tracked, projects) {
|
|
|
572
661
|
const roots = projects.map((project) => project.root);
|
|
573
662
|
return tracked.filter((file) => {
|
|
574
663
|
const base = file.slice(file.lastIndexOf("/") + 1);
|
|
575
|
-
|
|
664
|
+
// Literal-first: four of the five names answer by equality, and only
|
|
665
|
+
// `*.csproj` reaches the glob — this filter runs once per tracked file
|
|
666
|
+
// (`./analysis/manifest-util.mjs`'s `basenameMatches` owns why).
|
|
667
|
+
if (!basenameMatches(base, POLYGLOT_MANIFEST_NAMES, posix.matchesGlob)) return false;
|
|
576
668
|
return roots.some(
|
|
577
669
|
(root) => root === "" || root === "." || file === root || file.startsWith(`${root}/`),
|
|
578
670
|
);
|