@ecoma-io/archkeep 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +485 -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 +289 -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
@@ -342,7 +342,6 @@ export function loadProfileRegistry(
342
342
  if (violations.length > 0) {
343
343
  throw new Error(`archkeep: ${path} is malformed:\n ${violations.join("\n ")}`);
344
344
  }
345
- /** @type {{profiles: object[]}} */ (parsed);
346
345
  return parsed;
347
346
  }
348
347
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The graph layer: cross-project EDGES for Go, Rust, and Python, in the shape
2
+ * The graph layer: cross-project EDGES for Go, Rust, Python, Java, and C#/\.NET, in the shape
3
3
  * Nx's `createDependencies` hook returns. Nothing else — nodes still come from
4
4
  * each project's hand-written `project.json`, and targets are never inferred
5
5
  * (`packages/archkeep/AGENTS.md`).
@@ -14,33 +14,94 @@
14
14
  *
15
15
  * Each resolver reads tracked manifests and sources statically (regex for Go
16
16
  * imports, smol-toml for Cargo/pyproject manifests) so the graph computes
17
- * without any language toolchain installed. A workspace with no Go/Rust/Python
18
- * projects pays nothing: every resolver keys off `<projectRoot>/<manifest>`
19
- * existing in the project's tracked files. A resolver may THROW instead of
20
- * returning — the Python one does, for a declared path dependency it cannot
21
- * attribute to any project (`../analysis/python.mjs` header) — and the throw
22
- * is deliberate: edges and an error are the only two outputs this hook has,
23
- * and an edge quietly missing from the graph is the failure mode this plugin
24
- * exists to close.
17
+ * without any language toolchain installed. A workspace with no Go/Rust/Python/
18
+ * Java/C# projects pays nothing: every resolver keys off what it reads existing in
19
+ * the project's tracked files.
25
20
  *
26
- * Resolver contract (kept identical across languages, see `../analysis/*.mjs`):
27
- * resolve(projects, filesOf, readFile) -> [{ source, target, sourceFile, type }]
21
+ * ## The hook's failure posture (#364)
22
+ *
23
+ * Edges and a throw are the only two outputs this hook has, and every
24
+ * manifest reader and name index throws for the states its funnel
25
+ * classifies as could-not-complete — the same failure lists the CLI turns
26
+ * into exit 3: the Maven, Gradle and .csproj models, the JVM package index,
27
+ * the C# namespace index, the Go module map and the Rust crate map
28
+ * (`../analysis/source-util.mjs`'s `refuseUnreadTree`).
29
+ * Nx wraps a throwing hook's error and fails the whole graph computation —
30
+ * measured against nx 23.1.1, which also has a dedicated `ProcessDependenciesError`
31
+ * for the event and whose daemon refuses with an error rather than serving a
32
+ * stale graph — so the throw is a designed-for channel, and `nx affected`
33
+ * fails loudly on a broken reactor instead of under-selecting on it.
34
+ *
35
+ * The boundary: manifests and workspace-wide name indexes throw — one
36
+ * unreadable entry corrupts resolution for every project that names that
37
+ * identity, arbitrarily far from the file, so the failure cannot be
38
+ * attributed to the file's own edges — while per-source import reads keep
39
+ * the null-read posture the hook's `readFile` states below (a dropped source
40
+ * loses only its own edges; a dropped manifest or index entry loses
41
+ * everyone's). Python's dangling-path throw is the precedent that predates
42
+ * the rule; its malformed-TOML tolerance stays the documented exception its
43
+ * own header pins (`../analysis/python.mjs`).
44
+ *
45
+ * Resolver contract (see `../analysis/*.mjs`): every resolver returns raw Nx
46
+ * edges — { source, target, sourceFile, type } and nothing else. Go, Rust and
47
+ * Python take `resolve(projects, filesOf, readFile)`; the C# and JVM halves
48
+ * instead take ONE workspace-shaped object (`{ projects, filesOf, readFile }`),
49
+ * because everything they read is `perWorkspace`-memoized on that object — the
50
+ * C# namespace index, the JVM package index, the Maven and Gradle models — and
51
+ * a resolver destructuring its own arguments would have to build a fresh
52
+ * object per call, defeating the memo (#363: three builds of the same index
53
+ * per run).
28
54
  */
29
55
  import { readFileSync } from "node:fs";
30
56
  import { join } from "node:path";
31
57
 
32
58
  import { containmentViolation } from "../containment.mjs";
59
+ import { resolveCsharpDependencies } from "../analysis/csharp.mjs";
60
+ import { resolveCsprojDependencies, dotnetManifestFailures } from "../analysis/dotnet/csproj.mjs";
33
61
  import { resolveGoDependencies } from "../analysis/go.mjs";
62
+ import { resolveJavaDependencies } from "../analysis/java.mjs";
63
+ import { resolveKotlinDependencies } from "../analysis/kotlin.mjs";
64
+ import { resolveMavenDependencies, mavenManifestFailures } from "../analysis/jvm/maven.mjs";
65
+ import { resolveGradleDependencies, gradleManifestFailures } from "../analysis/jvm/gradle.mjs";
66
+ import { dotnetIndexFailures } from "../analysis/dotnet/namespaces.mjs";
34
67
  import { resolvePythonDependencies } from "../analysis/python.mjs";
35
68
  import { resolveRustDependencies } from "../analysis/rust.mjs";
36
69
  import { resolveOptions } from "../options.mjs";
37
70
 
38
71
  /** Pure core over an abstract workspace; injectable for tests. */
39
72
  export function resolvePolyglotDependencies(projects, filesOf, readFile) {
73
+ // The C# and JVM halves read the same tree through ONE workspace-shaped
74
+ // object: their memoized reads (namespace index, package index, Maven and
75
+ // Gradle models) all key on the object itself, so every resolver handed the
76
+ // same one shares a single build of each — and the memoized read behind the
77
+ // object means no file's content is fetched twice on one graph computation.
78
+ // The positional resolvers (Go, Rust, Python) hold no such memo, so they
79
+ // keep taking the three values directly.
80
+ const reads = new Map();
81
+ const sharedWorkspace = {
82
+ projects,
83
+ filesOf,
84
+ readFile: (path) => {
85
+ if (!reads.has(path)) reads.set(path, readFile(path));
86
+ return reads.get(path);
87
+ },
88
+ };
40
89
  const deps = [
41
90
  ...resolveGoDependencies(projects, filesOf, readFile),
42
91
  ...resolveRustDependencies(projects, filesOf, readFile),
43
92
  ...resolvePythonDependencies(projects, filesOf, readFile),
93
+ ...resolveJavaDependencies(sharedWorkspace),
94
+ ...resolveKotlinDependencies(sharedWorkspace),
95
+ ...resolveCsharpDependencies(sharedWorkspace),
96
+ // Manifest edges for .csproj trees: ProjectReference resolution,
97
+ // independent of (and complementary to) the source-track edges above.
98
+ ...resolveCsprojDependencies(sharedWorkspace),
99
+ // Manifest edges for Maven/Gradle trees: the identity-anchor half of JVM
100
+ // support, independent of (and complementary to) the import edges above
101
+ // — a declared-but-unused dependency and an undeclared-but-imported one
102
+ // are both findings.
103
+ ...resolveMavenDependencies(sharedWorkspace),
104
+ ...resolveGradleDependencies(sharedWorkspace),
44
105
  ];
45
106
  // One edge per (source, target, sourceFile) — a Go project importing a
46
107
  // sibling from ten files yields ten sourceFile-attributed edges upstream
@@ -54,6 +115,64 @@ export function resolvePolyglotDependencies(projects, filesOf, readFile) {
54
115
  });
55
116
  }
56
117
 
118
+ /**
119
+ * The manifest-track edges in one list — `<ProjectReference>` (plus
120
+ * `<Using Include>`) for .NET trees, Maven coordinate matching and Gradle
121
+ * `project(":x")` for JVM ones. `resolvePolyglotDependencies` above already
122
+ * folds these into the Nx plugin hook's answer; the CLI's native and Moon
123
+ * branches and the language server's index build their graphs from import
124
+ * sites alone, with no plugin host to call the hook, so they fold this list
125
+ * themselves (`../providers/native/graph.mjs`'s `mergeDeclaredEdges`) — the
126
+ * two-track contract of `../../../../docs/adr/0006-dotnet-language-integration.md`
127
+ * (Decision 3) and `../../../../docs/adr/0005-jvm-language-integration.md`
128
+ * (Decision 4) names no face it does not hold on.
129
+ *
130
+ * Each resolver refuses — throws — on exactly the failure list its
131
+ * `*ManifestFailures` twin reports from the same memoized model
132
+ * (`../../analysis/source-util.mjs`'s `perWorkspace` memoizes on the
133
+ * workspace object, so twin calls share one build): a caller that has found
134
+ * `mavenManifestFailures`/`gradleManifestFailures`/`dotnetManifestFailures`/
135
+ * `dotnetIndexFailures` empty on THIS workspace cannot hit the throw.
136
+ *
137
+ * @param {object} workspace The shared workspace-shaped object.
138
+ * @returns {{source: string, target: string, sourceFile: string, type: string}[]}
139
+ */
140
+ export function resolveDeclaredManifestEdges(workspace) {
141
+ return [
142
+ ...resolveCsprojDependencies(workspace),
143
+ ...resolveMavenDependencies(workspace),
144
+ ...resolveGradleDependencies(workspace),
145
+ ];
146
+ }
147
+
148
+ /**
149
+ * The could-not-complete failures behind `resolveDeclaredManifestEdges`'s
150
+ * refusal — the four manifest and index failure lists folded into one. A
151
+ * caller uses this list twice: folded into its own whole-file failure
152
+ * reporting, and as the no-throw guard for the edge call — each resolver
153
+ * refuses on exactly the failures its `*ManifestFailures` twin reports from
154
+ * the same memoized model, so an empty list here makes
155
+ * `resolveDeclaredManifestEdges` unable to throw on this workspace. Both
156
+ * halves of that sentence live beside each other here so the identity cannot
157
+ * drift between a caller that forgets to report and one that skips the guard.
158
+ *
159
+ * `jvmIndexFailures` and `pythonUnmodelledFailures` are deliberately absent:
160
+ * no resolver here reads the JVM package index or refuses on Python's
161
+ * unmodelled posture, so neither is a condition of the no-throw guarantee —
162
+ * callers keep those lists flowing through their own funnels.
163
+ *
164
+ * @param {object} workspace The shared workspace-shaped object.
165
+ * @returns {{sourceFile: string, reason: string}[]}
166
+ */
167
+ export function resolveDeclaredManifestFailures(workspace) {
168
+ return [
169
+ ...mavenManifestFailures(workspace),
170
+ ...gradleManifestFailures(workspace),
171
+ ...dotnetManifestFailures(workspace),
172
+ ...dotnetIndexFailures(workspace),
173
+ ];
174
+ }
175
+
57
176
  /**
58
177
  * The Nx hook.
59
178
  *
@@ -81,10 +200,14 @@ export const createDependencies = (options, context) => {
81
200
  // attacker-supplied the moment a PR adds a tracked path. A tracked symlink
82
201
  // whose realpath leaves the workspace would draw a dependency edge from
83
202
  // outside bytes into `nx affected`'s graph; refusing (null) drops the
84
- // read so the file produces no edge (the `resolvePolyglotDependencies`
85
- // contract is a null read = no edge, not a throw this hook cannot exit
86
- // non-zero by design). A plugin that never resolves outside bytes stays
87
- // silent-green only when the bytes are really inside (`../containment.mjs`).
203
+ // read. For a SOURCE file an import walk reads, that is the end of it:
204
+ // null read = no edge, the file's own edges and nothing wider. For a
205
+ // MANIFEST or an index-feeding file (a pom, a settings or build file, a
206
+ // .csproj, a .java/.kt/.cs a name index reads), the reader records the
207
+ // null read as a could-not-complete failure and its resolver throws on
208
+ // it — the posture this file's header owns (#364). A plugin that never
209
+ // resolves outside bytes stays silent-green only when the bytes are
210
+ // really inside (`../containment.mjs`).
88
211
  if (containmentViolation(context.workspaceRoot, abs) !== null) return null;
89
212
  try {
90
213
  return readFileSync(abs, "utf8");
@@ -63,7 +63,7 @@ import { indexGaps } from "./workspace-index.mjs";
63
63
  * @param {string} request.text Its current contents — the editor's buffer, not
64
64
  * what is on disk. Diagnosing the saved file would answer a question nobody
65
65
  * asked while the developer is looking at their unsaved edit.
66
- * @param {{workspace: object, graph: object, skippedProjects?: object[], fileFailures?: object[], importSites?: object[], nativeMarker?: boolean, nativeModelFailure?: string|null, moonModelFailure?: string|null, nxModelFailure?: string|null, workspaceLayoutFailure?: string|null}} request.index
66
+ * @param {{workspace: object, graph: object, skippedProjects?: object[], fileFailures?: object[], importSites?: object[], duplicateProjects?: object[], nativeMarker?: boolean, nativeModelFailure?: string|null, moonModelFailure?: string|null, nxModelFailure?: string|null, workspaceLayoutFailure?: string|null}} request.index
67
67
  * From `./workspace-index.mjs`. `importSites` is the whole tree's retained
68
68
  * analysis output — the evidence half of the run below; absent (an index
69
69
  * built before it existed) reads as none, which degrades evidence, never a
@@ -43,7 +43,11 @@ import {
43
43
  readMoonOptions,
44
44
  readPluginOptions,
45
45
  } from "../options.mjs";
46
- import { moonMarkerAt } from "../providers/moon.mjs";
46
+ import {
47
+ MOON_ALT_WORKSPACE_MARKER,
48
+ MOON_WORKSPACE_MARKER,
49
+ moonMarkerAt,
50
+ } from "../providers/moon.mjs";
47
51
  import { ARCHKEEP_MODEL_FILE, loadNativeModel } from "../providers/native/model.mjs";
48
52
 
49
53
  import { readBoundaryConfig } from "./boundary-config.mjs";
@@ -82,6 +86,88 @@ const MFE_CONFIG_FILES = Object.freeze([
82
86
  "module-federation.config.ts",
83
87
  ]);
84
88
 
89
+ /**
90
+ * The manifests the non-TypeScript halves of the project graph are read from —
91
+ * the finite names each provider decides projects and edges by.
92
+ *
93
+ * This list carried none of them, and that was the defect #410 reports: the
94
+ * watched set described the TypeScript-side configuration only, while on a Go,
95
+ * Rust, Python, JVM or Moon workspace the graph itself is read from manifests.
96
+ * `textDocument/didSave` invalidates the whole session only when
97
+ * `touchesWatchedFile` matches this list, so saving an edited `go.mod`
98
+ * republished the manifest alone and left every open verdict answering from
99
+ * the graph as it was at index time — a module added to the workspace stayed
100
+ * invisible, the rules judged against a graph that no longer matched disk, and
101
+ * the affected files went on publishing `[]`, which reads as "no violation"
102
+ * (`../../../../AGENTS.md`). Only an unrelated watched edit or an editor
103
+ * restart rebuilt the graph.
104
+ *
105
+ * What reads each entry:
106
+ *
107
+ * - `go.mod` — the module path `../analysis/go.mjs`'s `resolveGoDependencies`
108
+ * resolves Go imports against, and the anchor
109
+ * `../providers/native/model.mjs`'s `DEFAULT_MANIFEST_NAMES` infers Go
110
+ * projects from. Renaming a module in the editor re-points or drops every
111
+ * edge into that project at the next index build; this entry is what makes
112
+ * the next index build happen.
113
+ * - `go.work` — the use list deciding which Go modules the workspace's own
114
+ * tooling builds (`../go-work.mjs`'s parser): the same module set the Go
115
+ * analysis models, so a save of it is a graph-shaping edit on the same terms
116
+ * as a `go.mod`.
117
+ * - `Cargo.toml` — crate identity, `[workspace]` membership and dependency
118
+ * declarations (`../analysis/rust.mjs`). One basename covers the root
119
+ * manifest and every member's, the reach `**\/package.json` already has.
120
+ * - `pyproject.toml` — the package names and `sources` tables
121
+ * `../analysis/python.mjs` resolves Python imports against.
122
+ * - `pom.xml` — Maven identity and dependency edges
123
+ * (`../analysis/jvm/maven.mjs`), and a project anchor in
124
+ * `DEFAULT_MANIFEST_NAMES`.
125
+ * - `settings.gradle` / `settings.gradle.kts` — the reactor structure
126
+ * `../analysis/jvm/gradle.mjs` reads Gradle identity from.
127
+ * - `build.gradle` / `build.gradle.kts` — the dependency declarations the same
128
+ * module reads Gradle EDGES from, which is why they sit beside the settings
129
+ * file that anchors the reactor rather than instead of it.
130
+ * - `moon.yml` and the two `workspace.yml` spellings — the Moon provider
131
+ * derives its whole graph from `moon project-graph --json`
132
+ * (`../providers/moon.mjs`'s `readProjectGraph`), which reads the tree's
133
+ * `moon.yml` files for ids, tags and `dependsOn`, under the workspace whose
134
+ * marker the two imported constants name. They are imported rather than
135
+ * spelled out so the watched set follows Moon's own directory conventions
136
+ * instead of a second copy of them.
137
+ *
138
+ * `*.csproj` is deliberately absent although `DEFAULT_MANIFEST_NAMES` carries
139
+ * it: that entry is a glob PATTERN, and the reach this list's entries get —
140
+ * `touchesWatchedFile`'s workspace-relative suffix comparison, mirrored by the
141
+ * `**\/<entry>` glob `registerFileWatchers` registers — covers exact names and
142
+ * directory-prefixed names only. A pattern entry would register `**\/*.csproj`,
143
+ * a watcher that fires, while the suffix match could never answer it: a
144
+ * notification path that looks covered and is not, which is the silent
145
+ * direction in the one list whose job is noticing. The list carries only names
146
+ * it can fire on.
147
+ *
148
+ * The entries are unconditional rather than provider-selected, for the same
149
+ * reason `archkeep.json` is: which manifests shape a root's graph can move
150
+ * under a running session — a `go.mod` appearing beside `nx.json` — and a
151
+ * watch list fixed to the provider the session started with is this same
152
+ * stale-graph hole one step out. The cost of an entry a TypeScript-only
153
+ * workspace never matches is one glob; the failure it buys out is a graph the
154
+ * editor never re-reads.
155
+ */
156
+ const POLYGLOT_GRAPH_MANIFESTS = Object.freeze([
157
+ "go.mod",
158
+ "go.work",
159
+ "Cargo.toml",
160
+ "pyproject.toml",
161
+ "pom.xml",
162
+ "settings.gradle",
163
+ "settings.gradle.kts",
164
+ "build.gradle",
165
+ "build.gradle.kts",
166
+ "moon.yml",
167
+ MOON_WORKSPACE_MARKER,
168
+ MOON_ALT_WORKSPACE_MARKER,
169
+ ]);
170
+
85
171
  /**
86
172
  * The files whose content changes the verdict for files that did not change,
87
173
  * given the options this session resolved.
@@ -150,6 +236,15 @@ const MFE_CONFIG_FILES = Object.freeze([
150
236
  * takes a project's NAME when its `project.json` states none, so an edit to it
151
237
  * can move a project in the graph and not only waive something in it.
152
238
  *
239
+ * The polyglot graph manifests are here for that same argument one level out,
240
+ * at the place a project comes into existence at all: on a Go, Rust, Python,
241
+ * JVM or Moon root the graph itself is read from manifests this list used to
242
+ * carry none of, so saving an edited one republished the manifest alone and
243
+ * left every verdict answering from the graph as it was at index time. Which
244
+ * names, and which module reads each, is `POLYGLOT_GRAPH_MANIFESTS`'s own
245
+ * comment — the roster is not restated here, because two rosters are two
246
+ * chances to drift.
247
+ *
153
248
  * Those three are PER-PROJECT paths rather than workspace-root singletons, and
154
249
  * they need no new mechanism for it: `registerFileWatchers` gives every entry
155
250
  * the glob `**\/<entry>` and `touchesWatchedFile` matches the same reach — an
@@ -209,6 +304,7 @@ export function watchedFilesFor(options, { unresolved = false } = {}) {
209
304
  ARCHKEEP_MODEL_FILE,
210
305
  PACKAGE_MANIFEST_FILE,
211
306
  ...MFE_CONFIG_FILES,
307
+ ...POLYGLOT_GRAPH_MANIFESTS,
212
308
  ]);
213
309
  }
214
310
 
@@ -126,7 +126,11 @@ import {
126
126
  createWorkspace,
127
127
  listTrackedFiles,
128
128
  } from "../workspace.mjs";
129
- import { buildDependencies } from "../providers/native/graph.mjs";
129
+ import { buildDependencies, mergeDeclaredEdges } from "../providers/native/graph.mjs";
130
+ import {
131
+ resolveDeclaredManifestEdges,
132
+ resolveDeclaredManifestFailures,
133
+ } from "../graph/create-dependencies.mjs";
130
134
  import { nodeTypeOf, PROJECT_CONFIG_FILE } from "../providers/native/discover.mjs";
131
135
  import { ARCHKEEP_MODEL_FILE } from "../providers/native/model.mjs";
132
136
  import { nativeProvider } from "../providers/native/index.mjs";
@@ -249,7 +253,14 @@ export function discoverProjects({ files, readFile }) {
249
253
  * reads it unguarded and an absent list is not the same fact as an empty one.
250
254
  *
251
255
  * @param {{name: string, root: string, config: object}[]} projects
252
- * @returns {Record<string, object>}
256
+ * @returns {{nodes: Record<string, object>, duplicateProjects: {name: string, roots: string[]}[]}}
257
+ * `duplicateProjects` names every name two or more projects resolved to and
258
+ * every root that claimed it (#375): a silent `nodes[name] = …` overwrite
259
+ * drops the shadowed project from the graph, its files match no root, and
260
+ * the editor publishes no diagnostics for real boundary crossings — the
261
+ * exact silent direction `../../../../AGENTS.md`'s invariant refuses. The
262
+ * first project still wins in `nodes` (the index stays usable); the caller
263
+ * publishes the collision through `indexGaps`.
253
264
  */
254
265
  export function buildNodes(projects) {
255
266
  // Null-prototype for the same reason `../providers/native/graph.mjs` and
@@ -264,14 +275,36 @@ export function buildNodes(projects) {
264
275
  // has no inherited `__proto__` accessor to collide with, so the name behaves
265
276
  // like every other project name: a real, own, enumerable entry.
266
277
  const nodes = Object.create(null);
278
+ /** @type {Map<string, string>} name → root of the first project that claimed it. */
279
+ const seenNames = new Map();
280
+ /** @type {Map<string, string[]>} name → every root that resolved to it, for names claimed twice or more. */
281
+ const duplicateMap = new Map();
282
+
267
283
  for (const { name, root, config } of projects) {
284
+ if (seenNames.has(name)) {
285
+ // Duplicate name detected — record it for loud reporting
286
+ if (!duplicateMap.has(name)) {
287
+ duplicateMap.set(name, [seenNames.get(name)]);
288
+ }
289
+ duplicateMap.get(name).push(root);
290
+ // Skip adding the duplicate to nodes — first project wins
291
+ continue;
292
+ }
293
+ seenNames.set(name, root);
268
294
  nodes[name] = {
269
295
  name,
270
296
  type: nodeTypeOf(name, config.projectType),
271
297
  data: { ...config, root, tags: config.tags ?? [] },
272
298
  };
273
299
  }
274
- return nodes;
300
+
301
+ // Convert the duplicate map to the expected output format
302
+ const duplicateProjects = [];
303
+ for (const [name, roots] of duplicateMap.entries()) {
304
+ duplicateProjects.push({ name, roots });
305
+ }
306
+
307
+ return { nodes, duplicateProjects };
275
308
  }
276
309
 
277
310
  /**
@@ -293,10 +326,12 @@ export function buildNodes(projects) {
293
326
  * filesystem existence (the marker is a directory, which `readFileAt`
294
327
  * cannot answer) and the provider's one-call graph reader, both injectable
295
328
  * for the same reason `listFiles` is.
296
- * @returns {{root: string, files: string[], workspace: object, graph: object, skippedProjects: object[], fileFailures: object[], importSites: object[], nativeMarker: boolean, nativeModelFailure: string|null, moonModelFailure: string|null, nxModelFailure: string|null, workspaceLayoutFailure: string|null}}
329
+ * @returns {{root: string, files: string[], workspace: object, graph: object, skippedProjects: object[], fileFailures: object[], importSites: object[], duplicateProjects: {name: string, roots: string[]}[], nativeMarker: boolean, nativeModelFailure: string|null, moonModelFailure: string|null, nxModelFailure: string|null, workspaceLayoutFailure: string|null}}
297
330
  * `importSites` is the whole tree's analysis output, retained past the graph
298
331
  * build for the rule engine's evidence index — see this module header's "Why
299
- * the whole tree's import sites stay on the index".
332
+ * the whole tree's import sites stay on the index". `duplicateProjects` is
333
+ * the Nx-shaped branch's own (#375): empty everywhere but that branch, which
334
+ * is the only one that resolves names from `project.json` files itself.
300
335
  * @throws {Error} when the file list cannot be obtained. Loud on purpose: an
301
336
  * index built from no files would put every file in no project, and a file in
302
337
  * no project has no boundary to cross — a clean report, produced by not
@@ -359,7 +394,7 @@ export function buildWorkspaceIndex({
359
394
  }
360
395
 
361
396
  const { projects, skipped } = discoverProjects({ files, readFile });
362
- const nodes = buildNodes(projects);
397
+ const { nodes, duplicateProjects } = buildNodes(projects);
363
398
  // The same Module Federation fact the CLI path computes, from the same
364
399
  // predicate (`../workspace.mjs` → `annotateMFERemotes`): a CLI verdict and an
365
400
  // editor verdict on the same import must match, and the field failing closed
@@ -394,6 +429,18 @@ export function buildWorkspaceIndex({
394
429
 
395
430
  const { importSites, fileFailures } = analyzeTrackedFiles({ files, workspace });
396
431
 
432
+ // The manifest track — Maven, Gradle, csproj — draws its edges here too.
433
+ // The CLI's Nx face gets them from the polyglot plugin inside Nx's own
434
+ // graph computation; this index has no plugin host, so it folds the same
435
+ // resolvers' records itself (`../graph/create-dependencies.mjs`), the way
436
+ // the native and Moon branches below do. The failure list doubles as the
437
+ // no-throw guard (that module's `resolveDeclaredManifestFailures` — each
438
+ // resolver refuses on exactly the failures its `*ManifestFailures` twin
439
+ // reports, same memoized model) and joins `fileFailures`, so a manifest
440
+ // the index cannot read is a named gap on every document rather than
441
+ // silently missing edges.
442
+ const manifestRefusalFailures = resolveDeclaredManifestFailures(workspace);
443
+
397
444
  // `nx.json`'s `workspaceLayout` reaches the rule engine here the same way
398
445
  // `../providers/nx.mjs`'s `readProjectGraph` merges it onto the graph it
399
446
  // returns to `cli.mjs` — see that function's own doc for why a merge step
@@ -454,21 +501,27 @@ export function buildWorkspaceIndex({
454
501
  `workspace, whose projects are declared in package.json, yields none`
455
502
  : null;
456
503
 
504
+ const graph = {
505
+ nodes,
506
+ dependencies: buildDependencies({ importSites, nodes, projectOf }),
507
+ ...(workspaceLayout === undefined ? {} : { workspaceLayout }),
508
+ };
509
+ if (manifestRefusalFailures.length === 0) {
510
+ mergeDeclaredEdges(graph, resolveDeclaredManifestEdges(workspace));
511
+ }
512
+
457
513
  return {
458
514
  root,
459
515
  files,
460
516
  workspace,
461
- graph: {
462
- nodes,
463
- dependencies: buildDependencies({ importSites, nodes, projectOf }),
464
- ...(workspaceLayout === undefined ? {} : { workspaceLayout }),
465
- },
517
+ graph,
466
518
  skippedProjects: skipped,
467
- fileFailures,
519
+ fileFailures: [...fileFailures, ...manifestRefusalFailures],
468
520
  // Retained past the graph build — the evidence half of `evaluate()`'s
469
521
  // input (`./diagnose.mjs` composes its run from these). See this module
470
522
  // header's "Why the whole tree's import sites stay on the index".
471
523
  importSites,
524
+ duplicateProjects,
472
525
  nativeMarker: false,
473
526
  nativeModelFailure: null,
474
527
  moonModelFailure: null,
@@ -570,6 +623,7 @@ function buildNativeWorkspaceIndex({ root, files, readFile, tsConfig }) {
570
623
  // was read — and an empty list is the honest answer, not a missing field
571
624
  // every consumer would have to guard against.
572
625
  importSites: [],
626
+ duplicateProjects: [],
573
627
  nativeMarker: true,
574
628
  nativeModelFailure: cause?.message ?? String(cause),
575
629
  moonModelFailure: null,
@@ -605,9 +659,26 @@ function buildNativeWorkspaceIndex({ root, files, readFile, tsConfig }) {
605
659
  // reports the same way: an unowned file is analyzed by nothing and judged
606
660
  // by nothing, which is exactly the hole `../providers/native/coverage.mjs`'s
607
661
  // own header names.
608
- const fileFailures = [...discovered.failures, ...analysisFailures];
662
+ const manifestRefusalFailures = resolveDeclaredManifestFailures(workspace);
663
+ const fileFailures = [
664
+ ...discovered.failures,
665
+ ...analysisFailures,
666
+ // The manifest track's own could-not-complete failures, in the same
667
+ // whole-file shape — an unreadable pom/build.gradle/.csproj is a file
668
+ // this index could not analyze, exactly like the ones above.
669
+ ...manifestRefusalFailures,
670
+ ];
609
671
 
610
672
  const graph = nativeProvider.buildGraph({ discovered, importSites });
673
+ // The manifest track's edges fold in after the import sites — no plugin
674
+ // host exists on this branch to draw them (`../graph/create-dependencies.mjs`
675
+ // names the faces that route through the Nx hook instead). The failure list
676
+ // above doubles as the no-throw guard: each resolver refuses on exactly the
677
+ // failures its `*ManifestFailures` twin reports from the same memoized model.
678
+ if (manifestRefusalFailures.length === 0) {
679
+ mergeDeclaredEdges(graph, resolveDeclaredManifestEdges(workspace));
680
+ }
681
+
611
682
  // The same Module Federation and `package.json` facts the Nx branch and
612
683
  // `../../cli.mjs`'s native branch both compute, from the same shared
613
684
  // functions (`../workspace.mjs`) — a CLI verdict and an editor verdict on
@@ -626,6 +697,7 @@ function buildNativeWorkspaceIndex({ root, files, readFile, tsConfig }) {
626
697
  // input (`./diagnose.mjs` composes its run from these), on this branch
627
698
  // exactly as on the Nx-shaped one.
628
699
  importSites,
700
+ duplicateProjects: [],
629
701
  nativeMarker: true,
630
702
  nativeModelFailure: null,
631
703
  moonModelFailure: null,
@@ -673,6 +745,7 @@ function buildMoonWorkspaceIndex({ root, files, readFile, tsConfig, readGraph })
673
745
  // empty list is the honest answer, not a missing field every consumer
674
746
  // would have to guard against.
675
747
  importSites: [],
748
+ duplicateProjects: [],
676
749
  nativeMarker: false,
677
750
  nativeModelFailure: null,
678
751
  moonModelFailure: cause?.message ?? String(cause),
@@ -704,17 +777,29 @@ function buildMoonWorkspaceIndex({ root, files, readFile, tsConfig, readGraph })
704
777
  projectOf: (file) => projectOfFile.get(file),
705
778
  });
706
779
 
780
+ // The manifest track folds in after the import sites, for the same
781
+ // no-face-qualifier reason the CLI's Moon branch states at its own fold —
782
+ // Moon has no plugin hook to draw declared edges either. The failure list
783
+ // doubles as the no-throw guard and joins `fileFailures`, so an
784
+ // unreadable pom/build.gradle/.csproj is a named gap rather than silently
785
+ // missing edges.
786
+ const manifestRefusalFailures = resolveDeclaredManifestFailures(workspace);
787
+ if (manifestRefusalFailures.length === 0) {
788
+ mergeDeclaredEdges(graph, resolveDeclaredManifestEdges(workspace));
789
+ }
790
+
707
791
  return {
708
792
  root,
709
793
  files,
710
794
  workspace,
711
795
  graph,
712
796
  skippedProjects: [],
713
- fileFailures,
797
+ fileFailures: [...fileFailures, ...manifestRefusalFailures],
714
798
  // Retained past the graph build — the evidence half of `evaluate()`'s
715
799
  // input (`./diagnose.mjs` composes its run from these), on this branch
716
800
  // exactly as on the other two.
717
801
  importSites,
802
+ duplicateProjects: [],
718
803
  nativeMarker: false,
719
804
  nativeModelFailure: null,
720
805
  moonModelFailure: null,
@@ -799,12 +884,13 @@ function buildMoonWorkspaceIndex({ root, files, readFile, tsConfig, readGraph })
799
884
  *
800
885
  * Each sentence names a path, so the diagnostic says which file to open.
801
886
  *
802
- * @param {{skippedProjects?: {file: string, reason: string}[], fileFailures?: {sourceFile: string, reason: string}[], nativeModelFailure?: string|null, moonModelFailure?: string|null, nxModelFailure?: string|null, workspaceLayoutFailure?: string|null}} index
887
+ * @param {{skippedProjects?: {file: string, reason: string}[], fileFailures?: {sourceFile: string, reason: string}[], duplicateProjects?: {name: string, roots: string[]}[], nativeModelFailure?: string|null, moonModelFailure?: string|null, nxModelFailure?: string|null, workspaceLayoutFailure?: string|null}} index
803
888
  * @returns {string[]}
804
889
  */
805
890
  export function indexGaps({
806
891
  skippedProjects = [],
807
892
  fileFailures = [],
893
+ duplicateProjects = [],
808
894
  nativeModelFailure = null,
809
895
  moonModelFailure = null,
810
896
  nxModelFailure = null,
@@ -840,6 +926,11 @@ export function indexGaps({
840
926
  `so imports across a non-default apps/libs boundary are judged against the default layout ` +
841
927
  `instead of the one this workspace declared`,
842
928
  ]),
929
+ ...duplicateProjects.map(
930
+ ({ name, roots }) =>
931
+ `multiple projects resolve to the name '${name}': ${roots.map((r) => `'${r}'`).join(" and ")} — ` +
932
+ `only the first is indexed; files in the shadowed projects will show no diagnostics`,
933
+ ),
843
934
  ...skippedProjects.map(
844
935
  ({ file, reason }) =>
845
936
  `${file} ${firstLine(reason)}, so that project is missing from the graph entirely`,
package/src/options.mjs CHANGED
@@ -182,12 +182,20 @@ export function resolveOptions(rawOptions) {
182
182
  * This plugin's own entry in an `nx.json` `plugins` array, in either form Nx
183
183
  * accepts: a bare string, or `{plugin, options}`.
184
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.
185
+ * Matched by exact package specifier or relative/aliased path, never by bare
186
+ * suffix. The three legitimate spellings this plugin recognizes are:
187
+ *
188
+ * - `@ecoma-io/archkeep/nx` the full package specifier from npm
189
+ * - `archkeep/nx` the bare shorthand (accepted for backward compatibility)
190
+ * - `./packages/archkeep/nx.mjs`, `../../node_modules/@ecoma-io/archkeep/nx`,
191
+ * or other relative/aliased spellings of the same package
192
+ *
193
+ * A third-party plugin whose specifier happens to end with `/archkeep/nx`
194
+ * (e.g., `vendor-archkeep/nx`, `@other-scope/archkeep/nx`) is NOT claimed:
195
+ * such a package has its own options schema and this plugin must neither
196
+ * validate it nor read it. The suffix-only match that existed before would
197
+ * claim those options, validate them against this plugin's schema, and throw
198
+ * on any unknown key — a false violation on a stranger's configuration.
191
199
  *
192
200
  * The bare package specifier (`@ecoma-io/archkeep`, with no `/nx`) does NOT
193
201
  * match: that resolves to the engine entry, which exports neither `name` nor
@@ -202,7 +210,22 @@ function namesThisPlugin(entry) {
202
210
  typeof entry === "string" ? entry : /** @type {{ plugin?: unknown }} */ (entry)?.plugin;
203
211
  if (typeof specifier !== "string") return false;
204
212
  const withoutExt = specifier.replace(/\.mjs$/u, "");
205
- return withoutExt === "archkeep/nx" || withoutExt.endsWith("/archkeep/nx");
213
+
214
+ // Exact match against this package's own specifier
215
+ if (withoutExt === "@ecoma-io/archkeep/nx" || withoutExt === "archkeep/nx") {
216
+ return true;
217
+ }
218
+
219
+ // Match relative/aliased spellings of the same package (e.g.,
220
+ // `./packages/archkeep/nx.mjs`, `../../node_modules/@ecoma-io/archkeep/nx`).
221
+ // These paths start with `./` or `../` and end with `/archkeep/nx`,
222
+ // which distinguishes them from third-party packages like `vendor-archkeep/nx`.
223
+ const isRelativePath = withoutExt.startsWith("./") || withoutExt.startsWith("../");
224
+ if (isRelativePath && withoutExt.endsWith("/archkeep/nx")) {
225
+ return true;
226
+ }
227
+
228
+ return false;
206
229
  }
207
230
 
208
231
  /**