@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.
- package/README.md +3 -3
- package/cli.mjs +126 -4
- package/commands.mjs +6 -0
- package/lsp.mjs +15 -2
- package/package.json +6 -2
- package/src/analysis/analyze.mjs +15 -0
- package/src/analysis/contract.md +36 -18
- package/src/analysis/csharp.mjs +514 -0
- package/src/analysis/dotnet/csproj.mjs +380 -0
- package/src/analysis/dotnet/mask.mjs +178 -0
- package/src/analysis/dotnet/namespaces.mjs +172 -0
- package/src/analysis/dotnet/resolve.mjs +89 -0
- package/src/analysis/go.mjs +303 -5
- package/src/analysis/java.mjs +329 -0
- package/src/analysis/jvm/gradle.mjs +545 -0
- package/src/analysis/jvm/mask.mjs +170 -0
- package/src/analysis/jvm/maven.mjs +612 -0
- package/src/analysis/jvm/packages.mjs +209 -0
- package/src/analysis/jvm/resolve.mjs +139 -0
- package/src/analysis/kotlin.mjs +210 -0
- package/src/analysis/manifest-util.mjs +30 -0
- package/src/analysis/python.mjs +3 -2
- package/src/analysis/registry.mjs +11 -0
- package/src/analysis/rust.mjs +171 -17
- package/src/analysis/source-util.mjs +155 -6
- package/src/analysis/typescript.mjs +9 -2
- package/src/commands/context.mjs +84 -14
- package/src/commands/provenance.mjs +7 -44
- package/src/commands/rules.mjs +775 -0
- package/src/governance/profile-registry.mjs +0 -1
- package/src/graph/create-dependencies.mjs +138 -15
- package/src/lsp/diagnose.mjs +1 -1
- package/src/lsp/server.mjs +97 -1
- package/src/lsp/workspace-index.mjs +106 -15
- package/src/options.mjs +30 -7
- package/src/process.mjs +10 -1
- package/src/providers/moon.mjs +287 -36
- package/src/providers/native/differential.fixtures.mjs +32 -6
- package/src/providers/native/discover.mjs +83 -4
- package/src/providers/native/graph.mjs +58 -0
- package/src/providers/native/model.mjs +59 -1
- package/src/rules/index.mjs +21 -6
- package/src/rules/reachability.mjs +2 -0
- package/src/rules/tags.mjs +7 -5
- package/src/rules/topology.mjs +5 -3
- 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
|
+
}
|
package/src/analysis/go.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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: {
|
|
695
|
+
spelling: {
|
|
696
|
+
path: false,
|
|
697
|
+
relative: isOwnProjectImport(target, owner),
|
|
698
|
+
namesOnly: true,
|
|
699
|
+
},
|
|
402
700
|
resolved: {
|
|
403
701
|
target,
|
|
404
702
|
file: null,
|