@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.
Files changed (46) hide show
  1. package/README.md +3 -3
  2. package/cli.mjs +126 -4
  3. package/commands.mjs +6 -0
  4. package/lsp.mjs +15 -2
  5. package/package.json +6 -2
  6. package/src/analysis/analyze.mjs +15 -0
  7. package/src/analysis/contract.md +36 -18
  8. package/src/analysis/csharp.mjs +514 -0
  9. package/src/analysis/dotnet/csproj.mjs +380 -0
  10. package/src/analysis/dotnet/mask.mjs +178 -0
  11. package/src/analysis/dotnet/namespaces.mjs +172 -0
  12. package/src/analysis/dotnet/resolve.mjs +89 -0
  13. package/src/analysis/go.mjs +303 -5
  14. package/src/analysis/java.mjs +329 -0
  15. package/src/analysis/jvm/gradle.mjs +545 -0
  16. package/src/analysis/jvm/mask.mjs +170 -0
  17. package/src/analysis/jvm/maven.mjs +612 -0
  18. package/src/analysis/jvm/packages.mjs +209 -0
  19. package/src/analysis/jvm/resolve.mjs +139 -0
  20. package/src/analysis/kotlin.mjs +210 -0
  21. package/src/analysis/manifest-util.mjs +30 -0
  22. package/src/analysis/python.mjs +3 -2
  23. package/src/analysis/registry.mjs +11 -0
  24. package/src/analysis/rust.mjs +171 -17
  25. package/src/analysis/source-util.mjs +155 -6
  26. package/src/analysis/typescript.mjs +9 -2
  27. package/src/commands/context.mjs +84 -14
  28. package/src/commands/provenance.mjs +7 -44
  29. package/src/commands/rules.mjs +775 -0
  30. package/src/governance/profile-registry.mjs +0 -1
  31. package/src/graph/create-dependencies.mjs +138 -15
  32. package/src/lsp/diagnose.mjs +1 -1
  33. package/src/lsp/server.mjs +97 -1
  34. package/src/lsp/workspace-index.mjs +106 -15
  35. package/src/options.mjs +30 -7
  36. package/src/process.mjs +10 -1
  37. package/src/providers/moon.mjs +287 -36
  38. package/src/providers/native/differential.fixtures.mjs +32 -6
  39. package/src/providers/native/discover.mjs +83 -4
  40. package/src/providers/native/graph.mjs +58 -0
  41. package/src/providers/native/model.mjs +59 -1
  42. package/src/rules/index.mjs +21 -6
  43. package/src/rules/reachability.mjs +2 -0
  44. package/src/rules/tags.mjs +7 -5
  45. package/src/rules/topology.mjs +5 -3
  46. 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
- exclude: rawInfer.exclude ?? [],
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) => {
@@ -176,13 +176,18 @@ const isProjectGraphProjectNode = (node) =>
176
176
  */
177
177
  function spellingOf(site) {
178
178
  const spelling = site.spelling;
179
- if (typeof spelling?.path !== "boolean" || typeof spelling?.relative !== "boolean") {
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 specifier ` +
184
- `is a path and whether it stays inside its own project are per-language questions ` +
185
- `only the analyzer can answer. See src/analysis/contract.md.`,
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
- const absoluteIntoAnotherProject = isAbsoluteImportIntoAnotherProject(imp, ctx.workspaceLayout);
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) => {
@@ -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).filter(
169
- (projectName) =>
170
- pathExists(reach, targetProject.name, projectName) &&
171
- tags.some((tag) => hasTag(graph.nodes[projectName], tag)),
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]
@@ -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, since
262
- // the index only knows the files it was handed.
263
- (files[0] ?? ""),
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 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.
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(from, markers = [NX_CONFIG_FILE]) {
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 three polyglot manifests `polyglotManifests` looks for. */
540
- const POLYGLOT_MANIFEST_NAMES = ["go.mod", "Cargo.toml", "pyproject.toml"];
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 Python manifests that sit under some project's root —
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 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.
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
- if (!POLYGLOT_MANIFEST_NAMES.includes(base)) return false;
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
  );