@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
@@ -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,31 @@
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
+ * ## An unreadable go.mod refuses, it does not skip (#405)
48
+ *
49
+ * `parseGoModulePath` parses content; it never sees a failed read. Reading a
50
+ * tracked go.mod as the empty string made a null read (permissions, EISDIR, a
51
+ * file gone between the listing and the read) parse to no module path and the
52
+ * project silently leave the module map — every import reaching it resolved
53
+ * as if it were a proxy package, and the run reported clean over the hole.
54
+ * The per-workspace reader records a whole-file failure naming the manifest
55
+ * instead, the same posture the JVM and Python manifest readers hold, and the
56
+ * graph resolver refuses the whole graph for it (`refuseUnreadTree`, #364's
57
+ * posture — `../graph/create-dependencies.mjs`'s header owns the boundary).
58
+ *
34
59
  * `import` opens its line, after indentation only, OR follows a `;` on the
35
60
  * same line — the same statement separator `gofmt` inserts automatically at
36
61
  * a newline, so an explicit one reopens an import exactly the way a fresh
@@ -62,7 +87,9 @@
62
87
  * - `kind` is always `static`. Go has no dynamic import, no type-only import,
63
88
  * and no re-export form; a blank (`_`) or dot (`.`) import is still an
64
89
  * ordinary compile-time dependency.
65
- * - `spelling.path` is always `false`; `spelling.relative` is true exactly when
90
+ * - `spelling.path` is always `false` and `spelling.namesOnly` always `true`
91
+ * (a module path is the only spelling Go has, so no text rule may read it as
92
+ * a path — the #376 leak); `spelling.relative` is true exactly when
66
93
  * the import resolved to the source file's own project. Go has no relative
67
94
  * import form, so the second bit reads what an import REACHED rather than
68
95
  * how it was written — `isOwnProjectImport` states why that is the honest
@@ -75,6 +102,7 @@ import {
75
102
  perWorkspace,
76
103
  positionAt,
77
104
  projectOwning,
105
+ refuseUnreadTree,
78
106
  trackedManifests,
79
107
  } from "./source-util.mjs";
80
108
 
@@ -172,6 +200,189 @@ export function maskGoComments(goText) {
172
200
  return masked + goText.slice(copied);
173
201
  }
174
202
 
203
+ /**
204
+ * `goText` with the CONTENT of every string literal blanked out, byte-for-byte
205
+ * the same length and with both quote delimiters of a closed literal left in
206
+ * place, so an offset into the result is the same offset into the original.
207
+ *
208
+ * `maskGoComments` keeps literals intact on purpose — an import path IS a
209
+ * string literal, and the site regexes read their content. The malformation
210
+ * scan below needs the opposite view: the Go source a code-generating raw
211
+ * string holds must not be mistaken for import syntax, or a template would
212
+ * have a compiling file reported as broken. Blanking rather than deleting
213
+ * keeps every offset honest for the same reason the comment mask does. The
214
+ * literal forms are the three `maskGoComments` scans — an interpreted string
215
+ * and a rune literal, both escape-aware and ended by a line break, and a raw
216
+ * string, which takes no escapes and runs either to its closing backtick or
217
+ * to end-of-file.
218
+ *
219
+ * @param {string} goText
220
+ * @returns {string} Same length as `goText`.
221
+ */
222
+ function blankGoStringContents(goText) {
223
+ const scan = /["'`]/g;
224
+ let masked = "";
225
+ let copied = 0;
226
+ let match;
227
+ while ((match = scan.exec(goText)) !== null) {
228
+ const start = match.index;
229
+ let end;
230
+ if (match[0] === "`") {
231
+ const close = goText.indexOf("`", start + 1);
232
+ end = close === -1 ? goText.length : close + 1;
233
+ } else {
234
+ // Interpreted string or rune literal: `\` escapes the next character,
235
+ // except at a line break, where the literal is unterminated instead.
236
+ let at = start + 1;
237
+ while (at < goText.length && goText[at] !== match[0] && goText[at] !== "\n") {
238
+ at += goText[at] === "\\" && goText[at + 1] !== "\n" ? 2 : 1;
239
+ }
240
+ end = Math.min(goText[at] === match[0] ? at + 1 : at, goText.length);
241
+ }
242
+ const close = end > start && goText[end - 1] === match[0] ? end - 1 : end;
243
+ masked +=
244
+ goText.slice(copied, start + 1) +
245
+ blankOut(goText.slice(start + 1, close)) +
246
+ goText.slice(close, end);
247
+ copied = end;
248
+ scan.lastIndex = end;
249
+ }
250
+ return masked + goText.slice(copied);
251
+ }
252
+
253
+ /** An alias is optional in every form this scan reads, and `\\s+` follows it. */
254
+ const GO_ALIAS_AT = /[\p{L}_.][\p{L}\p{Nd}_.]*\s+/uy;
255
+
256
+ /**
257
+ * Why a `.go` file's imports cannot be fully read, as reasons for `analyzeGo`
258
+ * to record as whole-file failures (`contract.md`): an `import (…)` block that
259
+ * never closes, a block spec whose string never closes inside its block, an
260
+ * import whose string literal never terminates, and an `import` statement that
261
+ * states no path at all. Each is the shape a TRUNCATED file takes — a failed
262
+ * write, a merge marker left mid-import, a bad checkout — and each used to
263
+ * parse as zero import sites with no failure record, byte-for-byte identical
264
+ * to a file that imports nothing (#413).
265
+ *
266
+ * The detection mirrors the import regexes' own failure conditions, so a file
267
+ * they read fully is never flagged: the block branch asks whether the `)` the
268
+ * block form could end at exists, and the string branches ask whether the
269
+ * closing quote `[^"]+` needs exists in the range that form searches — the
270
+ * whole rest of the file for a single-form import, the block's content for a
271
+ * spec inside one. Shapes the regexes still misread but do answer — a quote
272
+ * closed on a later line, a `)` supplied from a function body below — produce
273
+ * a record today and stay the header's documented parse limits, not new
274
+ * failures here.
275
+ *
276
+ * Imports written inside a raw string are excluded: the template a
277
+ * code-generating file holds is text, and flagging it would report a
278
+ * compiling file as broken (`blankGoStringContents` removes them). One reason
279
+ * per kind keeps a file with many truncated imports to one record each, and
280
+ * keeps the scan linear: a failed block search is never repeated, because no
281
+ * later opener can find a `)` an earlier one already failed to find.
282
+ *
283
+ * The graph layer does not consume this — per-source import reads keep the
284
+ * null-read posture there, where a dropped source loses only its own edges
285
+ * (`../graph/create-dependencies.mjs`'s header). This is the failure the
286
+ * ANALYSIS verdict refuses on: a whole-file failure counts the file toward
287
+ * `unchecked`, and `check` reports the run incomplete instead of clean.
288
+ *
289
+ * @param {string} goText
290
+ * @returns {string[]} At most one reason per kind, each naming the line it was
291
+ * seen on. Empty when the imports read fully.
292
+ */
293
+ export function goImportMalformations(goText) {
294
+ const source = blankGoStringContents(maskGoComments(goText));
295
+ /** @type {string[]} */
296
+ const reasons = [];
297
+ const flagged = new Set();
298
+ const flag = (offset, kind, reason) => {
299
+ if (flagged.has(kind)) return;
300
+ flagged.add(kind);
301
+ reasons.push(`${reason} (line ${positionAt(goText, offset).line})`);
302
+ };
303
+ // The last of each quote kind is the O(1) answer to "does this literal ever
304
+ // close": a quote terminates exactly when another of its kind follows it,
305
+ // which is the same search the site regexes' `[^"]+` / `[^\`]+` run.
306
+ const lastDoubleQuote = source.lastIndexOf('"');
307
+ const lastBacktick = source.lastIndexOf("`");
308
+ // `lastClose` keeps the block search linear across openers: openers ascend,
309
+ // and each `)` lies after its own opener, so the next search resumes at the
310
+ // close just found rather than rescanning the text before it.
311
+ let lastClose = 0;
312
+ let unclosedSince = -1;
313
+ for (const m of source.matchAll(/(?:^|;)[ \t]*import/gm)) {
314
+ let at = m.index + m[0].length;
315
+ while (at < source.length && /\s/u.test(source[at])) at += 1;
316
+ if (at >= source.length) {
317
+ flag(
318
+ m.index,
319
+ "bare",
320
+ "an import states no path — the file is truncated or malformed, so its imports cannot be read",
321
+ );
322
+ continue;
323
+ }
324
+ if (source[at] === "(") {
325
+ if (unclosedSince !== -1 && at > unclosedSince) continue;
326
+ const close = source.indexOf(")", Math.max(at + 1, lastClose));
327
+ if (close === -1) {
328
+ unclosedSince = at;
329
+ flag(
330
+ at,
331
+ "block",
332
+ "an `import (` block opens and never closes — the file is truncated or malformed, so its imports cannot be read",
333
+ );
334
+ continue;
335
+ }
336
+ lastClose = close + 1;
337
+ // Every quote inside the block content is a spec's string delimiter:
338
+ // everything else the content could have held is blanked. The quotes
339
+ // pair left-to-right — the same pairing `blankGoStringContents` ran —
340
+ // so an odd count means the last one opened a spec's string that
341
+ // nothing inside the block closes, which is the one `blockForm`'s
342
+ // `[^"]+` cannot bridge.
343
+ for (const quote of ['"', "`"]) {
344
+ const positions = [];
345
+ let at2 = source.indexOf(quote, at + 1);
346
+ while (at2 !== -1 && at2 < close) {
347
+ positions.push(at2);
348
+ at2 = source.indexOf(quote, at2 + 1);
349
+ }
350
+ if (positions.length % 2 === 1) {
351
+ flag(
352
+ positions[positions.length - 1],
353
+ "block-spec",
354
+ "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",
355
+ );
356
+ }
357
+ }
358
+ continue;
359
+ }
360
+ GO_ALIAS_AT.lastIndex = at;
361
+ const alias = GO_ALIAS_AT.exec(source);
362
+ if (alias !== null) {
363
+ at = alias.index + alias[0].length;
364
+ while (at < source.length && /\s/u.test(source[at])) at += 1;
365
+ }
366
+ if (at >= source.length || (source[at] !== '"' && source[at] !== "`")) {
367
+ flag(
368
+ m.index,
369
+ "bare",
370
+ "an import states no path — the file is truncated or malformed, so its imports cannot be read",
371
+ );
372
+ continue;
373
+ }
374
+ const last = source[at] === '"' ? lastDoubleQuote : lastBacktick;
375
+ if (last <= at) {
376
+ flag(
377
+ at,
378
+ "string",
379
+ "an import opens a string literal that never terminates — the file is truncated or malformed, so its imports cannot be read",
380
+ );
381
+ }
382
+ }
383
+ return reasons;
384
+ }
385
+
175
386
  // An import alias is a Go identifier: `\p{L}` is what `unicode.IsLetter`
176
387
  // accepts as a starting rune (gofmt permits `import π "…"`), and the ASCII
177
388
  // `[A-Za-z_.]` this used to be silently dropped every non-ASCII one — a
@@ -241,18 +452,44 @@ export function parseGoImports(goText) {
241
452
  * `projects`: [{ name, root }]; `filesOf(name)`: workspace-relative paths of
242
453
  * a project's tracked files; `readFile(path)`: contents or null. Returns raw
243
454
  * Nx dependencies ({ source, target, sourceFile, type: "static" }).
455
+ *
456
+ * A go.mod the tree tracks but the read cannot produce is not parsed as the
457
+ * empty string (#405): the project's module paths would be unknown, so every
458
+ * import reaching it resolved as if it were a proxy package and the project
459
+ * left the graph in silence. The read failure is recorded and the whole graph
460
+ * is refused (`refuseUnreadTree`, #364's posture) — a manifest is an identity
461
+ * anchor, so one unreadable entry corrupts resolution for every project that
462
+ * names it, which is not a loss this resolver may answer with a missing edge.
463
+ * A go.mod that READS but declares no module directive is different: the
464
+ * content is known and there is genuinely no identity in it (a `go.work`-
465
+ * owning root), so that project still contributes nothing — loudly recorded
466
+ * nowhere, because nothing was lost.
467
+ *
468
+ * @throws {Error} when any tracked go.mod could not be read, naming each one.
244
469
  */
245
470
  export function resolveGoDependencies(projects, filesOf, readFile) {
246
471
  const moduleOf = new Map(); // module path -> project name
247
472
  const goProjects = [];
473
+ const unreadable = [];
248
474
  for (const project of projects) {
249
475
  const goModPath = normalizePath(project.root, "go.mod");
250
476
  if (!filesOf(project.name).includes(goModPath)) continue;
251
- const modulePath = parseGoModulePath(readFile(goModPath) ?? "");
477
+ const text = readFile(goModPath);
478
+ if (text === null) {
479
+ unreadable.push(
480
+ fileFailure(
481
+ goModPath,
482
+ "could not be read — the project's Go module paths are unknown, so imports reaching it cannot be judged",
483
+ ),
484
+ );
485
+ continue;
486
+ }
487
+ const modulePath = parseGoModulePath(text);
252
488
  if (!modulePath) continue;
253
489
  moduleOf.set(modulePath, project.name);
254
490
  goProjects.push(project);
255
491
  }
492
+ refuseUnreadTree("the Go module map", unreadable);
256
493
 
257
494
  const dependencies = [];
258
495
  for (const project of goProjects) {
@@ -287,21 +524,55 @@ export function resolveGoDependencies(projects, filesOf, readFile) {
287
524
  * per `.go` file. Every tracked `go.mod` in a project counts, not only the one
288
525
  * at its root — see `trackedManifests` for why analysis is broader here than
289
526
  * the edge resolver above.
527
+ *
528
+ * A tracked `go.mod` the read cannot produce is recorded as a whole-file
529
+ * failure naming the manifest rather than parsed as the empty string (#405):
530
+ * the empty string parses to no module path, and the project would leave the
531
+ * module map in silence — every import reaching it then resolved as if it were
532
+ * a proxy package, indistinguishable from a real external import. The failure
533
+ * is workspace-scoped, the way the Python and JVM manifest readers record
534
+ * theirs: a module list this reader could not read is a hole in every run,
535
+ * not only in the files that happen to import into it.
290
536
  */
291
537
  const goModulesOf = perWorkspace((workspace) => {
292
538
  const byModulePath = new Map(); // module path -> project name
293
539
  const byProject = new Map(); // project name -> [module path]
540
+ const failures = [];
294
541
  for (const project of workspace.projects) {
295
542
  for (const goModPath of trackedManifests(workspace, project.name, "go.mod")) {
296
- const modulePath = parseGoModulePath(workspace.readFile(goModPath) ?? "");
543
+ const text = workspace.readFile(goModPath);
544
+ if (text === null) {
545
+ failures.push(
546
+ fileFailure(
547
+ goModPath,
548
+ "could not be read — the project's Go module paths are unknown, so imports reaching it cannot be judged",
549
+ ),
550
+ );
551
+ continue;
552
+ }
553
+ const modulePath = parseGoModulePath(text);
297
554
  if (!modulePath) continue;
298
555
  byModulePath.set(modulePath, project.name);
299
556
  byProject.set(project.name, [...(byProject.get(project.name) ?? []), modulePath]);
300
557
  }
301
558
  }
302
- return { byModulePath, byProject };
559
+ return { byModulePath, byProject, failures };
303
560
  });
304
561
 
562
+ /**
563
+ * Every tracked go.mod this reader could not read, as whole-file failures
564
+ * attributed to the manifest — the accessor `../commands/context.mjs` merges
565
+ * beside `pythonUnmodelledFailures` and the JVM readers', so `check` reports
566
+ * the run incomplete (exit 3) rather than clean while a project's module list
567
+ * says nothing this tool can read. Workspace-scoped on purpose, for the same
568
+ * reason those readers are: a scoped run must not be able to hide a project
569
+ * whose manifest it cannot read by naming a path that excludes it.
570
+ *
571
+ * @param {object} workspace
572
+ * @returns {object[]} `fileFailure` shapes (`./source-util.mjs`).
573
+ */
574
+ export const goManifestFailures = (workspace) => goModulesOf(workspace).failures;
575
+
305
576
  /** True when `importPath` is inside the module rooted at `modulePath`. */
306
577
  const isUnderModule = (importPath, modulePath) =>
307
578
  importPath === modulePath || importPath.startsWith(`${modulePath}/`);
@@ -373,6 +644,15 @@ export function analyzeGo({ sourceFile, text, workspace }) {
373
644
  const owner = projectOwning(workspace.projects, sourceFile);
374
645
  const ownModules = owner ? (byProject.get(owner.name) ?? []) : [];
375
646
 
647
+ // A file truncated inside an import used to parse as importing nothing,
648
+ // with no failure beside the empty result — the clean verdict over it was
649
+ // the bug (#413). The whole-file shape is what turns the verdict loud:
650
+ // `check` counts the file toward `unchecked` and refuses to call the run
651
+ // complete, instead of reporting a hole as a clean file.
652
+ for (const reason of goImportMalformations(text)) {
653
+ result.failures.push(fileFailure(sourceFile, reason));
654
+ }
655
+
376
656
  for (const site of parseGoImportSites(text)) {
377
657
  const { line, column } = positionAt(text, site.offset);
378
658
  // The one resolution both layers share (WSX-D02): longest module path
@@ -398,7 +678,11 @@ export function analyzeGo({ sourceFile, text, workspace }) {
398
678
  // `relative` is true exactly when the import landed inside the file's
399
679
  // own project, which is `isOwnProjectImport` above and the same answer
400
680
  // Rust gives a binary that names its own package's library crate.
401
- spelling: { path: false, relative: isOwnProjectImport(target, owner) },
681
+ spelling: {
682
+ path: false,
683
+ relative: isOwnProjectImport(target, owner),
684
+ namesOnly: true,
685
+ },
402
686
  resolved: {
403
687
  target,
404
688
  file: null,