@ecoma-io/archkeep 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,394 @@
1
+ /**
2
+ * go.work drift — the workspace-level check that a developer's `go` and the Nx
3
+ * graph select the same set of Go modules.
4
+ *
5
+ * A `go.work` at the workspace root decides, through its `use` directives,
6
+ * which modules `go build` and gopls load on a developer's machine. The Nx
7
+ * graph covers every project carrying `<projectRoot>/go.mod`, and nothing ties
8
+ * the two lists together: a project whose directory is missing from `use`
9
+ * builds differently on the dev machine than in CI, and the divergence is
10
+ * byte-for-byte invisible — both sides look fine on their own. That silence is
11
+ * the state this package exists to end, so both directions of drift are
12
+ * findings and `../cli.mjs` fails the run on them the way it fails on a
13
+ * boundary violation.
14
+ *
15
+ * The file is read statically, never by invoking `go` — the same refusal every
16
+ * resolver here holds (project `AGENTS.md`, "Static analysis by design"), for
17
+ * the same reason: this runs on machines that never installed the toolchain.
18
+ * Only a TRACKED `go.work` at the workspace root is read, which is the
19
+ * established keying — a workspace without one pays nothing and the report
20
+ * says nothing about it. There is no switch.
21
+ *
22
+ * **This check runs on the CLI surface only, not in the language server.** A
23
+ * drift finding describes the workspace — a list in one file against a graph —
24
+ * not any document being edited. The LSP publishes per-document diagnostics,
25
+ * and pinning a workspace-level finding to whichever file happens to be open
26
+ * would put the report somewhere its fix is not.
27
+ *
28
+ * The grammar this reads is the documented one (go.dev/ref/mod, "Workspaces" —
29
+ * `go.work` shares `go.mod`'s lexical elements): `//` comments and no block
30
+ * comments, interpreted strings whose `\X` escape is replaced by `X`, raw
31
+ * backquoted strings, identifiers and strings interchangeable, one directive
32
+ * per line, and a keyword factored over a `( … )` block. `use` takes one
33
+ * directory path, absolute or relative to the directory holding `go.work`.
34
+ *
35
+ * Known parse limits, deliberate and pinned by tests. The Go analyzers' limits
36
+ * each err toward a spurious record; a manifest read errs the same way a
37
+ * malformed boundary config does — it stops the run loudly — because here the
38
+ * silent direction is an EMPTY use list, and a malformed `go.work` read as "no
39
+ * drift" is exactly the false green this check exists to remove:
40
+ *
41
+ * - **A `go.work` this parser cannot read throws, never returns fewer
42
+ * entries.** An unterminated block or string, a `use` without a path, stray
43
+ * tokens, or a keyword outside go.work's five (below) each name their line;
44
+ * `../cli.mjs` turns the throw into a whole-file failure, so the run exits 3
45
+ * (no verdict), not 0.
46
+ * - **A string may not span a line.** Legal `go.mod` syntax keeps strings on
47
+ * one line; a raw string left open runs to the end of the line and throws as
48
+ * unterminated rather than silently swallowing the directives below it.
49
+ * - **Only go.work's own five directive keywords are skipped as unread,
50
+ * blocks included.** `use` is read; `go`, `toolchain`, `godebug` and
51
+ * `replace` are recognized and passed over without being mistaken for
52
+ * paths. Any other keyword throws — verified against `go work edit` itself
53
+ * (go1.24.7), which rejects one as `"unknown directive"` rather than
54
+ * skipping it, so text that is not a go.work file at all (an HTML error
55
+ * page from a bad download, a truncated checkout) is read as unparseable,
56
+ * never as zero `use` entries.
57
+ * - **Paths are compared with `/` separators.** A `\`-separated Windows path
58
+ * or a drive-letter absolute path matches no project root and surfaces as a
59
+ * drift finding naming the text the file really contains — loud, never
60
+ * silent.
61
+ *
62
+ * The comparison follows the graph's model — one module per project root,
63
+ * `<projectRoot>/go.mod` (project `AGENTS.md`, "One module/crate/package per
64
+ * project root"). Consequences, both deliberate: a nested `go.mod` is never
65
+ * REQUIRED to appear in `use`, because the graph does not model it either; but
66
+ * a `use` entry that names one is reported, because that is the moment the
67
+ * developer's build and the graph demonstrably diverge — the module builds
68
+ * locally while `nx affected` and the boundary check never see it.
69
+ */
70
+ import { isAbsolute, posix, relative, sep } from "node:path";
71
+
72
+ import { projectOwning } from "./analysis/source-util.mjs";
73
+
74
+ /**
75
+ * What each drift finding means — one entry per `messageId` a finding can
76
+ * carry. `../report/sarif.mjs` derives its rule descriptors from this table,
77
+ * the same arrangement it has with `../rules/messages.mjs`, so a kind added
78
+ * here cannot be nameless in a code-scanning upload.
79
+ */
80
+ export const GO_WORK_MESSAGES = Object.freeze({
81
+ goWorkMissingUse:
82
+ "A project's go.mod is not in go.work's use list: a developer's go build and gopls skip a " +
83
+ "module the Nx graph covers, so dev machines and CI select different module sets.",
84
+ goWorkStaleUse:
85
+ "A go.work use entry names a directory with no tracked go.mod: go commands fail on developer " +
86
+ "machines while CI, which never reads go.work, stays green.",
87
+ goWorkUnmodeledUse:
88
+ "A go.work use entry names a module the Nx graph does not model: it builds on developer " +
89
+ "machines while nx affected and the boundary check never see it.",
90
+ goWorkOutsideUse:
91
+ "A go.work use entry points outside the workspace: developer builds include a module no run " +
92
+ "over this workspace can cover.",
93
+ });
94
+
95
+ export const GO_WORK_MESSAGE_IDS = Object.freeze(Object.keys(GO_WORK_MESSAGES));
96
+
97
+ /** A parse failure that names its line, so the failure record is actionable. */
98
+ const parseError = (line, reason) => new Error(`go.work:${line}: ${reason}`);
99
+
100
+ /** The characters that end an identifier token — go.mod's own boundaries. */
101
+ const IDENTIFIER_BOUNDARY = new Set([" ", "\t", "\r", '"', "`", "(", ")"]);
102
+
103
+ /**
104
+ * One line of go.work as tokens: `(`/`)` punctuation, identifiers, and the
105
+ * unquoted values of interpreted and raw strings. A `//` between tokens starts
106
+ * a comment that runs to the end of the line; inside an identifier a `/` is an
107
+ * ordinary path character, which is how `./a` and `example.com/x` stay whole.
108
+ *
109
+ * @param {string} line One line, without its newline.
110
+ * @param {number} lineNumber 1-based, for error messages.
111
+ * @returns {{ value: string, column: number, punct: boolean }[]} `column` is
112
+ * 1-based and points at the token's first character in the line.
113
+ * @throws {Error} on an unterminated string — a string left open would
114
+ * otherwise swallow the rest of the line silently.
115
+ */
116
+ export function tokenizeGoWorkLine(line, lineNumber) {
117
+ const tokens = [];
118
+ let at = 0;
119
+ while (at < line.length) {
120
+ const char = line[at];
121
+ if (char === " " || char === "\t" || char === "\r") {
122
+ at += 1;
123
+ } else if (char === "/" && line[at + 1] === "/") {
124
+ break;
125
+ } else if (char === "(" || char === ")") {
126
+ tokens.push({ value: char, column: at + 1, punct: true });
127
+ at += 1;
128
+ } else if (char === '"') {
129
+ // Interpreted string: each `\X` escape is replaced by `X`, which is the
130
+ // documented unquoting — `\"` is `"`, and `\n` is the letter n.
131
+ let value = "";
132
+ let cursor = at + 1;
133
+ while (cursor < line.length && line[cursor] !== '"') {
134
+ if (line[cursor] === "\\" && cursor + 1 < line.length) {
135
+ value += line[cursor + 1];
136
+ cursor += 2;
137
+ } else {
138
+ value += line[cursor];
139
+ cursor += 1;
140
+ }
141
+ }
142
+ if (cursor >= line.length) {
143
+ throw parseError(lineNumber, "unterminated quoted string");
144
+ }
145
+ tokens.push({ value, column: at + 1, punct: false });
146
+ at = cursor + 1;
147
+ } else if (char === "`") {
148
+ // Raw string: no escapes. One that never closes would swallow every
149
+ // directive after it, so it throws instead — the header's second limit.
150
+ const close = line.indexOf("`", at + 1);
151
+ if (close === -1) {
152
+ throw parseError(lineNumber, "unterminated raw string (strings may not span a line)");
153
+ }
154
+ tokens.push({ value: line.slice(at + 1, close), column: at + 1, punct: false });
155
+ at = close + 1;
156
+ } else {
157
+ let end = at;
158
+ while (end < line.length && !IDENTIFIER_BOUNDARY.has(line[end])) end += 1;
159
+ tokens.push({ value: line.slice(at, end), column: at + 1, punct: false });
160
+ at = end;
161
+ }
162
+ }
163
+ return tokens;
164
+ }
165
+
166
+ /**
167
+ * go.work's own directive keywords — verified against `go work edit` itself
168
+ * (go1.24.7): a keyword outside this set is `go`'s own `"unknown directive"`
169
+ * parse error, not a directive the format tolerates. `use` is the only one
170
+ * this parser reads; the other four are recognized so their lines (and, for
171
+ * `replace`, their block bodies) can be skipped without being mistaken for
172
+ * paths — the header's "only five keywords are skipped as unread" limit.
173
+ */
174
+ const KNOWN_DIRECTIVES = new Set(["go", "toolchain", "use", "replace", "godebug"]);
175
+
176
+ /**
177
+ * Every `use` directive in a go.work, single-line and block form, with the
178
+ * position of each path as written.
179
+ *
180
+ * Malformed input **throws** rather than returning what was readable so far:
181
+ * this list's emptiness is the fact the drift check turns on, and a use list
182
+ * truncated by a parse problem would read as projects the developer removed —
183
+ * or, worse, as no drift at all (the header's first limit). A keyword outside
184
+ * go.work's own five throws for the same reason: text that only coincidentally
185
+ * tokenizes (an HTML error page, a truncated download) must not read as a
186
+ * go.work with zero `use` entries.
187
+ *
188
+ * @param {string} text The go.work file's contents.
189
+ * @returns {{ path: string, line: number, column: number }[]} In file order;
190
+ * `path` is unquoted but not normalized, so a finding can cite it as written.
191
+ * @throws {Error} naming the offending line.
192
+ */
193
+ export function parseGoWorkUse(text) {
194
+ const uses = [];
195
+ /** @type {{ keyword: string, line: number }|null} */
196
+ let block = null;
197
+ const lines = text.split("\n");
198
+ for (let index = 0; index < lines.length; index++) {
199
+ const lineNumber = index + 1;
200
+ const tokens = tokenizeGoWorkLine(lines[index], lineNumber);
201
+ if (tokens.length === 0) continue;
202
+
203
+ if (block !== null) {
204
+ if (tokens[0].punct && tokens[0].value === ")") {
205
+ if (tokens.length > 1) {
206
+ throw parseError(lineNumber, "unexpected text after the ')' closing a block");
207
+ }
208
+ block = null;
209
+ } else if (block.keyword !== "use") {
210
+ // Another directive's block body — skipped, never read as paths.
211
+ } else if (tokens.length !== 1 || tokens[0].punct) {
212
+ throw parseError(lineNumber, "a use block line must hold exactly one directory path");
213
+ } else {
214
+ uses.push({ path: tokens[0].value, line: lineNumber, column: tokens[0].column });
215
+ }
216
+ continue;
217
+ }
218
+
219
+ const [keyword, ...rest] = tokens;
220
+ if (keyword.punct) {
221
+ throw parseError(lineNumber, `unexpected '${keyword.value}' outside any block`);
222
+ }
223
+ if (rest.length === 1 && rest[0].punct && rest[0].value === "(") {
224
+ if (!KNOWN_DIRECTIVES.has(keyword.value)) {
225
+ throw parseError(lineNumber, `unknown block type: ${keyword.value}`);
226
+ }
227
+ block = { keyword: keyword.value, line: lineNumber };
228
+ continue;
229
+ }
230
+ if (keyword.value !== "use") {
231
+ if (!KNOWN_DIRECTIVES.has(keyword.value)) {
232
+ throw parseError(lineNumber, `unknown directive: ${keyword.value}`);
233
+ }
234
+ continue; // go, toolchain, replace, godebug one-liners
235
+ }
236
+ if (rest.length !== 1 || rest[0].punct) {
237
+ throw parseError(
238
+ lineNumber,
239
+ rest.length === 0
240
+ ? "'use' needs a directory path"
241
+ : "'use' takes exactly one directory path (the block form puts '(' at the end of the line)",
242
+ );
243
+ }
244
+ uses.push({ path: rest[0].value, line: lineNumber, column: rest[0].column });
245
+ }
246
+ if (block !== null) {
247
+ throw parseError(block.line, `'${block.keyword} (' block is never closed`);
248
+ }
249
+ return uses;
250
+ }
251
+
252
+ /** Nx spells the workspace-root project's root as "." where files use "". */
253
+ const normalizeProjectRoot = (root) => (root === "." || root == null ? "" : root);
254
+
255
+ /** A directory for display, with the one root spelling that needs words. */
256
+ const displayDir = (dir) => (dir === "" ? "the workspace root" : dir);
257
+
258
+ /** The `use` argument that would name `dir`, for a fix the reader can paste. */
259
+ const useSpelling = (dir) => (dir === "" ? "." : `./${dir}`);
260
+
261
+ /**
262
+ * A use path as a workspace-relative directory, or `null` when it points
263
+ * outside the workspace. `go.work` sits at the workspace root, so a relative
264
+ * path is relative to exactly that directory.
265
+ *
266
+ * @param {string} usePath As written (unquoted).
267
+ * @param {string} workspaceRoot Absolute.
268
+ * @returns {string|null} `""` for the root itself.
269
+ */
270
+ function useDirectory(usePath, workspaceRoot) {
271
+ if (isAbsolute(usePath)) {
272
+ const rel = relative(workspaceRoot, usePath).split(sep).join("/");
273
+ return rel === ".." || rel.startsWith("../") || isAbsolute(rel) ? null : rel;
274
+ }
275
+ let dir = posix.normalize(usePath);
276
+ while (dir.endsWith("/")) dir = dir.slice(0, -1);
277
+ if (dir === ".") dir = "";
278
+ return dir === ".." || dir.startsWith("../") ? null : dir;
279
+ }
280
+
281
+ /**
282
+ * The drift between a go.work use list and the graph's Go module projects —
283
+ * pure, facts as arguments, so the tests need no filesystem.
284
+ *
285
+ * Both directions are findings, because both mean the developer's `go` and CI
286
+ * disagree about which modules exist:
287
+ *
288
+ * - **`goWorkMissingUse`** — a project with a tracked `<projectRoot>/go.mod`
289
+ * whose directory no `use` entry names. The graph judges it; the developer's
290
+ * build skips it.
291
+ * - **`goWorkStaleUse`** — a `use` entry with no tracked `go.mod` at its
292
+ * directory. `go` itself refuses such an entry, so the developer's build is
293
+ * broken (or the `go.mod` is untracked, which reads the same to a tool whose
294
+ * file set is `git ls-files`) while CI never notices.
295
+ * - **`goWorkUnmodeledUse`** — a `use` entry whose directory has a tracked
296
+ * `go.mod` but is no project root: a module nested inside a project, or
297
+ * inside no project at all. It builds locally; the graph draws no edge and
298
+ * no boundary verdict for it (the header's modeling note).
299
+ * - **`goWorkOutsideUse`** — a `use` entry above the workspace root. Whatever
300
+ * it builds, no run over this workspace can cover it.
301
+ *
302
+ * @param {{ uses: { path: string, line: number, column: number }[],
303
+ * workspaceRoot: string,
304
+ * projects: { name: string, root: string }[],
305
+ * files: string[] }} facts `files` is the tracked-file list; `projects`
306
+ * comes from the graph.
307
+ * @returns {{ findings: { messageId: string, file: string, line: number|null,
308
+ * column: number|null, message: string, directory: string|null,
309
+ * project: string|null }[], moduleProjects: number }} `moduleProjects`
310
+ * counts the go.mod-carrying projects, so the report can state the check's
311
+ * coverage beside its verdict.
312
+ */
313
+ export function compareGoWork({ uses, workspaceRoot, projects, files }) {
314
+ const tracked = new Set(files);
315
+ const goModAt = (dir) => (dir === "" ? "go.mod" : `${dir}/go.mod`);
316
+ const normalizedProjects = projects.map((project) => ({
317
+ name: project.name,
318
+ root: normalizeProjectRoot(project.root),
319
+ }));
320
+ const moduleProjects = normalizedProjects.filter((project) => tracked.has(goModAt(project.root)));
321
+ const projectByRoot = new Map(moduleProjects.map((project) => [project.root, project]));
322
+
323
+ const findings = [];
324
+ const usedDirs = new Set();
325
+ for (const use of uses) {
326
+ const site = { file: "go.work", line: use.line, column: use.column };
327
+ const dir = useDirectory(use.path, workspaceRoot);
328
+ if (dir === null) {
329
+ findings.push({
330
+ messageId: "goWorkOutsideUse",
331
+ ...site,
332
+ directory: null,
333
+ project: null,
334
+ message:
335
+ `go.work uses "${use.path}", which lies outside the workspace — a developer's go ` +
336
+ `build includes a module that no graph, affected run, or boundary check over this ` +
337
+ `workspace can ever cover.`,
338
+ });
339
+ continue;
340
+ }
341
+ usedDirs.add(dir);
342
+ if (projectByRoot.has(dir)) continue;
343
+ if (tracked.has(goModAt(dir))) {
344
+ const owner = projectOwning(normalizedProjects, goModAt(dir));
345
+ findings.push({
346
+ messageId: "goWorkUnmodeledUse",
347
+ ...site,
348
+ directory: dir,
349
+ project: owner?.name ?? null,
350
+ message:
351
+ `go.work uses "${use.path}", whose go.mod is ` +
352
+ (owner
353
+ ? `nested inside project "${owner.name}" — the graph models one module per project ` +
354
+ `root (<projectRoot>/go.mod), so this module builds on developer machines while ` +
355
+ `nx affected and the boundary check draw no edges for it. Split it into its own ` +
356
+ `Nx project.`
357
+ : `inside no Nx project — the module builds on developer machines while nx affected ` +
358
+ `and the boundary check never see it. Declare a project there so the graph ` +
359
+ `models it.`),
360
+ });
361
+ } else {
362
+ findings.push({
363
+ messageId: "goWorkStaleUse",
364
+ ...site,
365
+ directory: dir,
366
+ project: null,
367
+ message:
368
+ `go.work uses "${use.path}", but the workspace has no tracked go.mod at ` +
369
+ `${displayDir(dir)} — the entry names a module this workspace does not contain ` +
370
+ `(moved, deleted, or untracked), so go commands fail on developer machines while CI, ` +
371
+ `which never reads go.work, stays green. Remove the entry, or track the module.`,
372
+ });
373
+ }
374
+ }
375
+
376
+ for (const project of moduleProjects) {
377
+ if (usedDirs.has(project.root)) continue;
378
+ findings.push({
379
+ messageId: "goWorkMissingUse",
380
+ file: "go.work",
381
+ line: null,
382
+ column: null,
383
+ directory: project.root,
384
+ project: project.name,
385
+ message:
386
+ `go.work does not use ${displayDir(project.root)} — the graph covers project ` +
387
+ `"${project.name}" (${goModAt(project.root)}), but a developer's go build and gopls ` +
388
+ `skip it, so dev machines and CI select different module sets. Add ` +
389
+ `"use ${useSpelling(project.root)}" to go.work.`,
390
+ });
391
+ }
392
+
393
+ return { findings, moduleProjects: moduleProjects.length };
394
+ }