@ecoma-io/archkeep 0.15.0 → 0.16.0

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