@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,514 @@
1
+ /**
2
+ * C# analyzer — directive extraction over comment-and-literal-masked text,
3
+ * sharing the dotnet core with the namespace index exactly the way
4
+ * `./java.mjs` shares the JVM one (`docs/adr/0006-dotnet-language-integration.md`).
5
+ *
6
+ * Static analysis only, no .NET SDK required (`docs/reference/languages.md`
7
+ * owns why graphs compute on machines with no toolchain). C# fixes five
8
+ * written directive forms plus one alias-adjacent spelling:
9
+ *
10
+ * using X.Y.Z; namespace (whole-namespace import)
11
+ * using static X.Y.Z.Type; static members of a type
12
+ * using Alias = X.Y.Z; namespace/type alias
13
+ * using Alias = X.Y.Z.Type; type alias — resolves by its base
14
+ * global using X.Y.Z; file-set-wide (C# 10+)
15
+ * extern alias X; externally supplied root alias
16
+ *
17
+ * Every dotted subject may carry the `global::` qualifier — `using
18
+ * global::X.Y;`, `using static global::X.Y.T;`, `using A = global::X.Y;` —
19
+ * legal wherever a local name shadows a namespace, and common in generated
20
+ * code. The qualifier is syntax around the subject, so it is stripped before
21
+ * classification and stays out of the specifier, the same way an alias's own
22
+ * name does: `imp` must equal `packageName` for an external ban to fire.
23
+ *
24
+ * A UTF-8 BOM is matched, never stripped — the same byte
25
+ * `./dotnet/namespaces.mjs` and the JVM package declaration tolerate (#221's
26
+ * lesson, `../jvm/packages.mjs`): a first-line directive behind one is read,
27
+ * and every offset this parse returns stays an offset into the bytes on disk
28
+ * (`../contract.md`'s byte-tolerance law). A directive may sit wherever a
29
+ * fresh declaration may — after `;`, `{` or `}` on the same line, so
30
+ * `namespace N { using A.B; }` is read — besides the line head.
31
+ *
32
+ * There is no wildcard form (a plain `using` already imports a whole
33
+ * namespace), no dynamic form, no re-export syntax: `kind` is always
34
+ * `"static"`, and `spelling.path` is always `false` because no C# directive is
35
+ * spelled as a filesystem path — `spelling.namesOnly` is always `true`, so a
36
+ * namespace whose root is literally named `libs` is a name, not a path into a
37
+ * project (#376). `spelling.relative` takes Go's argued answer —
38
+ * true exactly when the directive resolved into its own project — because no
39
+ * C# spelling is relative either, so the bit reads what a directive REACHED
40
+ * rather than how it was written.
41
+ *
42
+ * Using STATEMENTS share their first word with directives and are excluded by
43
+ * shape, not by guesswork: `using var s = …;`, `using (r) { … }` and
44
+ * `using StreamReader r = …;` all fail the body grammars below, which accept
45
+ * exactly a dotted name, `static` plus a dotted name, or ONE identifier, an
46
+ * equals sign, and a right-hand side. Anything else is a statement and stays
47
+ * unread — a resource variable's type would otherwise read as an import of
48
+ * itself.
49
+ *
50
+ * Alias right-hand sides resolve by their generic-free base: `using Grid =
51
+ * Corp.Domain.Grid<int>;` reaches `Corp.Domain.Grid`'s project through the
52
+ * base name, because the constructed type lives where its definition lives.
53
+ * A right-hand side that never reduces to a dotted name — a tuple alias,
54
+ * `using Pair = (int, string);` — introduces no cross-project name at all and
55
+ * classifies external with the written right-hand side standing in as
56
+ * `packageName`.
57
+ *
58
+ * Known parse limits, deliberate and pinned by tests, each erring toward a
59
+ * record naming text the file really contains or toward a documented silence,
60
+ * never toward a wrong project:
61
+ *
62
+ * - A **multi-line directive** (`using A.B.\n C;`) is not read: the body
63
+ * must sit on its own line, terminated by `;`. Every formatter writes one
64
+ * line; the miss is compensated by the manifest resolver's independent
65
+ * edges (`./dotnet/csproj.mjs`).
66
+ * - **Attribute references, inline fully-qualified names and reflection**
67
+ * reach types without any directive; extraction cannot see them. Documented
68
+ * limits whose compensation is the manifest track and tag law.
69
+ * - **Verbatim identifiers** (`@class`) inside a directive are not read — the
70
+ * same class of limit Kotlin's backtick segments pin.
71
+ * - An **unterminated directive** (no `;` before end of line) is not a
72
+ * complete directive and is not read.
73
+ * - A **statement whose initializer opens but never closes** (`using var x =
74
+ * new F {`, the `;` never arriving) is neither read nor flagged — the `{`
75
+ * behind the resource's `=` is the statement's own (#469), the same
76
+ * silence a `using (r) {` block has always held. The malformation scan
77
+ * judges directives only.
78
+ */
79
+ import { csharpNamespaceIndex } from "./dotnet/namespaces.mjs";
80
+ import { maskCSharpComments } from "./dotnet/mask.mjs";
81
+ import { resolveCsharpSpecifier } from "./dotnet/resolve.mjs";
82
+ import {
83
+ emptyResult,
84
+ fileFailure,
85
+ perWorkspace,
86
+ positionAt,
87
+ projectOwning,
88
+ refuseUnreadTree,
89
+ } from "./source-util.mjs";
90
+
91
+ /**
92
+ * One identifier segment. Verbatim identifiers (`@class`) are deliberately
93
+ * outside this grammar — see the limits above.
94
+ */
95
+ const SEG = String.raw`[\p{L}_][\p{L}\p{Nd}_]*`;
96
+ const DOTTED_NAME = `${SEG}(?:\\.${SEG})*`;
97
+
98
+ /**
99
+ * The body of a using directive, captured up to its terminating semicolon.
100
+ * The anchor accepts a fresh declaration's every legal predecessor — line
101
+ * head (through a UTF-8 BOM), `;`, `{`, `}` — so `namespace N { using A.B; }`
102
+ * on one line is read like any formatted tree. The semicolon is a lookahead,
103
+ * never part of the match (#407): consumed, the scan resumed PAST it, and the
104
+ * `;` before a second same-line directive — its only legal anchor — was
105
+ * already behind it, so `using A.B; using C.D;` read only `A.B`.
106
+ */
107
+ const CS_USING_BODY = new RegExp(
108
+ String.raw`(?:^\uFEFF?|[\n;{}])[ \t]*(?:global[ \t]+)?using[ \t]+([^;\n]+?)[ \t]*(?=;)`,
109
+ "gu",
110
+ );
111
+
112
+ /**
113
+ * The directive openers without their bodies — the heads the malformation
114
+ * scan (`csharpDirectiveMalformations`) anchors on. They live beside the
115
+ * regexes they mirror rather than inside the scan, because the two must
116
+ * agree about where a directive opens: a head that matched MORE than the
117
+ * body regex would flag a file the body regexes read fully.
118
+ */
119
+ const CS_USING_BODY_HEAD = new RegExp(
120
+ String.raw`(?:^\uFEFF?|[\n;{}])[ \t]*(?:global[ \t]+)?using[ \t]+`,
121
+ "gu",
122
+ );
123
+
124
+ /** The extern-alias opener, for the same scan. */
125
+ const CS_EXTERN_ALIAS_HEAD = new RegExp(
126
+ String.raw`(?:^\uFEFF?|[\n;{}])[ \t]*extern[ \t]+alias[ \t]+`,
127
+ "gu",
128
+ );
129
+ /** The extern-alias directive: `extern alias X;` — recorded, resolved as external. */
130
+ const CS_EXTERN_ALIAS = new RegExp(
131
+ String.raw`(?:^\uFEFF?|[\n;{}])[ \t]*extern[ \t]+alias[ \t]+(${SEG})[ \t]*(?=;)`,
132
+ "gu",
133
+ );
134
+
135
+ /** Exactly one identifier followed by `=`: the alias form. */
136
+ const ALIAS_FORM = new RegExp(String.raw`^(${SEG})[ \t]*=[ \t]*(.+)$`, "su");
137
+
138
+ /**
139
+ * One bare identifier, optionally verbatim (`@class`): an alias's own name.
140
+ * The malformation scan reads it backwards — the text before an initializer
141
+ * `=` is an alias's name only when it is NOT this shape.
142
+ */
143
+ const ALIAS_NAME = new RegExp(String.raw`^@?${SEG}$`, "u");
144
+
145
+ /** A dotted name, optionally behind the `global::` qualifier: the plain form. */
146
+ const PLAIN_FORM = new RegExp(String.raw`^(?:global::)?(${DOTTED_NAME})$`, "u");
147
+
148
+ /** `static` plus an optionally qualified dotted name: the static-members form. */
149
+ const STATIC_FORM = new RegExp(String.raw`^static[ \t]+(?:global::)?(${DOTTED_NAME})$`, "u");
150
+
151
+ /**
152
+ * Strips one balanced trailing generic argument list from an alias's
153
+ * right-hand side: `Corp.Domain.Grid<int>` becomes `Corp.Domain.Grid`.
154
+ * Unbalanced brackets are left alone — the caller then classifies the raw
155
+ * text external rather than guessing where the type began.
156
+ *
157
+ * @param {string} rhs
158
+ * @returns {string}
159
+ */
160
+ function withoutGenericArguments(rhs) {
161
+ const open = rhs.indexOf("<");
162
+ if (open === -1 || !rhs.endsWith(">")) return rhs;
163
+ let depth = 0;
164
+ for (let at = open; at < rhs.length; at++) {
165
+ if (rhs[at] === "<") depth++;
166
+ else if (rhs[at] === ">") {
167
+ depth--;
168
+ if (depth === 0) return at === rhs.length - 1 ? rhs.slice(0, open) : rhs;
169
+ }
170
+ }
171
+ return rhs;
172
+ }
173
+
174
+ /**
175
+ * Classifies one directive body into what resolution may see.
176
+ *
177
+ * The SPECIFIER is the directive's SUBJECT — the dotted name a plain/static
178
+ * directive imports, or an alias's right-hand side — never the statement's
179
+ * form words. That is not style: the `bannedExternalImports` family matches
180
+ * its globs against the specifier and requires it to equal the resolved
181
+ * package name (or a `/`-beneath path of it), so a specifier carrying
182
+ * `static ` or `Alias = ` would silently exempt every static and aliased
183
+ * crossing from every external ban — the exact direction this repository
184
+ * exists to close. Form information lives in the directive's shape, which any
185
+ * reader of the line sees anyway.
186
+ *
187
+ * @param {string} body The trimmed text between `using` and `;`.
188
+ * @returns {{ specifier: string, importableName: string|null, specifierStartInBody: number }|null} `null`
189
+ * when the body is a using STATEMENT's shape, not a directive's.
190
+ */
191
+ export function classifyUsingBody(body) {
192
+ const trimmed = body.trim();
193
+ if (trimmed === "") return null;
194
+ const staticForm = STATIC_FORM.exec(trimmed);
195
+ if (staticForm) {
196
+ return {
197
+ specifier: staticForm[1],
198
+ importableName: staticForm[1],
199
+ specifierStartInBody: trimmed.indexOf(staticForm[1]),
200
+ };
201
+ }
202
+ const plainForm = PLAIN_FORM.exec(trimmed);
203
+ if (plainForm) {
204
+ return {
205
+ specifier: plainForm[1],
206
+ importableName: plainForm[1],
207
+ specifierStartInBody: trimmed.indexOf(plainForm[1]),
208
+ };
209
+ }
210
+ const aliasForm = ALIAS_FORM.exec(trimmed);
211
+ if (aliasForm) {
212
+ // The alias name is local syntax; only the right-hand side can cross a
213
+ // boundary, so the right-hand side IS the specifier. A constructed
214
+ // generic keeps its generic-free base as both specifier and importable,
215
+ // because `imp` (the specifier the rule matches against globs) must
216
+ // equal `packageName` for `isConstraintBanningProject` to fire. The
217
+ // `global::` qualifier is stripped with the alias name for the same
218
+ // reason — it is syntax around the subject, not part of it.
219
+ const rhs = aliasForm[2].trim().replace(/^global::/, "");
220
+ const base = withoutGenericArguments(rhs);
221
+ const importableName = new RegExp(`^${DOTTED_NAME}$`, "u").test(base) ? base : null;
222
+ const specifier = importableName ?? rhs;
223
+ return { specifier, importableName, specifierStartInBody: trimmed.indexOf(specifier) };
224
+ }
225
+ // `var s = …`, `Type r = …`, `(expr)` — statement shapes stay unread.
226
+ return null;
227
+ }
228
+
229
+ /**
230
+ * Every directive in a `.cs` file, in source order and WITHOUT deduplication —
231
+ * one entry per written directive, which is what an import-site record is.
232
+ * Offsets index the ORIGINAL text and point at the specifier's own start, so
233
+ * the reported column is where the written name begins.
234
+ *
235
+ * @param {string} csharpText Raw file contents.
236
+ * @returns {{ specifier: string, importableName: string|null, offset: number }[]}
237
+ */
238
+ export function parseCSharpDirectiveSites(csharpText) {
239
+ const source = maskCSharpComments(csharpText);
240
+ const sites = [];
241
+ for (const match of source.matchAll(CS_USING_BODY)) {
242
+ const classified = classifyUsingBody(match[1]);
243
+ if (!classified) continue;
244
+ sites.push({
245
+ specifier: classified.specifier,
246
+ importableName: classified.importableName,
247
+ offset: match.index + match[0].indexOf(match[1]) + classified.specifierStartInBody,
248
+ });
249
+ }
250
+ for (const match of source.matchAll(CS_EXTERN_ALIAS)) {
251
+ sites.push({
252
+ specifier: match[1],
253
+ // Extern aliases supply a ROOT name from outside the compilation's
254
+ // sources — resolution against the tracked tree cannot mean anything,
255
+ // so the site records the alias's own name and classifies external
256
+ // (documented limit). The name, not `extern alias X`, is the specifier:
257
+ // form words would silently exempt the site from every external ban.
258
+ importableName: null,
259
+ offset: match.index + match[0].indexOf(match[1]),
260
+ });
261
+ }
262
+ return sites.sort((a, b) => a.offset - b.offset);
263
+ }
264
+
265
+ /**
266
+ * Why a `.cs` file's directives cannot be fully read, as reasons for
267
+ * `analyzeCSharp` to record as whole-file failures (`contract.md`): a `using`
268
+ * or `extern alias` directive that never reaches its `;`.
269
+ *
270
+ * The detection mirrors the directive regexes' own failure conditions, so a
271
+ * file they read fully is never flagged. The openers are the body regexes'
272
+ * own heads, and the `;` is required to arrive before the next `{`: the brace
273
+ * a type body opens with separates a directive that terminated (`;` first)
274
+ * from one the file truncates — a failed write, a merge marker left
275
+ * mid-directive — which used to parse as zero directive sites with no
276
+ * failure, byte-for-byte identical to a file that imports nothing (#419).
277
+ * Statement shapes hold no opinion at all, by two markers the text shows
278
+ * before any terminator arrives: a `(` right after the keyword is the
279
+ * parenthesized family — `using (var s = f()) { … }`, `using (x);` — and an
280
+ * `=` between the keyword and the walk's first `{` is the declaration family
281
+ * (#469) whenever MORE than one bare identifier precedes it — `var writer`,
282
+ * `Dictionary<string, int> map`, every declaration spelling — because a
283
+ * brace behind a resource's own `=` is that statement's initializer, while
284
+ * the first `{` behind a directive can only be the file's truncation. One
285
+ * bare identifier before the `=` is an ALIAS's own name (`using Pair = …`),
286
+ * the brace then belongs to whatever follows, and the truncated alias stays
287
+ * loud. A statement's `;` may arrive inside its own
288
+ * block, so the scan has no opinion there. One silence the rule keeps: a
289
+ * missing `;` that a LATER declaration supplies its own (`extern alias X`
290
+ * then `namespace Shop.App;`) is not seen — the walk reads the next
291
+ * terminator, and attributing a `;` to its declaration is parser work the
292
+ * head regexes do not carry. The same mask the Java scan's terminator walk
293
+ * keeps.
294
+ *
295
+ * The mask runs first (the same `maskCSharpComments` the site parser runs),
296
+ * so directive-shaped text inside strings, raw strings and comments is never
297
+ * read as directive syntax and a compiling file is never reported as broken.
298
+ *
299
+ * The Go posture `goImportMalformations` set (#419's sibling audit): shapes
300
+ * the regexes answer are the documented parse limits; the shape they cannot
301
+ * answer is the failure.
302
+ *
303
+ * @param {string} csharpText Raw file contents.
304
+ * @returns {string[]} At most one reason per kind — `using`, `extern alias` —
305
+ * each naming its line. Empty when the directives read fully.
306
+ */
307
+ export function csharpDirectiveMalformations(csharpText) {
308
+ const source = maskCSharpComments(csharpText);
309
+ /** @type {string[]} */
310
+ const reasons = [];
311
+ const flagged = new Set();
312
+ const flag = (offset, kind, reason) => {
313
+ if (flagged.has(kind)) return;
314
+ flagged.add(kind);
315
+ reasons.push(`${reason} (line ${positionAt(csharpText, offset).line})`);
316
+ };
317
+ // `;` and `{` ascend with the text, and so do the openers, so each arm
318
+ // walks the same terminator list with its own forward cursor — one pass per
319
+ // arm, not an `indexOf` per opener that rescans the tail (`.cs` content is
320
+ // attacker-supplied per SECURITY.md). Both arms hold ONE rule, the using
321
+ // arm's: the directive's own `;` must arrive before the next `{`. An
322
+ // `indexOf`-style "a `;` exists somewhere later" is what the alias arm
323
+ // briefly held, and it is the weaker claim — a LATER declaration's `;`
324
+ // (`extern alias X` then `namespace Shop.App;`) masked the truncation and
325
+ // the silent direction survived it.
326
+ const terminators = [...source.matchAll(/[;{]/g)];
327
+ const terminatorAfter = () => {
328
+ let cursor = 0;
329
+ return (at) => {
330
+ while (cursor < terminators.length && terminators[cursor].index < at) cursor += 1;
331
+ return terminators[cursor];
332
+ };
333
+ };
334
+ // The matched spans start at their ANCHORS (a `\n`, `;`, `{` or `}`), so
335
+ // each reason locates the keyword inside the span rather than pointing at
336
+ // m.index — the anchor names the PREVIOUS line when it is a `\n`, and a
337
+ // diagnostic naming the wrong line sends every reader to the wrong
338
+ // directive. The same locate-it move `parseCSharpDirectiveSites` makes for
339
+ // the specifier.
340
+ const usingTerminatorAfter = terminatorAfter();
341
+ for (const m of source.matchAll(CS_USING_BODY_HEAD)) {
342
+ const at = m.index + m[0].length;
343
+ if (source[at] === "(") continue;
344
+ const next = usingTerminatorAfter(at);
345
+ if (next === undefined || next[0] === "{") {
346
+ // The declaration family's initializer `=` (#469) — argued beside the
347
+ // openers above: an `=` whose prefix is more than one bare identifier
348
+ // is a resource's own, and the brace with it. One bare identifier is
349
+ // an alias's own name, the brace belongs to whatever follows, and the
350
+ // truncated alias stays loud.
351
+ const eq = next !== undefined ? source.indexOf("=", at) : -1;
352
+ const initializer =
353
+ eq !== -1 && eq < next.index && !ALIAS_NAME.test(source.slice(at, eq).trim());
354
+ if (next === undefined || !initializer) {
355
+ flag(
356
+ m.index + m[0].indexOf("using"),
357
+ "using",
358
+ "a `using` directive never reaches its `;` — the file is truncated or malformed, so its imports cannot be read",
359
+ );
360
+ }
361
+ }
362
+ }
363
+ const externTerminatorAfter = terminatorAfter();
364
+ for (const m of source.matchAll(CS_EXTERN_ALIAS_HEAD)) {
365
+ const next = externTerminatorAfter(m.index + m[0].length);
366
+ if (next === undefined || next[0] === "{") {
367
+ flag(
368
+ m.index + m[0].indexOf("extern"),
369
+ "extern alias",
370
+ "an `extern alias` never reaches its `;` — the file is truncated or malformed, so its imports cannot be read",
371
+ );
372
+ }
373
+ }
374
+ return reasons;
375
+ }
376
+
377
+ /**
378
+ * The workspace's namespace index, built once per workspace object — the same
379
+ * map the graph resolver below reads, both layers share one answer about
380
+ * who owns a name.
381
+ */
382
+ const csharpIndexOf = perWorkspace(csharpNamespaceIndex);
383
+
384
+ /**
385
+ * Analyzes one `.cs` file.
386
+ *
387
+ * An ambiguous namespace (two tracked projects declaring the same deepest
388
+ * matched prefix) resolves to `resolved: null` WITH a positioned failure
389
+ * naming both projects — ordinary C#, unresolvable by static reading, where
390
+ * picking either side would report violations against a guess. Intra-project
391
+ * directives are emitted as records (`contract.md`), with
392
+ * `spelling.relative` true exactly there.
393
+ *
394
+ * @param {{ sourceFile: string, text: string, workspace: object }} request
395
+ * @returns {{ imports: object[], failures: object[] }}
396
+ */
397
+ export function analyzeCSharp({ sourceFile, text, workspace }) {
398
+ const result = emptyResult();
399
+ try {
400
+ const { byName: index } = csharpIndexOf(workspace);
401
+ const owner = projectOwning(workspace.projects, sourceFile);
402
+ // A file truncated inside a directive used to parse as importing nothing,
403
+ // with no failure beside the empty result — the clean verdict over it was
404
+ // the bug (#419). The whole-file shape is what turns the verdict loud:
405
+ // `check` counts the file toward `unchecked` and refuses to call the run
406
+ // complete, instead of reporting a hole as a clean file.
407
+ for (const reason of csharpDirectiveMalformations(text)) {
408
+ result.failures.push(fileFailure(sourceFile, reason));
409
+ }
410
+ for (const site of parseCSharpDirectiveSites(text)) {
411
+ const { line, column } = positionAt(text, site.offset);
412
+ let resolution;
413
+ if (site.importableName === null) {
414
+ resolution = {
415
+ target: null,
416
+ file: null,
417
+ external: true,
418
+ packageName: site.specifier,
419
+ };
420
+ } else {
421
+ const resolved = resolveCsharpSpecifier(site.importableName, index);
422
+ if (resolved.external) {
423
+ // A name no tracked project claims: classified, never dropped, and
424
+ // deliberately NOT added as an externalNodes entry here — only
425
+ // project↔project edges matter to the graph (`AGENTS.md`).
426
+ resolution = {
427
+ target: null,
428
+ file: null,
429
+ external: true,
430
+ packageName: site.importableName,
431
+ };
432
+ } else if (resolved.ambiguous) {
433
+ resolution = null;
434
+ result.failures.push({
435
+ sourceFile,
436
+ line,
437
+ column,
438
+ reason:
439
+ `'${resolved.matchedPrefix}' is declared by more than one project ` +
440
+ `(${resolved.ambiguous.join(", ")}) — the compiler picks by reference order, ` +
441
+ `which this static reader does not model`,
442
+ });
443
+ } else {
444
+ resolution = { target: resolved.target, file: null, external: false, packageName: null };
445
+ }
446
+ }
447
+ const target = resolution?.target ?? null;
448
+ result.imports.push({
449
+ sourceFile,
450
+ line,
451
+ column,
452
+ specifier: site.specifier,
453
+ kind: "static",
454
+ spelling: {
455
+ path: false,
456
+ relative: target !== null && owner !== null && target === owner.name,
457
+ namesOnly: true,
458
+ },
459
+ resolved: resolution,
460
+ });
461
+ }
462
+ } catch (cause) {
463
+ result.failures.push(fileFailure(sourceFile, `C# analysis failed: ${cause?.message ?? cause}`));
464
+ }
465
+ return result;
466
+ }
467
+
468
+ /**
469
+ * Static edges between .NET projects derived from written directives — the
470
+ * source-truth half of the two-track principle. `../dotnet/csproj.mjs`'s
471
+ * ProjectReference resolver owns the manifest half; neither replaces the
472
+ * other. Takes the SAME workspace-shaped object that resolver receives: the
473
+ * namespace index both halves read is `perWorkspace`-cached on the object,
474
+ * so a graph computation builds it once no matter which half runs first.
475
+ *
476
+ * Returns raw Nx dependencies ({ source, target, sourceFile, type: "static" }).
477
+ * Ambiguous namespaces draw no edge — analysis reports them loudly instead,
478
+ * and an edge against a guess would be worse than the missing one. An
479
+ * unreadable `.cs` source refuses the whole graph (#364's posture — the
480
+ * index state corrupts every importer of its namespaces, so the failure
481
+ * cannot be attributed to the file's own edges), through the same
482
+ * `refuseUnreadTree` the manifest resolvers hold.
483
+ *
484
+ * @param {{ projects: {name: string, root: string}[], filesOf: (name: string) => string[],
485
+ * readFile: (path: string) => string|null }} workspace
486
+ * @returns {{ source: string, target: string, sourceFile: string, type: string }[]}
487
+ * @throws {Error} when `csharpNamespaceIndex` recorded any failure, naming
488
+ * each unreadable `.cs` source.
489
+ */
490
+ export function resolveCsharpDependencies(workspace) {
491
+ const { byName: index, failures: indexFailures } = csharpNamespaceIndex(workspace);
492
+ refuseUnreadTree("the C# namespace index", indexFailures);
493
+ const dependencies = [];
494
+ for (const project of workspace.projects) {
495
+ for (const file of workspace.filesOf(project.name)) {
496
+ if (!file.endsWith(".cs")) continue;
497
+ const text = workspace.readFile(file);
498
+ if (text === null) continue;
499
+ for (const site of parseCSharpDirectiveSites(text)) {
500
+ if (site.importableName === null) continue;
501
+ const resolved = resolveCsharpSpecifier(site.importableName, index);
502
+ if (resolved.external || resolved.ambiguous) continue;
503
+ if (resolved.target === project.name) continue;
504
+ dependencies.push({
505
+ source: project.name,
506
+ target: resolved.target,
507
+ sourceFile: file,
508
+ type: "static",
509
+ });
510
+ }
511
+ }
512
+ }
513
+ return dependencies;
514
+ }