@ecoma-io/archkeep 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +3 -3
  2. package/cli.mjs +126 -4
  3. package/commands.mjs +6 -0
  4. package/lsp.mjs +15 -2
  5. package/package.json +6 -2
  6. package/src/analysis/analyze.mjs +15 -0
  7. package/src/analysis/contract.md +36 -18
  8. package/src/analysis/csharp.mjs +514 -0
  9. package/src/analysis/dotnet/csproj.mjs +380 -0
  10. package/src/analysis/dotnet/mask.mjs +178 -0
  11. package/src/analysis/dotnet/namespaces.mjs +172 -0
  12. package/src/analysis/dotnet/resolve.mjs +89 -0
  13. package/src/analysis/go.mjs +303 -5
  14. package/src/analysis/java.mjs +329 -0
  15. package/src/analysis/jvm/gradle.mjs +545 -0
  16. package/src/analysis/jvm/mask.mjs +170 -0
  17. package/src/analysis/jvm/maven.mjs +612 -0
  18. package/src/analysis/jvm/packages.mjs +209 -0
  19. package/src/analysis/jvm/resolve.mjs +139 -0
  20. package/src/analysis/kotlin.mjs +210 -0
  21. package/src/analysis/manifest-util.mjs +30 -0
  22. package/src/analysis/python.mjs +3 -2
  23. package/src/analysis/registry.mjs +11 -0
  24. package/src/analysis/rust.mjs +171 -17
  25. package/src/analysis/source-util.mjs +155 -6
  26. package/src/analysis/typescript.mjs +9 -2
  27. package/src/commands/context.mjs +84 -14
  28. package/src/commands/provenance.mjs +7 -44
  29. package/src/commands/rules.mjs +775 -0
  30. package/src/governance/profile-registry.mjs +0 -1
  31. package/src/graph/create-dependencies.mjs +138 -15
  32. package/src/lsp/diagnose.mjs +1 -1
  33. package/src/lsp/server.mjs +97 -1
  34. package/src/lsp/workspace-index.mjs +106 -15
  35. package/src/options.mjs +30 -7
  36. package/src/process.mjs +10 -1
  37. package/src/providers/moon.mjs +287 -36
  38. package/src/providers/native/differential.fixtures.mjs +32 -6
  39. package/src/providers/native/discover.mjs +83 -4
  40. package/src/providers/native/graph.mjs +58 -0
  41. package/src/providers/native/model.mjs +59 -1
  42. package/src/rules/index.mjs +21 -6
  43. package/src/rules/reachability.mjs +2 -0
  44. package/src/rules/tags.mjs +7 -5
  45. package/src/rules/topology.mjs +5 -3
  46. package/src/workspace.mjs +115 -23
@@ -0,0 +1,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
+ }