@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
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The dotnet namespace index — the content-derived `namespace → project` map
3
+ * every C# resolution reads through.
4
+ *
5
+ * C#, like Java, does not enforce directory = namespace: the compiler accepts
6
+ * any type in any file regardless of folder layout, and only conventions
7
+ * suggest otherwise. A resolver that derived importable names from directory
8
+ * layout would answer confidently about a tree it had misread — the failure
9
+ * mode `../jvm/packages.mjs` avoids by reading the declaration in the file,
10
+ * and this index reads it the same way.
11
+ *
12
+ * One structural difference is deliberate. A JVM package split across two
13
+ * projects is pathological; a C# namespace spanning assemblies is ordinary —
14
+ * partial ownership is how layered solutions grow. The index therefore
15
+ * supports MULTIPLE owners per declared name without treating the second one
16
+ * as a defect, and resolution (`./resolve.mjs`) decides what several owners at
17
+ * the deepest matched prefix mean: an ambiguity failure naming every claimant,
18
+ * never a guess. The index stays a fact-record; the judgment lives one module
19
+ * over.
20
+ *
21
+ * Unlike the JVM package declaration — one per compilation unit, confined to
22
+ * the header — C# allows MANY namespace blocks per file (including reopening
23
+ * the same name), so the parser returns every declaration, and the builder
24
+ * deduplicates per file rather than taking the first match.
25
+ *
26
+ * Reads are injected (`workspace.filesOf` / `workspace.readFile`) and memoized
27
+ * per workspace object through `perWorkspace`, so a whole-tree run builds the
28
+ * index once no matter how many files ask.
29
+ */
30
+ import { fileFailure, perWorkspace } from "../source-util.mjs";
31
+ import { maskCSharpComments } from "./mask.mjs";
32
+
33
+ /**
34
+ * Every `namespace` declaration of an already-masked C# source, with the
35
+ * offset of each name itself.
36
+ *
37
+ * Both written forms match:
38
+ *
39
+ * namespace X.Y.Z { … } block form — anything may follow the brace
40
+ * namespace X.Y.Z; file-scoped form (C# 10+)
41
+ *
42
+ * The name must start on the declaration's own line (anchored behind a line
43
+ * head, optionally after a UTF-8 BOM, which would otherwise hide a first-line
44
+ * declaration and drop the whole file out of the index — the silent direction
45
+ * `../jvm/packages.mjs` refuses for the same byte). Spaces around dots are
46
+ * tolerated the way the JVM declaration tolerates them, because `namespace X
47
+ * . Y` compiles. The terminator is `;`, or a lookahead over whitespace and
48
+ * newlines for `{` — a brace on its own line below the declaration still
49
+ * opens a block form.
50
+ *
51
+ * A UTF-8 BOM is matched, not stripped, so every offset returned indexes the
52
+ * original text directly.
53
+ *
54
+ * `namespace` is a reserved word, so in masked code the keyword only ever
55
+ * introduces a declaration; a verbatim identifier (`var @namespace = 1;`)
56
+ * keeps the `@` between the line head and the keyword and cannot match.
57
+ *
58
+ * @param {string} maskedText Comment-and-literal-blanked source (same length
59
+ * as the original), so offsets index the original text.
60
+ * @returns {{ name: string, offset: number }[]} Every declaration, in source
61
+ * order; empty for a file outside any namespace (the global namespace),
62
+ * which declares no name the index can carry.
63
+ */
64
+ export function parseCSharpNamespaceDeclarations(maskedText) {
65
+ const CS_NAMESPACE_DECLARATION =
66
+ /(?:^\uFEFF?|\n)[ \t]*namespace[ \t]+([\p{L}_][\p{L}\p{Nd}_]*(?:[ \t]*\.[ \t]*[\p{L}_][\p{L}\p{Nd}_]*)*)[ \t]*(?:;|(?=[{;\r\n]|$))/gu;
67
+ const declarations = [];
68
+ for (const match of maskedText.matchAll(CS_NAMESPACE_DECLARATION)) {
69
+ const name = match[1].replace(/[ \t]*\.[ \t]*/g, ".");
70
+ declarations.push({
71
+ name,
72
+ offset: match.index + match[0].indexOf(match[1]),
73
+ });
74
+ }
75
+ return declarations;
76
+ }
77
+
78
+ const MASK_BY_EXTENSION = { ".cs": maskCSharpComments };
79
+
80
+ /** The mask for a dotnet source file's extension, or `undefined` elsewhere. */
81
+ const maskFor = (file) => MASK_BY_EXTENSION[file.slice(file.lastIndexOf("."))];
82
+
83
+ /**
84
+ * Build the index: every tracked `.cs` source's namespaces, attributed by
85
+ * longest project root. Returns the map keyed by exact declared dotted name,
86
+ * each entry listing `{ project, file }` pairs in project order — one pair
87
+ * per FILE even when a file declares the same namespace twice, because a
88
+ * reopened block is one declaration's worth of ownership, not two — beside
89
+ * one whole-file failure per `.cs` source that could not be read: a file
90
+ * dropped from the index silently would make every import of its namespaces
91
+ * classify external, a first-party crossing wearing an external face, with
92
+ * nothing anywhere naming why (`../contract.md`'s I/O law).
93
+ *
94
+ * @param {object} workspace `{ projects, filesOf(name), readFile(path) }`
95
+ * @returns {{ byName: Map<string, { project: string, file: string }[]>,
96
+ * failures: { sourceFile: string, line: null, column: null, reason: string }[] }}
97
+ */
98
+ function buildCsharpNamespaceIndex(workspace) {
99
+ const byName = new Map();
100
+ const failures = [];
101
+ for (const project of workspace.projects) {
102
+ for (const file of workspace.filesOf(project.name)) {
103
+ if (!maskFor(file)) continue;
104
+ const text = workspace.readFile(file);
105
+ if (text === null || text === undefined) {
106
+ failures.push(fileFailure(file, "C# source could not be read for the namespace index"));
107
+ continue;
108
+ }
109
+ for (const declared of parseCSharpNamespaceDeclarations(maskCSharpComments(text))) {
110
+ const owners = byName.get(declared.name) ?? [];
111
+ if (!owners.some((owner) => owner.file === file)) {
112
+ owners.push({ project: project.name, file });
113
+ byName.set(declared.name, owners);
114
+ }
115
+ }
116
+ }
117
+ }
118
+ return { byName, failures };
119
+ }
120
+
121
+ /**
122
+ * The workspace's namespace index, built once per workspace object. Every
123
+ * consumer — the analyzer and the graph resolver — reads resolution through
124
+ * this one map, so the layers can never disagree about who owns a name.
125
+ */
126
+ export const csharpNamespaceIndex = perWorkspace(buildCsharpNamespaceIndex);
127
+
128
+ /**
129
+ * Whole-file failures for every `.cs` source the index could not read — the
130
+ * funnel `../../commands/context.mjs` merges beside the manifest failures, so
131
+ * an unreadable source refuses the verdict (exit 3) instead of quietly
132
+ * degrading every importer of its namespaces to external.
133
+ *
134
+ * @param {object} workspace
135
+ * @returns {{ sourceFile: string, line: null, column: null, reason: string }[]}
136
+ */
137
+ export function dotnetIndexFailures(workspace) {
138
+ return csharpNamespaceIndex(workspace).failures;
139
+ }
140
+
141
+ /**
142
+ * Longest-prefix resolution over the index — the single answer both layers
143
+ * read a specifier with, matching the discipline `../jvm/packages.mjs`'s
144
+ * `resolveJvmPackagePrefix` states: walk from the full name toward its head,
145
+ * stop at the first (deepest) prefix the index knows, and report the owner
146
+ * set found there. A shallower match under a deeper hit is invisible by
147
+ * construction; a nested-namespace project is a different project, and a
148
+ * first-shallow-match answer would name its parent.
149
+ *
150
+ * Mirrored here rather than imported across family directories: the walk is
151
+ * eight lines, and a cross-family import would couple `dotnet/`'s resolution
152
+ * to a module whose headers argue Java and Kotlin. The DISCIPLINE is shared
153
+ * and cited; the spelling stays local.
154
+ *
155
+ * @param {string} specifier Dotted name as written after `using` (aliases
156
+ * already stripped by the caller).
157
+ * @param {Map<string, { project: string, file: string }[]>} index As built
158
+ * by `csharpNamespaceIndex`.
159
+ * @returns {{ owners: { project: string, file: string }[], prefix: string }}
160
+ * | null `null` names no known prefix — the specifier is outside every
161
+ * tracked project (a framework namespace, a NuGet package's namespace, or
162
+ * first-party code this run cannot see).
163
+ */
164
+ export function resolveDottedNamespacePrefix(specifier, index) {
165
+ const parts = specifier.split(".");
166
+ for (let depth = parts.length; depth >= 1; depth--) {
167
+ const prefix = parts.slice(0, depth).join(".");
168
+ const owners = index.get(prefix);
169
+ if (owners) return { owners, prefix };
170
+ }
171
+ return null;
172
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * C# specifier resolution — turning an imported dotted name into the record
3
+ * the analysis contract carries (`../contract.md`): a project target, or an
4
+ * ambiguity the caller reports rather than guesses past, or an external
5
+ * classification.
6
+ *
7
+ * The classification order is shorter than the JVM's, on purpose:
8
+ *
9
+ * 1. **Workspace namespaces first.** A name (or a prefix of it) some tracked
10
+ * project declares resolves through `resolveDottedNamespacePrefix`'s
11
+ * longest-prefix walk. First-party beats every later answer: misreading
12
+ * first-party as external is the silent direction, and no rule can see a
13
+ * crossing the resolver called a library.
14
+ * 2. **Everything else is external**, with the whole written name standing in
15
+ * as `packageName` — where `Serilog.Configuration` ends and its assembly
16
+ * begins is not statically knowable, so the full name stands in exactly as
17
+ * the JVM resolver lets the whole dotted path stand in. A
18
+ * `bannedExternalImports` glob matches it the same way.
19
+ *
20
+ * There is no default-import table here because C# has no language-level one.
21
+ * Java's `java.lang` and Kotlin's stdlib roots are language constants; C#'s
22
+ * equivalent — implicit usings and `<Using Include>` items — is per-project
23
+ * MSBuild data (`docs/adr/0006-dotnet-language-integration.md`, Decision 4),
24
+ * and every namespace in the SDK-fixed set classifies external through step 2
25
+ * anyway. The one case where that data would change an ANSWER does not exist:
26
+ * an explicit `using X.Y;` resolves to a tracked owner or classifies external
27
+ * identically whether or not some project also auto-imported it.
28
+ *
29
+ * What this module deliberately does NOT do: read files, hold caches, or know
30
+ * which extension wrote the specifier. The index comes in as an argument.
31
+ */
32
+
33
+ import { resolveDottedNamespacePrefix } from "./namespaces.mjs";
34
+
35
+ /**
36
+ * Classify one imported dotted name.
37
+ *
38
+ * @param {string} importableName Everything the resolution can see: for
39
+ * `using a.b.c` the name `a.b.c`; for `using static a.b.Type` still
40
+ * `a.b.Type` (the walk stops at the deepest DECLARED namespace naturally,
41
+ * because types are not index keys); for `using alias = a.b.c` the
42
+ * right-hand side `a.b.c`, with the alias stripped by the caller.
43
+ * @param {Map<string, { project: string, file: string }[]>} index As built by
44
+ * `./namespaces.mjs`'s `csharpNamespaceIndex`.
45
+ * @returns {CsharpResolution} A project target; or an ambiguity the caller
46
+ * turns into `resolved: null` + a positioned failure naming the projects;
47
+ * or an external classification with `packageName` = the full written name.
48
+ */
49
+
50
+ /**
51
+ * @typedef {object} CsharpResolution
52
+ * @property {string|null} target The owning project, null when external or
53
+ * ambiguous.
54
+ * @property {boolean} external True when no tracked project claims any
55
+ * prefix of the name.
56
+ * @property {string|null} packageName The full written name for externals;
57
+ * null otherwise.
58
+ * @property {string[]} [ambiguous] Every distinct claimant of the deepest
59
+ * matched prefix, when several projects declare it.
60
+ * @property {string} [matchedPrefix] The deepest prefix the ambiguity was
61
+ * found at.
62
+ */
63
+ export function resolveCsharpSpecifier(importableName, index) {
64
+ const matched = resolveDottedNamespacePrefix(importableName, index);
65
+ if (matched) {
66
+ const projects = [...new Set(matched.owners.map((owner) => owner.project))];
67
+ if (projects.length === 1) {
68
+ return { target: projects[0], external: false, packageName: null };
69
+ }
70
+ // Several projects declare the same deepest matched namespace — ordinary
71
+ // C# (a namespace spanning assemblies), unresolvable by static reading.
72
+ // Like the JVM split-package answer this mirrors, picking either side
73
+ // would report violations against a guess, so the caller reports the tie
74
+ // instead. The compiler picks by reference order; this reader does not
75
+ // model references' contents.
76
+ return {
77
+ target: null,
78
+ external: false,
79
+ packageName: null,
80
+ ambiguous: projects,
81
+ matchedPrefix: matched.prefix,
82
+ };
83
+ }
84
+ return {
85
+ target: null,
86
+ external: true,
87
+ packageName: importableName,
88
+ };
89
+ }
@@ -31,6 +31,38 @@
31
31
  * string literal, and a mask that ate string literals would have nothing
32
32
  * left to read.
33
33
  *
34
+ * ## The two shapes that parse as nothing, and the failure that says so (#413)
35
+ *
36
+ * Both import regexes require a closing token: the block form a `)`, the
37
+ * single form a quote. A file truncated inside an import — a failed write, a
38
+ * merge marker left mid-import, a bad checkout — supplies neither, so both
39
+ * regexes matched nothing and the file analyzed as importing NOTHING, with no
40
+ * failure record and a clean verdict over it. `goImportMalformations` detects
41
+ * exactly those zero-record shapes and hands `analyzeGo` a whole-file failure
42
+ * per `contract.md`, which is what counts the file toward `unchecked` and
43
+ * turns the verdict loud. Shapes the regexes still misread but do answer — a
44
+ * quote closed on a later line, a `)` supplied from a function body below —
45
+ * produce a record today and stay the documented parse limits above.
46
+ *
47
+ * One misread answers neither record nor failure, named as a limit rather
48
+ * than fixed (#468's neighbourhood): a bare `import` whose line ends with
49
+ * no path and whose `(` arrives from the statement BELOW it — `import`
50
+ * alone, then `const (` opening the next — is read as that block, and the
51
+ * doubly-broken file stays silent. Rejecting it needs a same-line rule
52
+ * that would flag the legal `import` newline `"path"` continuation.
53
+ *
54
+ * ## An unreadable go.mod refuses, it does not skip (#405)
55
+ *
56
+ * `parseGoModulePath` parses content; it never sees a failed read. Reading a
57
+ * tracked go.mod as the empty string made a null read (permissions, EISDIR, a
58
+ * file gone between the listing and the read) parse to no module path and the
59
+ * project silently leave the module map — every import reaching it resolved
60
+ * as if it were a proxy package, and the run reported clean over the hole.
61
+ * The per-workspace reader records a whole-file failure naming the manifest
62
+ * instead, the same posture the JVM and Python manifest readers hold, and the
63
+ * graph resolver refuses the whole graph for it (`refuseUnreadTree`, #364's
64
+ * posture — `../graph/create-dependencies.mjs`'s header owns the boundary).
65
+ *
34
66
  * `import` opens its line, after indentation only, OR follows a `;` on the
35
67
  * same line — the same statement separator `gofmt` inserts automatically at
36
68
  * a newline, so an explicit one reopens an import exactly the way a fresh
@@ -62,7 +94,9 @@
62
94
  * - `kind` is always `static`. Go has no dynamic import, no type-only import,
63
95
  * and no re-export form; a blank (`_`) or dot (`.`) import is still an
64
96
  * ordinary compile-time dependency.
65
- * - `spelling.path` is always `false`; `spelling.relative` is true exactly when
97
+ * - `spelling.path` is always `false` and `spelling.namesOnly` always `true`
98
+ * (a module path is the only spelling Go has, so no text rule may read it as
99
+ * a path — the #376 leak); `spelling.relative` is true exactly when
66
100
  * the import resolved to the source file's own project. Go has no relative
67
101
  * import form, so the second bit reads what an import REACHED rather than
68
102
  * how it was written — `isOwnProjectImport` states why that is the honest
@@ -75,6 +109,7 @@ import {
75
109
  perWorkspace,
76
110
  positionAt,
77
111
  projectOwning,
112
+ refuseUnreadTree,
78
113
  trackedManifests,
79
114
  } from "./source-util.mjs";
80
115
 
@@ -172,6 +207,196 @@ export function maskGoComments(goText) {
172
207
  return masked + goText.slice(copied);
173
208
  }
174
209
 
210
+ /**
211
+ * `goText` with the CONTENT of every string literal blanked out, byte-for-byte
212
+ * the same length and with both quote delimiters of a closed literal left in
213
+ * place, so an offset into the result is the same offset into the original.
214
+ *
215
+ * `maskGoComments` keeps literals intact on purpose — an import path IS a
216
+ * string literal, and the site regexes read their content. The malformation
217
+ * scan below needs the opposite view: the Go source a code-generating raw
218
+ * string holds must not be mistaken for import syntax, or a template would
219
+ * have a compiling file reported as broken. Blanking rather than deleting
220
+ * keeps every offset honest for the same reason the comment mask does. The
221
+ * literal forms are the three `maskGoComments` scans — an interpreted string
222
+ * and a rune literal, both escape-aware and ended by a line break, and a raw
223
+ * string, which takes no escapes and runs either to its closing backtick or
224
+ * to end-of-file.
225
+ *
226
+ * @param {string} goText
227
+ * @returns {string} Same length as `goText`.
228
+ */
229
+ function blankGoStringContents(goText) {
230
+ const scan = /["'`]/g;
231
+ let masked = "";
232
+ let copied = 0;
233
+ let match;
234
+ while ((match = scan.exec(goText)) !== null) {
235
+ const start = match.index;
236
+ let end;
237
+ if (match[0] === "`") {
238
+ const close = goText.indexOf("`", start + 1);
239
+ end = close === -1 ? goText.length : close + 1;
240
+ } else {
241
+ // Interpreted string or rune literal: `\` escapes the next character,
242
+ // except at a line break, where the literal is unterminated instead.
243
+ let at = start + 1;
244
+ while (at < goText.length && goText[at] !== match[0] && goText[at] !== "\n") {
245
+ at += goText[at] === "\\" && goText[at + 1] !== "\n" ? 2 : 1;
246
+ }
247
+ end = Math.min(goText[at] === match[0] ? at + 1 : at, goText.length);
248
+ }
249
+ const close = end > start && goText[end - 1] === match[0] ? end - 1 : end;
250
+ masked +=
251
+ goText.slice(copied, start + 1) +
252
+ blankOut(goText.slice(start + 1, close)) +
253
+ goText.slice(close, end);
254
+ copied = end;
255
+ scan.lastIndex = end;
256
+ }
257
+ return masked + goText.slice(copied);
258
+ }
259
+
260
+ /** An alias is optional in every form this scan reads, and `\\s+` follows it. */
261
+ const GO_ALIAS_AT = /[\p{L}_.][\p{L}\p{Nd}_.]*\s+/uy;
262
+
263
+ /**
264
+ * Why a `.go` file's imports cannot be fully read, as reasons for `analyzeGo`
265
+ * to record as whole-file failures (`contract.md`): an `import (…)` block that
266
+ * never closes, a block spec whose string never closes inside its block, an
267
+ * import whose string literal never terminates, and an `import` statement that
268
+ * states no path at all. Each is the shape a TRUNCATED file takes — a failed
269
+ * write, a merge marker left mid-import, a bad checkout — and each used to
270
+ * parse as zero import sites with no failure record, byte-for-byte identical
271
+ * to a file that imports nothing (#413).
272
+ *
273
+ * The detection mirrors the import regexes' own failure conditions, so a file
274
+ * they read fully is never flagged: the block branch asks whether the `)` the
275
+ * block form could end at exists, and the string branches ask whether the
276
+ * closing quote `[^"]+` needs exists in the range that form searches — the
277
+ * whole rest of the file for a single-form import, the block's content for a
278
+ * spec inside one. Shapes the regexes still misread but do answer — a quote
279
+ * closed on a later line, a `)` supplied from a function body below — produce
280
+ * a record today and stay the header's documented parse limits, not new
281
+ * failures here.
282
+ *
283
+ * Imports written inside a raw string are excluded: the template a
284
+ * code-generating file holds is text, and flagging it would report a
285
+ * compiling file as broken (`blankGoStringContents` removes them). One reason
286
+ * per kind keeps a file with many truncated imports to one record each, and
287
+ * keeps the scan linear: a failed block search is never repeated, because no
288
+ * later opener can find a `)` an earlier one already failed to find.
289
+ *
290
+ * The graph layer does not consume this — per-source import reads keep the
291
+ * null-read posture there, where a dropped source loses only its own edges
292
+ * (`../graph/create-dependencies.mjs`'s header). This is the failure the
293
+ * ANALYSIS verdict refuses on: a whole-file failure counts the file toward
294
+ * `unchecked`, and `check` reports the run incomplete instead of clean.
295
+ *
296
+ * @param {string} goText
297
+ * @returns {string[]} At most one reason per kind, each naming the line it was
298
+ * seen on. Empty when the imports read fully.
299
+ */
300
+ export function goImportMalformations(goText) {
301
+ const source = blankGoStringContents(maskGoComments(goText));
302
+ /** @type {string[]} */
303
+ const reasons = [];
304
+ const flagged = new Set();
305
+ const flag = (offset, kind, reason) => {
306
+ if (flagged.has(kind)) return;
307
+ flagged.add(kind);
308
+ reasons.push(`${reason} (line ${positionAt(goText, offset).line})`);
309
+ };
310
+ // The last of each quote kind is the O(1) answer to "does this literal ever
311
+ // close": a quote terminates exactly when another of its kind follows it,
312
+ // which is the same search the site regexes' `[^"]+` / `[^\`]+` run.
313
+ const lastDoubleQuote = source.lastIndexOf('"');
314
+ const lastBacktick = source.lastIndexOf("`");
315
+ // `lastClose` keeps the block search linear across openers: openers ascend,
316
+ // and each `)` lies after its own opener, so the next search resumes at the
317
+ // close just found rather than rescanning the text before it.
318
+ let lastClose = 0;
319
+ let unclosedSince = -1;
320
+ // `import` must be the keyword, never a prefix of an identifier (#468): a
321
+ // const member named `importPath` sits at line head exactly where the
322
+ // keyword would, and reading it as the keyword flagged a compiling file
323
+ // "an import states no path". Go lexes by maximal munch — letters, digits,
324
+ // and `_` continuing the word make it one identifier — so the lookahead
325
+ // excludes exactly those, and every legal follower (`(`, a quote, an
326
+ // alias, whitespace) still opens.
327
+ for (const m of source.matchAll(/(?:^|;)[ \t]*import(?![\p{L}\p{Nd}_])/gmu)) {
328
+ let at = m.index + m[0].length;
329
+ while (at < source.length && /\s/u.test(source[at])) at += 1;
330
+ if (at >= source.length) {
331
+ flag(
332
+ m.index,
333
+ "bare",
334
+ "an import states no path — the file is truncated or malformed, so its imports cannot be read",
335
+ );
336
+ continue;
337
+ }
338
+ if (source[at] === "(") {
339
+ if (unclosedSince !== -1 && at > unclosedSince) continue;
340
+ const close = source.indexOf(")", Math.max(at + 1, lastClose));
341
+ if (close === -1) {
342
+ unclosedSince = at;
343
+ flag(
344
+ at,
345
+ "block",
346
+ "an `import (` block opens and never closes — the file is truncated or malformed, so its imports cannot be read",
347
+ );
348
+ continue;
349
+ }
350
+ lastClose = close + 1;
351
+ // Every quote inside the block content is a spec's string delimiter:
352
+ // everything else the content could have held is blanked. The quotes
353
+ // pair left-to-right — the same pairing `blankGoStringContents` ran —
354
+ // so an odd count means the last one opened a spec's string that
355
+ // nothing inside the block closes, which is the one `blockForm`'s
356
+ // `[^"]+` cannot bridge.
357
+ for (const quote of ['"', "`"]) {
358
+ const positions = [];
359
+ let at2 = source.indexOf(quote, at + 1);
360
+ while (at2 !== -1 && at2 < close) {
361
+ positions.push(at2);
362
+ at2 = source.indexOf(quote, at2 + 1);
363
+ }
364
+ if (positions.length % 2 === 1) {
365
+ flag(
366
+ positions[positions.length - 1],
367
+ "block-spec",
368
+ "an import path inside an `import (` block opens a string that never closes — the file is truncated or malformed, so its imports cannot be read",
369
+ );
370
+ }
371
+ }
372
+ continue;
373
+ }
374
+ GO_ALIAS_AT.lastIndex = at;
375
+ const alias = GO_ALIAS_AT.exec(source);
376
+ if (alias !== null) {
377
+ at = alias.index + alias[0].length;
378
+ while (at < source.length && /\s/u.test(source[at])) at += 1;
379
+ }
380
+ if (at >= source.length || (source[at] !== '"' && source[at] !== "`")) {
381
+ flag(
382
+ m.index,
383
+ "bare",
384
+ "an import states no path — the file is truncated or malformed, so its imports cannot be read",
385
+ );
386
+ continue;
387
+ }
388
+ const last = source[at] === '"' ? lastDoubleQuote : lastBacktick;
389
+ if (last <= at) {
390
+ flag(
391
+ at,
392
+ "string",
393
+ "an import opens a string literal that never terminates — the file is truncated or malformed, so its imports cannot be read",
394
+ );
395
+ }
396
+ }
397
+ return reasons;
398
+ }
399
+
175
400
  // An import alias is a Go identifier: `\p{L}` is what `unicode.IsLetter`
176
401
  // accepts as a starting rune (gofmt permits `import π "…"`), and the ASCII
177
402
  // `[A-Za-z_.]` this used to be silently dropped every non-ASCII one — a
@@ -241,18 +466,44 @@ export function parseGoImports(goText) {
241
466
  * `projects`: [{ name, root }]; `filesOf(name)`: workspace-relative paths of
242
467
  * a project's tracked files; `readFile(path)`: contents or null. Returns raw
243
468
  * Nx dependencies ({ source, target, sourceFile, type: "static" }).
469
+ *
470
+ * A go.mod the tree tracks but the read cannot produce is not parsed as the
471
+ * empty string (#405): the project's module paths would be unknown, so every
472
+ * import reaching it resolved as if it were a proxy package and the project
473
+ * left the graph in silence. The read failure is recorded and the whole graph
474
+ * is refused (`refuseUnreadTree`, #364's posture) — a manifest is an identity
475
+ * anchor, so one unreadable entry corrupts resolution for every project that
476
+ * names it, which is not a loss this resolver may answer with a missing edge.
477
+ * A go.mod that READS but declares no module directive is different: the
478
+ * content is known and there is genuinely no identity in it (a `go.work`-
479
+ * owning root), so that project still contributes nothing — loudly recorded
480
+ * nowhere, because nothing was lost.
481
+ *
482
+ * @throws {Error} when any tracked go.mod could not be read, naming each one.
244
483
  */
245
484
  export function resolveGoDependencies(projects, filesOf, readFile) {
246
485
  const moduleOf = new Map(); // module path -> project name
247
486
  const goProjects = [];
487
+ const unreadable = [];
248
488
  for (const project of projects) {
249
489
  const goModPath = normalizePath(project.root, "go.mod");
250
490
  if (!filesOf(project.name).includes(goModPath)) continue;
251
- const modulePath = parseGoModulePath(readFile(goModPath) ?? "");
491
+ const text = readFile(goModPath);
492
+ if (text === null) {
493
+ unreadable.push(
494
+ fileFailure(
495
+ goModPath,
496
+ "could not be read — the project's Go module paths are unknown, so imports reaching it cannot be judged",
497
+ ),
498
+ );
499
+ continue;
500
+ }
501
+ const modulePath = parseGoModulePath(text);
252
502
  if (!modulePath) continue;
253
503
  moduleOf.set(modulePath, project.name);
254
504
  goProjects.push(project);
255
505
  }
506
+ refuseUnreadTree("the Go module map", unreadable);
256
507
 
257
508
  const dependencies = [];
258
509
  for (const project of goProjects) {
@@ -287,21 +538,55 @@ export function resolveGoDependencies(projects, filesOf, readFile) {
287
538
  * per `.go` file. Every tracked `go.mod` in a project counts, not only the one
288
539
  * at its root — see `trackedManifests` for why analysis is broader here than
289
540
  * the edge resolver above.
541
+ *
542
+ * A tracked `go.mod` the read cannot produce is recorded as a whole-file
543
+ * failure naming the manifest rather than parsed as the empty string (#405):
544
+ * the empty string parses to no module path, and the project would leave the
545
+ * module map in silence — every import reaching it then resolved as if it were
546
+ * a proxy package, indistinguishable from a real external import. The failure
547
+ * is workspace-scoped, the way the Python and JVM manifest readers record
548
+ * theirs: a module list this reader could not read is a hole in every run,
549
+ * not only in the files that happen to import into it.
290
550
  */
291
551
  const goModulesOf = perWorkspace((workspace) => {
292
552
  const byModulePath = new Map(); // module path -> project name
293
553
  const byProject = new Map(); // project name -> [module path]
554
+ const failures = [];
294
555
  for (const project of workspace.projects) {
295
556
  for (const goModPath of trackedManifests(workspace, project.name, "go.mod")) {
296
- const modulePath = parseGoModulePath(workspace.readFile(goModPath) ?? "");
557
+ const text = workspace.readFile(goModPath);
558
+ if (text === null) {
559
+ failures.push(
560
+ fileFailure(
561
+ goModPath,
562
+ "could not be read — the project's Go module paths are unknown, so imports reaching it cannot be judged",
563
+ ),
564
+ );
565
+ continue;
566
+ }
567
+ const modulePath = parseGoModulePath(text);
297
568
  if (!modulePath) continue;
298
569
  byModulePath.set(modulePath, project.name);
299
570
  byProject.set(project.name, [...(byProject.get(project.name) ?? []), modulePath]);
300
571
  }
301
572
  }
302
- return { byModulePath, byProject };
573
+ return { byModulePath, byProject, failures };
303
574
  });
304
575
 
576
+ /**
577
+ * Every tracked go.mod this reader could not read, as whole-file failures
578
+ * attributed to the manifest — the accessor `../commands/context.mjs` merges
579
+ * beside `pythonUnmodelledFailures` and the JVM readers', so `check` reports
580
+ * the run incomplete (exit 3) rather than clean while a project's module list
581
+ * says nothing this tool can read. Workspace-scoped on purpose, for the same
582
+ * reason those readers are: a scoped run must not be able to hide a project
583
+ * whose manifest it cannot read by naming a path that excludes it.
584
+ *
585
+ * @param {object} workspace
586
+ * @returns {object[]} `fileFailure` shapes (`./source-util.mjs`).
587
+ */
588
+ export const goManifestFailures = (workspace) => goModulesOf(workspace).failures;
589
+
305
590
  /** True when `importPath` is inside the module rooted at `modulePath`. */
306
591
  const isUnderModule = (importPath, modulePath) =>
307
592
  importPath === modulePath || importPath.startsWith(`${modulePath}/`);
@@ -373,6 +658,15 @@ export function analyzeGo({ sourceFile, text, workspace }) {
373
658
  const owner = projectOwning(workspace.projects, sourceFile);
374
659
  const ownModules = owner ? (byProject.get(owner.name) ?? []) : [];
375
660
 
661
+ // A file truncated inside an import used to parse as importing nothing,
662
+ // with no failure beside the empty result — the clean verdict over it was
663
+ // the bug (#413). The whole-file shape is what turns the verdict loud:
664
+ // `check` counts the file toward `unchecked` and refuses to call the run
665
+ // complete, instead of reporting a hole as a clean file.
666
+ for (const reason of goImportMalformations(text)) {
667
+ result.failures.push(fileFailure(sourceFile, reason));
668
+ }
669
+
376
670
  for (const site of parseGoImportSites(text)) {
377
671
  const { line, column } = positionAt(text, site.offset);
378
672
  // The one resolution both layers share (WSX-D02): longest module path
@@ -398,7 +692,11 @@ export function analyzeGo({ sourceFile, text, workspace }) {
398
692
  // `relative` is true exactly when the import landed inside the file's
399
693
  // own project, which is `isOwnProjectImport` above and the same answer
400
694
  // Rust gives a binary that names its own package's library crate.
401
- spelling: { path: false, relative: isOwnProjectImport(target, owner) },
695
+ spelling: {
696
+ path: false,
697
+ relative: isOwnProjectImport(target, owner),
698
+ namesOnly: true,
699
+ },
402
700
  resolved: {
403
701
  target,
404
702
  file: null,