@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.
- 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 +485 -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 +289 -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,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .csproj manifest reader and edge resolver — the identity-anchor half of
|
|
3
|
+
* .NET/C# support wherever a root `.csproj` stands for a project. Static
|
|
4
|
+
* only: no `dotnet`, no MSBuild, no Roslyn, no network
|
|
5
|
+
* (`docs/adr/0006-dotnet-language-integration.md`, Decision 4).
|
|
6
|
+
*
|
|
7
|
+
* ## What the reader extracts
|
|
8
|
+
*
|
|
9
|
+
* Boundary edges need project-to-project references ONLY. NuGet
|
|
10
|
+
* `<PackageReference>` elements are external packages and draw no graph edge.
|
|
11
|
+
* The reader extracts:
|
|
12
|
+
*
|
|
13
|
+
* - `<ProjectReference Include="..." />` — a path to another `.csproj`,
|
|
14
|
+
* written relative to the declaring project's directory with Windows `\`
|
|
15
|
+
* separators normalized, resolved to the project that owns the exact
|
|
16
|
+
* csproj path it lands on.
|
|
17
|
+
* - `<Using Include="..." />` — a namespace made visible project-wide with
|
|
18
|
+
* no written directive (ADR 0006, Decision 4): it rides the reader and
|
|
19
|
+
* draws the edge its `using` spelling would. `Remove` items and `<Using>`
|
|
20
|
+
* items living in imported `.props` files are outside static scope — the
|
|
21
|
+
* csproj's own text is everything this reader sees.
|
|
22
|
+
*
|
|
23
|
+
* Elements are collected at ANY depth under `<Project>` — a plain
|
|
24
|
+
* `<ItemGroup>`, but also the `<Choose>`/`<When>`/`<Otherwise>` and
|
|
25
|
+
* `<Target>` nests a conditional reference lives in. Conditions are taken
|
|
26
|
+
* loudly rather than evaluated: a conditionally-present reference draws its
|
|
27
|
+
* possibly-spurious edge, the self-correcting direction, where skipping it
|
|
28
|
+
* would risk the silent one (ADR 0006, Decision 3).
|
|
29
|
+
*
|
|
30
|
+
* ## Malformed input degrades loudly
|
|
31
|
+
*
|
|
32
|
+
* XML that does not parse, a `<ProjectReference>` with no readable `Include`
|
|
33
|
+
* attribute, an `Include` holding an MSBuild placeholder (`$(…)` / `%(…)`),
|
|
34
|
+
* a path that resolves to no tracked project's `.csproj`, and an ambiguous
|
|
35
|
+
* `<Using>` namespace all surface through `dotnetManifestFailures` as
|
|
36
|
+
* whole-file failures naming the `.csproj` — the same posture Maven's reader
|
|
37
|
+
* holds for unresolvable placeholder coordinates (`../jvm/maven.mjs`), and
|
|
38
|
+
* the `go.work` precedent one layer out. A broken manifest read as "no
|
|
39
|
+
* dependencies" would mean "no drift" exactly where the tree is most broken,
|
|
40
|
+
* so the CLI funnels these into the could-not-complete class (exit 3) — and
|
|
41
|
+
* the graph resolver below THROWS on the same list (#364's posture,
|
|
42
|
+
* `../source-util.mjs`'s `refuseUnreadTree`), so `nx affected` fails loudly
|
|
43
|
+
* instead of under-selecting on it. One
|
|
44
|
+
* reference stays quiet on purpose: a self-reference resolves to its own
|
|
45
|
+
* project and draws no edge, because that csproj is legal and uninteresting.
|
|
46
|
+
*/
|
|
47
|
+
import { createRequire } from "node:module";
|
|
48
|
+
|
|
49
|
+
import { normalizePath } from "../manifest-util.mjs";
|
|
50
|
+
import { fileFailure, perWorkspace, refuseUnreadTree } from "../source-util.mjs";
|
|
51
|
+
import { csharpNamespaceIndex } from "./namespaces.mjs";
|
|
52
|
+
import { resolveCsharpSpecifier } from "./resolve.mjs";
|
|
53
|
+
|
|
54
|
+
const XML_PARSER = "fast-xml-parser";
|
|
55
|
+
|
|
56
|
+
let parserLoad = null;
|
|
57
|
+
|
|
58
|
+
function xmlParser() {
|
|
59
|
+
if (parserLoad === null) {
|
|
60
|
+
try {
|
|
61
|
+
const require = createRequire(import.meta.url);
|
|
62
|
+
const { XMLParser, XMLValidator } = require(XML_PARSER);
|
|
63
|
+
parserLoad = {
|
|
64
|
+
parser: new XMLParser({ processEntities: false, ignoreAttributes: false }),
|
|
65
|
+
validate: typeof XMLValidator?.validate === "function" ? XMLValidator.validate : null,
|
|
66
|
+
error: null,
|
|
67
|
+
};
|
|
68
|
+
} catch (cause) {
|
|
69
|
+
parserLoad = { parser: null, validate: null, error: cause?.message ?? String(cause) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return parserLoad;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const textOf = (value) => {
|
|
76
|
+
if (typeof value !== "string") return null;
|
|
77
|
+
const trimmed = value.trim();
|
|
78
|
+
return trimmed === "" ? null : trimmed;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Every element named `name` at any depth under `node`. fast-xml-parser's
|
|
83
|
+
* default shape nests objects and arrays freely, so the walk handles both —
|
|
84
|
+
* which is what reaches a `<ProjectReference>` inside `<Choose>`/`<When>`
|
|
85
|
+
* that a top-level-only read would never see.
|
|
86
|
+
*
|
|
87
|
+
* @param {unknown} node
|
|
88
|
+
* @param {string} name
|
|
89
|
+
* @returns {Record<string, unknown>[]}
|
|
90
|
+
*/
|
|
91
|
+
function collectElements(node, name) {
|
|
92
|
+
const found = [];
|
|
93
|
+
const walk = (value) => {
|
|
94
|
+
if (Array.isArray(value)) {
|
|
95
|
+
for (const item of value) walk(item);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (typeof value !== "object" || value === null) return;
|
|
99
|
+
for (const [key, child] of Object.entries(value)) {
|
|
100
|
+
if (key === name) {
|
|
101
|
+
for (const element of Array.isArray(child) ? child : [child]) {
|
|
102
|
+
if (typeof element === "object" && element !== null) found.push(element);
|
|
103
|
+
// A valueless spelling — `<ProjectReference />` — arrives as an
|
|
104
|
+
// empty string in fast-xml-parser's default shape; keep it as an
|
|
105
|
+
// element with no facts so the missing-Include problem still fires
|
|
106
|
+
// instead of the reference silently vanishing.
|
|
107
|
+
else if (typeof element === "string") found.push({});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
walk(child);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
walk(node);
|
|
114
|
+
return found;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parse one .csproj text. Returns the root element or a reason string.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} text
|
|
121
|
+
* @returns {{ project: Record<string, unknown>, reason?: undefined } |
|
|
122
|
+
* { project?: undefined, reason: string }}
|
|
123
|
+
*/
|
|
124
|
+
export function parseCsproj(text) {
|
|
125
|
+
const { parser, validate, error } = xmlParser();
|
|
126
|
+
if (parser === null) {
|
|
127
|
+
return { reason: `${XML_PARSER} is unavailable (${error})` };
|
|
128
|
+
}
|
|
129
|
+
const verdict = validate?.(text, { allowBooleanAttributes: true });
|
|
130
|
+
if (verdict && verdict !== true) {
|
|
131
|
+
return { reason: `malformed XML (${verdict.err.msg})` };
|
|
132
|
+
}
|
|
133
|
+
let document;
|
|
134
|
+
try {
|
|
135
|
+
document = /** @type {Record<string, unknown>} */ (parser.parse(text));
|
|
136
|
+
} catch {
|
|
137
|
+
return { reason: "malformed XML" };
|
|
138
|
+
}
|
|
139
|
+
const rawProject = document?.Project;
|
|
140
|
+
if (typeof rawProject !== "object" || rawProject === null) {
|
|
141
|
+
return { reason: "no <Project> element" };
|
|
142
|
+
}
|
|
143
|
+
return { project: /** @type {Record<string, unknown>} */ (rawProject) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* One csproj's `<ProjectReference>` facts: the normalized workspace-relative
|
|
148
|
+
* paths it declares, plus the problems that must degrade loudly instead of
|
|
149
|
+
* silently erasing a declared dependency.
|
|
150
|
+
*
|
|
151
|
+
* @param {Record<string, unknown>} project
|
|
152
|
+
* @param {string} csprojDir Directory containing the csproj, workspace-relative.
|
|
153
|
+
* @returns {{ paths: string[], problems: string[] }}
|
|
154
|
+
*/
|
|
155
|
+
export function projectReferenceFacts(project, csprojDir) {
|
|
156
|
+
const paths = [];
|
|
157
|
+
const problems = [];
|
|
158
|
+
for (const ref of collectElements(project, "ProjectReference")) {
|
|
159
|
+
const include = textOf(ref["@_Include"] ?? ref.Include);
|
|
160
|
+
if (include === null) {
|
|
161
|
+
problems.push("a <ProjectReference> with no readable Include attribute");
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (include.includes("$(") || include.includes("%(")) {
|
|
165
|
+
problems.push(`an Include that does not statically resolve ('${include}')`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// MSBuild accepts either separator, and a Windows-authored tree writes
|
|
169
|
+
// `\` — normalize before the path arithmetic, or the reference lands on
|
|
170
|
+
// a path no identity ever held.
|
|
171
|
+
paths.push(normalizePath(csprojDir, include.replace(/\\/g, "/")));
|
|
172
|
+
}
|
|
173
|
+
return { paths, problems };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The dotted namespaces a csproj makes visible project-wide through
|
|
178
|
+
* `<Using Include="…">`. v1 reads `Include` only — `Remove` items and values
|
|
179
|
+
* living in imported `.props` files are outside static scope. A value that
|
|
180
|
+
* is not a dotted name names no namespace any resolution could match, so it
|
|
181
|
+
* is skipped rather than recorded.
|
|
182
|
+
*
|
|
183
|
+
* @param {Record<string, unknown>} project
|
|
184
|
+
* @returns {string[]}
|
|
185
|
+
*/
|
|
186
|
+
export function usingNamespacesOf(project) {
|
|
187
|
+
const namespaces = [];
|
|
188
|
+
for (const item of collectElements(project, "Using")) {
|
|
189
|
+
const include = textOf(item["@_Include"] ?? item.Include);
|
|
190
|
+
if (include === null) continue;
|
|
191
|
+
if (!/^[\p{L}_][\p{L}\p{Nd}_.]*$/u.test(include)) continue;
|
|
192
|
+
namespaces.push(include);
|
|
193
|
+
}
|
|
194
|
+
return namespaces;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* One csproj's facts, for graph-edge resolution.
|
|
199
|
+
*
|
|
200
|
+
* @typedef {object} CsprojEntry
|
|
201
|
+
* @property {string} csprojPath Workspace-relative.
|
|
202
|
+
* @property {string} projectName The project whose root this csproj anchors.
|
|
203
|
+
* @property {string[]} projectRefPaths Normalized paths from ProjectReference.
|
|
204
|
+
* @property {string[]} usingNamespaces Dotted names from `<Using Include>`.
|
|
205
|
+
*/
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Extract one csproj entry. Never throws; a reference the reader cannot
|
|
209
|
+
* trust arrives as a problem string rather than a path.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} projectName
|
|
212
|
+
* @param {string} csprojPath
|
|
213
|
+
* @param {string} text
|
|
214
|
+
* @returns {{ entry: CsprojEntry, problems: string[], reason?: undefined } |
|
|
215
|
+
* { entry?: undefined, problems?: undefined, reason: string }}
|
|
216
|
+
*/
|
|
217
|
+
export function csprojEntryOf(projectName, csprojPath, text) {
|
|
218
|
+
const parsed = parseCsproj(text);
|
|
219
|
+
if (parsed.reason !== undefined) return { reason: parsed.reason };
|
|
220
|
+
// A manifest at the workspace root has no separator: `lastIndexOf` answers
|
|
221
|
+
// -1 and the unguarded slice would strip the filename's last character —
|
|
222
|
+
// `App.csproj` resolved as directory `App.cspro` (#408), so every reference
|
|
223
|
+
// it declared landed on a path no project occupies. `""` is the root, the
|
|
224
|
+
// same answer `../jvm/maven.mjs` and `../jvm/gradle.mjs` give their
|
|
225
|
+
// root-level manifests.
|
|
226
|
+
const csprojDir = csprojPath.includes("/")
|
|
227
|
+
? csprojPath.slice(0, csprojPath.lastIndexOf("/"))
|
|
228
|
+
: "";
|
|
229
|
+
const facts = projectReferenceFacts(parsed.project, csprojDir);
|
|
230
|
+
return {
|
|
231
|
+
entry: {
|
|
232
|
+
csprojPath,
|
|
233
|
+
projectName,
|
|
234
|
+
projectRefPaths: facts.paths,
|
|
235
|
+
usingNamespaces: usingNamespacesOf(parsed.project),
|
|
236
|
+
},
|
|
237
|
+
problems: facts.problems,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Identity map: normalized csproj path → project name, over the csproj files
|
|
243
|
+
* the workspace's own projects track. A reference is resolved against this
|
|
244
|
+
* map exactly — landing on no key is a failure the model records, never a
|
|
245
|
+
* silent no-edge, because a dangling `<ProjectReference>` erases a declared
|
|
246
|
+
* dependency the moment it is read as external-or-nothing.
|
|
247
|
+
*
|
|
248
|
+
* @param {{ projects: {name: string, root: string}[], filesOf: (name: string) => string[],
|
|
249
|
+
* readFile: (path: string) => string|null }} workspace
|
|
250
|
+
* @returns {{ entries: CsprojEntry[], identity: Map<string, string>,
|
|
251
|
+
* usingEdges: { source: string, target: string, sourceFile: string, type: string }[],
|
|
252
|
+
* failures: { sourceFile: string, line: null, column: null, reason: string }[] }}
|
|
253
|
+
*/
|
|
254
|
+
export const csprojModelOf = perWorkspace(({ projects, filesOf, readFile }) => {
|
|
255
|
+
const entries = [];
|
|
256
|
+
const failures = [];
|
|
257
|
+
const identity = new Map();
|
|
258
|
+
|
|
259
|
+
for (const project of projects) {
|
|
260
|
+
for (const file of filesOf(project.name)) {
|
|
261
|
+
if (!file.endsWith(".csproj")) continue;
|
|
262
|
+
const text = readFile(file);
|
|
263
|
+
if (text === null) {
|
|
264
|
+
failures.push(fileFailure(file, "csproj could not be read"));
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const result = csprojEntryOf(project.name, file, text);
|
|
268
|
+
if (result.reason !== undefined) {
|
|
269
|
+
failures.push(fileFailure(file, `its .csproj cannot be fully read: ${result.reason}`));
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
for (const problem of result.problems ?? []) {
|
|
273
|
+
failures.push(fileFailure(file, `its .csproj declares ${problem}`));
|
|
274
|
+
}
|
|
275
|
+
entries.push(result.entry);
|
|
276
|
+
identity.set(file, project.name);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Second pass, once every identity is known: a declared reference landing
|
|
281
|
+
// on no tracked project's csproj is a hole in the model, not an external
|
|
282
|
+
// package — external is `<PackageReference>`'s shape — and reading it as
|
|
283
|
+
// "no edge" would drop a declared dependency silently.
|
|
284
|
+
for (const entry of entries) {
|
|
285
|
+
for (const refPath of entry.projectRefPaths) {
|
|
286
|
+
if (identity.has(refPath)) continue;
|
|
287
|
+
failures.push(
|
|
288
|
+
fileFailure(
|
|
289
|
+
entry.csprojPath,
|
|
290
|
+
`its .csproj references '${refPath}', which no tracked project owns`,
|
|
291
|
+
),
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// `<Using>` namespaces resolve through the same index the analyzer reads,
|
|
297
|
+
// so one map answers who owns a name at every layer. An ambiguous owner
|
|
298
|
+
// fails loudly like a directive's ambiguity would; an external namespace
|
|
299
|
+
// (the SDK-fixed set and every NuGet default) draws nothing.
|
|
300
|
+
const { byName: index } = csharpNamespaceIndex({ projects, filesOf, readFile });
|
|
301
|
+
const usingEdges = [];
|
|
302
|
+
for (const entry of entries) {
|
|
303
|
+
for (const namespace of entry.usingNamespaces) {
|
|
304
|
+
const resolved = resolveCsharpSpecifier(namespace, index);
|
|
305
|
+
if (resolved.external) continue;
|
|
306
|
+
if (resolved.ambiguous) {
|
|
307
|
+
failures.push(
|
|
308
|
+
fileFailure(
|
|
309
|
+
entry.csprojPath,
|
|
310
|
+
`its <Using Include="${namespace}"> names '${resolved.matchedPrefix}', ` +
|
|
311
|
+
`declared by more than one project (${resolved.ambiguous.join(", ")})`,
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (resolved.target === entry.projectName) continue;
|
|
317
|
+
usingEdges.push({
|
|
318
|
+
source: entry.projectName,
|
|
319
|
+
target: resolved.target,
|
|
320
|
+
sourceFile: entry.csprojPath,
|
|
321
|
+
type: "static",
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { entries, identity, usingEdges, failures };
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Graph edges from a workspace's `.csproj` files: one per declared
|
|
331
|
+
* `<ProjectReference>`, plus one per `<Using Include>` that names another
|
|
332
|
+
* tracked project's namespace. The two spellings of one dependency from the
|
|
333
|
+
* same csproj yield ONE edge — the declared reference is the provenance
|
|
334
|
+
* worth keeping. A model recording any could-not-complete failure refuses
|
|
335
|
+
* the whole graph (#364's posture, `../source-util.mjs`'s `refuseUnreadTree`)
|
|
336
|
+
* — silently omitting the affected edges is the under-selecting `nx affected`
|
|
337
|
+
* this plugin exists to close.
|
|
338
|
+
*
|
|
339
|
+
* @param {{ projects: {name: string, root: string}[], filesOf: (name: string) => string[],
|
|
340
|
+
* readFile: (path: string) => string|null }} workspace
|
|
341
|
+
* @returns {{ source: string, target: string, sourceFile: string, type: string }[]}
|
|
342
|
+
* @throws {Error} when `csprojModelOf` recorded any failure, naming each
|
|
343
|
+
* csproj.
|
|
344
|
+
*/
|
|
345
|
+
export function resolveCsprojDependencies(workspace) {
|
|
346
|
+
const model = csprojModelOf(workspace);
|
|
347
|
+
refuseUnreadTree("the .csproj model", model.failures);
|
|
348
|
+
const deps = [];
|
|
349
|
+
const seen = new Set();
|
|
350
|
+
for (const entry of model.entries) {
|
|
351
|
+
for (const refPath of entry.projectRefPaths) {
|
|
352
|
+
const target = model.identity.get(refPath);
|
|
353
|
+
// `undefined` is unreachable while the refusal above holds (a dangling
|
|
354
|
+
// reference is a recorded failure); kept as the belt beneath it.
|
|
355
|
+
if (target === undefined || target === entry.projectName) continue;
|
|
356
|
+
seen.add(`${entry.projectName}${target}${entry.csprojPath}`);
|
|
357
|
+
deps.push({
|
|
358
|
+
source: entry.projectName,
|
|
359
|
+
target,
|
|
360
|
+
sourceFile: entry.csprojPath,
|
|
361
|
+
type: "static",
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
for (const edge of model.usingEdges) {
|
|
366
|
+
if (seen.has(`${edge.source}${edge.target}${edge.sourceFile}`)) continue;
|
|
367
|
+
deps.push(edge);
|
|
368
|
+
}
|
|
369
|
+
return deps;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Whole-file failures for every .csproj this reader could not fully judge.
|
|
374
|
+
*
|
|
375
|
+
* @param {object} workspace
|
|
376
|
+
* @returns {{ sourceFile: string, line: null, column: null, reason: string }[]}
|
|
377
|
+
*/
|
|
378
|
+
export function dotnetManifestFailures(workspace) {
|
|
379
|
+
return csprojModelOf(workspace).failures;
|
|
380
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dotnet lexical mask — the shared scanner both the C# frontend and the
|
|
3
|
+
* namespace index read through. It follows the same length-preserving
|
|
4
|
+
* discipline as `../jvm/mask.mjs` and `../go.mjs`: the result is byte-for-byte
|
|
5
|
+
* the same length with every line break in place, so an offset into the mask
|
|
6
|
+
* is the same offset into the original, and a regex over the mask cannot see
|
|
7
|
+
* a comment's or a literal's contents — a commented-out or quoted `using` is
|
|
8
|
+
* text, not a written directive.
|
|
9
|
+
*
|
|
10
|
+
* Why a separate scanner rather than another row in the JVM dialect table:
|
|
11
|
+
* that table parameterizes exactly two facts ({nests}, {tripleEscapes}) and
|
|
12
|
+
* C# differs beyond both knobs. Its literal grammar adds verbatim strings,
|
|
13
|
+
* interpolated strings, and raw strings with variable-length quote delimiters,
|
|
14
|
+
* and folding those into a table wide enough to serve Java and Kotlin too
|
|
15
|
+
* would misread one of them — the reason the JVM module argues its own
|
|
16
|
+
* parameterization is "the honesty". Mirroring the module, not importing it,
|
|
17
|
+
* keeps each family's scanner readable against its own language's spec.
|
|
18
|
+
*
|
|
19
|
+
* Per construct, what this scanner knows and why:
|
|
20
|
+
*
|
|
21
|
+
* | Construct | Rule here |
|
|
22
|
+
* |----------------------|-------------------------------------------------------|
|
|
23
|
+
* | line comment | `//` to end of line |
|
|
24
|
+
* | block comment | `/* .. *`+`/`, does NOT nest |
|
|
25
|
+
* | string | `"…"` with `\` escapes, one line |
|
|
26
|
+
* | char | `'…'` with `\` escapes |
|
|
27
|
+
* | verbatim string | `@"…"` / `$@"…"` / `@$"…`: `""` doubles a quote,
|
|
28
|
+
* | | no `\` escape, spans lines |
|
|
29
|
+
* | interpolated string | `$"…"`: escapes like `"…"`; HOLES NOT MODELED (below) |
|
|
30
|
+
* | raw string | `"""…"""` and wider: opener is the maximal quote run,
|
|
31
|
+
* | | closer is the first run at least as long, no escapes |
|
|
32
|
+
*
|
|
33
|
+
* Block comments do not nest in C# — the same load-bearing fact the JVM
|
|
34
|
+
* scanner pins for Java: a doc comment quoting a snippet must not swallow the
|
|
35
|
+
* real code below its first closer.
|
|
36
|
+
*
|
|
37
|
+
* Interpolation holes are deliberately not modeled. A hole holds an
|
|
38
|
+
* expression, and an expression cannot contain a directive, so treating the
|
|
39
|
+
* hole's quotes as ordinary string delimiters is safe in the direction that
|
|
40
|
+
* matters: the quotes pair off around short fragments of expression text, and
|
|
41
|
+
* no fragment can spell `using X.Y;`. The worst case is a spurious record
|
|
42
|
+
* naming text the file really contains — reachable only by quoting the words
|
|
43
|
+
* of a directive inside a hole, which valid C# cannot execute into existence.
|
|
44
|
+
* What the naive pairing must never do is overshoot a true terminator and
|
|
45
|
+
* swallow REAL code below, and it cannot: an overshoot requires an unpaired
|
|
46
|
+
* quote, which valid C# does not contain.
|
|
47
|
+
*
|
|
48
|
+
* Raw strings take the maximal quote run as their opener and end at the first
|
|
49
|
+
* later run at least as long — the spec's own termination rule. An
|
|
50
|
+
* unterminated raw string masks to end of file, which is the same declared
|
|
51
|
+
* posture the JVM scanner takes for an unterminated text block: the file does
|
|
52
|
+
* not compile, and degrading a malformed input by masking its remainder is
|
|
53
|
+
* named here rather than discovered by whoever meets it.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Every character of `text` except its line breaks, replaced by a space.
|
|
58
|
+
*/
|
|
59
|
+
const blankOut = (text) => text.replace(/[^\n]/g, " ");
|
|
60
|
+
|
|
61
|
+
/** Escape-walk over a `"…"` / `'…'` literal body: `\` skips the next char. */
|
|
62
|
+
const escapedStringLength = (text, start, quote) => {
|
|
63
|
+
let at = start + 1;
|
|
64
|
+
while (at < text.length && text[at] !== quote && text[at] !== "\n") {
|
|
65
|
+
at += text[at] === "\\" ? 2 : 1;
|
|
66
|
+
}
|
|
67
|
+
return Math.min(text[at] === quote ? at + 1 : at, text.length) - start;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Length of the non-nesting block comment (or unterminated run) at `start`. */
|
|
71
|
+
const blockCommentLength = (text, start) => {
|
|
72
|
+
// A second `/*` inside the open comment is prose; skip it without counting,
|
|
73
|
+
// so the first `*/` still ends the comment — the Java rule, for the same
|
|
74
|
+
// reason: XML-doc comments quote snippets, and nesting would eat the code
|
|
75
|
+
// below them.
|
|
76
|
+
let at = start + 2;
|
|
77
|
+
while (at < text.length && !text.startsWith("*/", at)) at++;
|
|
78
|
+
return Math.min(at + 2, text.length) - start;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Length of the `@"…"` verbatim body opened at `start` (`openPrefix` = `@"`, `$@"`, `@$"`). */
|
|
82
|
+
const verbatimStringLength = (text, start, openLength) => {
|
|
83
|
+
let at = start + openLength;
|
|
84
|
+
while (at < text.length) {
|
|
85
|
+
if (text[at] === '"') {
|
|
86
|
+
if (text[at + 1] === '"') {
|
|
87
|
+
at += 2;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
return at + 1 - start;
|
|
91
|
+
}
|
|
92
|
+
at++;
|
|
93
|
+
}
|
|
94
|
+
return text.length - start;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Number of consecutive quotes at `start` (at least one — the caller matched one). */
|
|
98
|
+
const quoteRunLength = (text, start) => {
|
|
99
|
+
let at = start;
|
|
100
|
+
while (text[at] === '"') at++;
|
|
101
|
+
return at - start;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** Length of the raw string opened at `start` by an `openerRun`-wide quote run. */
|
|
105
|
+
const rawStringLength = (text, start, openerRun) => {
|
|
106
|
+
let at = start + openerRun;
|
|
107
|
+
while (at < text.length) {
|
|
108
|
+
if (text[at] === '"') {
|
|
109
|
+
const run = quoteRunLength(text, at);
|
|
110
|
+
if (run >= openerRun) return at + run - start;
|
|
111
|
+
at += run;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
at++;
|
|
115
|
+
}
|
|
116
|
+
return text.length - start;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Lexical starts, in the order the alternation settles them: comments first,
|
|
121
|
+
* then the `@`-marked verbatim spellings (which may carry `$` on either side),
|
|
122
|
+
* then a `$"` whose next char is not a quote — the guard keeps the `$` of a
|
|
123
|
+
* `$$"""…"""` raw-interpolated opener from matching as an ordinary
|
|
124
|
+
* interpolated string — and finally either quote character, which the
|
|
125
|
+
* dispatcher classifies by counting the run behind it.
|
|
126
|
+
*/
|
|
127
|
+
const LEXICAL_START = /\/\/|\/\*|\$?@"|@\$"|\$"(?!")|["']/g;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `sourceText` with every comment AND literal blanked out, same length, line
|
|
131
|
+
* breaks in place. See the module header for each construct's rule.
|
|
132
|
+
*
|
|
133
|
+
* Literals are blanked here where Go's mask keeps its raw strings intact —
|
|
134
|
+
* the opposite choice on purpose, for the same reason the JVM scanner gives:
|
|
135
|
+
* a C# directive is bare words after its keyword, so every literal body can
|
|
136
|
+
* only plant spurious declarations into text that scans like code, while a
|
|
137
|
+
* directive never lives inside one.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} sourceText
|
|
140
|
+
* @returns {string} Same length as `sourceText`.
|
|
141
|
+
*/
|
|
142
|
+
export function maskCSharpComments(sourceText) {
|
|
143
|
+
const scan = new RegExp(LEXICAL_START.source, "g");
|
|
144
|
+
let masked = "";
|
|
145
|
+
let copied = 0;
|
|
146
|
+
let match;
|
|
147
|
+
while ((match = scan.exec(sourceText)) !== null) {
|
|
148
|
+
const start = match.index;
|
|
149
|
+
const token = match[0];
|
|
150
|
+
let end;
|
|
151
|
+
if (token === "//") {
|
|
152
|
+
const newline = sourceText.indexOf("\n", start);
|
|
153
|
+
end = newline === -1 ? sourceText.length : newline;
|
|
154
|
+
} else if (token === "/*") {
|
|
155
|
+
end = start + blockCommentLength(sourceText, start);
|
|
156
|
+
} else if (token.endsWith('@"')) {
|
|
157
|
+
// `@"` and `$@"` — the `$` changes interpolation, not termination.
|
|
158
|
+
end = start + verbatimStringLength(sourceText, start, token.length);
|
|
159
|
+
} else if (token === '@$"') {
|
|
160
|
+
end = start + verbatimStringLength(sourceText, start, token.length);
|
|
161
|
+
} else {
|
|
162
|
+
// A quote: the run behind it decides raw versus ordinary; a char
|
|
163
|
+
// literal takes the escaped walk with its own quote, so a `'"'` cannot
|
|
164
|
+
// open a phantom string that swallows the code below it.
|
|
165
|
+
const quote = token;
|
|
166
|
+
const run = quote === '"' ? quoteRunLength(sourceText, start) : 1;
|
|
167
|
+
end =
|
|
168
|
+
run >= 3
|
|
169
|
+
? start + rawStringLength(sourceText, start, run)
|
|
170
|
+
: start + escapedStringLength(sourceText, start, quote);
|
|
171
|
+
}
|
|
172
|
+
// Newlines survive everywhere; every other byte of the span goes.
|
|
173
|
+
masked += sourceText.slice(copied, start) + blankOut(sourceText.slice(start, end));
|
|
174
|
+
copied = end;
|
|
175
|
+
scan.lastIndex = end;
|
|
176
|
+
}
|
|
177
|
+
return masked + sourceText.slice(copied);
|
|
178
|
+
}
|