@descryy/adapter-csharp 0.1.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.
@@ -0,0 +1,1120 @@
1
+ /**
2
+ * C# files to Canonical IR.
3
+ *
4
+ * ## Identity is the project plus the declared namespace
5
+ *
6
+ * Not the namespace alone. C# namespaces do not follow directories and the same
7
+ * namespace legitimately spans projects, so two `.csproj` files in one
8
+ * repository may both declare `Shop.Orders.OrderService`. That is DEC-077's
9
+ * collision — the Java case where one fully-qualified name existed under two
10
+ * source sets and stopped identifying anything — and the project is C#'s
11
+ * equivalent separator.
12
+ *
13
+ * ## Two decisions land here, both taken before this file was written
14
+ *
15
+ * **DEC-083, a declaration that spans files.** `partial class` is the first
16
+ * construct in this project whose declaration is legitimately multi-file. The
17
+ * merge is free — a node id never contains a file path — so the parts collapse
18
+ * to one node with no new machinery. `file` is the lexicographically first
19
+ * declaring path and every part is listed in `attrs.declaredIn`, which is what
20
+ * a precision adjudicator has to search instead of `file`.
21
+ *
22
+ * **DEC-084, the base list.** `class A : B, IC` does not say which entry is the
23
+ * class, but the language does: at most one base class, and it must be first.
24
+ * So entries after the first are `IMPLEMENTS` unconditionally, a struct's and an
25
+ * interface's entries are all `IMPLEMENTS`, and exactly one entry per class ever
26
+ * needs resolving. The `IFoo` prefix is never read — it is a convention,
27
+ * `System.Exception` disproves it in the other direction, and inferring an edge
28
+ * from a naming habit is what golden pattern 12 exists to punish.
29
+ *
30
+ * ## Ambiguity is disclosed, never broken
31
+ *
32
+ * A `using` imports a whole namespace, so a bare name can be found under two of
33
+ * them at once. Real C# resolves that with the full type environment; this
34
+ * adapter has names only, so two candidates produce a ledger entry and no edge.
35
+ * Extension methods are the same answer for the same reason: `x.Foo()` where
36
+ * `Foo` is a static method on an unrelated class, selected by the receiver's
37
+ * type, is not decidable from names.
38
+ */
39
+ import { edgeId, endpointQsp, nodeId, normaliseEndpointPath, symbolQsp, testCaseQsp, } from "@descryy/ir";
40
+ import { efModels, isGenerated } from "./efcore.js";
41
+ import { aspNetRoutes } from "./aspnetcore.js";
42
+ import { resolveNullable, nullableDirectives } from "./nullability.js";
43
+ import { clientCalls, endpointFor } from "./client.js";
44
+ import { typedClientBases } from "./client-base.js";
45
+ export const LANGUAGE = "csharp";
46
+ const CONFIDENCE = { 0: 0.5, 1: 0.7, 2: 0.9, 3: 0.95 };
47
+ const REASONS = {
48
+ outside:
49
+ // Same correction as the Java adapter's, for the same reason, on weaker
50
+ // evidence. There the final sentence "A scope boundary, not an analysis
51
+ // gap." was measured false for 238 of 458 entries on `okhttp`, which is
52
+ // Kotlin-and-Java. No C# corpus here holds another CLR language — `.fs` and
53
+ // `.vb` counts are 0 across all six — so the claim is not *known* wrong on
54
+ // C#. It is unverifiable either way from inside one adapter, and a
55
+ // disclosure must not assert what its author cannot check.
56
+ "names a type or member this run did not analyse. Either a scope boundary — a NuGet " +
57
+ "package, the .NET base class library, a project outside the analysed set — or a " +
58
+ "declaration this run could not read, in another language in this same repository. " +
59
+ "This adapter sees only C# sources and cannot tell the two apart.",
60
+ unmodelled: "the type is in the analysed set but declares no such member — most often inherited from a " +
61
+ "base type or interface this run did not read, or reached through an extension method.",
62
+ ambiguousName: "the name is found under more than one imported namespace. C# resolves this with the full " +
63
+ "type environment; names alone cannot, so no edge is emitted rather than one of two guessed.",
64
+ untypedReceiver: "the receiver's type is not written down at this reference: a `var` bound to something " +
65
+ "unwritten, an untyped lambda parameter, or a member declared in an unread type.",
66
+ valueTarget: "the invocation target is a value, not a name — a delegate field, an element of a map of " +
67
+ "delegates, a `Func<>` local. Which method it holds is a runtime fact.",
68
+ noProject: "no .csproj covers this file, so it belongs to no assembly and nothing else can name its " +
69
+ "types. Disclosed rather than given an invented identity.",
70
+ noBaseContext: "`base` in a type whose base class is not in the analysed set.",
71
+ };
72
+ const INHERITANCE_HOPS = 12;
73
+ const PENDING_PASSES = 3;
74
+ /**
75
+ * Test attributes, by framework. All three are declared in source.
76
+ *
77
+ * xUnit `[Fact]`/`[Theory]`, NUnit `[Test]`/`[TestCase]`, MSTest `[TestMethod]`.
78
+ * This is the same shape Java's `@Test` already handles and it is reading an
79
+ * annotation, not guessing from a method name — C# has no naming convention for
80
+ * tests the way XCTest does.
81
+ */
82
+ const TEST_ATTRIBUTES = new Set(["Fact", "Theory", "Test", "TestCase", "TestMethod", "DataTestMethod"]);
83
+ function qualify(namespace, path) {
84
+ const joined = path.join(".");
85
+ return namespace === "" ? joined : `${namespace}.${joined}`;
86
+ }
87
+ export function extract(input) {
88
+ const nodes = [];
89
+ const edges = [];
90
+ const unresolved = [];
91
+ const seenEdge = new Set();
92
+ const scope = input.scope;
93
+ // The caller's half of the HTTP boundary — collected per-method below,
94
+ // where the enclosing FUNCTION node and receiver scopes are both in hand,
95
+ // and emitted with the routes below so both halves mint one `API_ENDPOINT`.
96
+ const clientCallSites = [];
97
+ /**
98
+ * `FUNCTION`/`TEST_CASE` node id -> its index in `nodes`, so D-FIX-3's
99
+ * client-base extractor (DEC-164) can patch `attrs.clientBase` onto the
100
+ * caller node once `clientCallSites` is fully known — golden patterns
101
+ * 08a/08b/08c bind their `caller_*` role to the `FUNCTION`, not the edge,
102
+ * per `capabilities.ts`'s own "on every caller" phrasing.
103
+ */
104
+ const functionNodeIndex = new Map();
105
+ /** `FUNCTION`/`TEST_CASE` node id -> the `clientBase` this row attaches to it. First writer wins. */
106
+ const functionClientBase = new Map();
107
+ // -------------------------------------------------------------------------
108
+ // Framework extractors, resolved for the whole batch before any id is minted
109
+ // -------------------------------------------------------------------------
110
+ //
111
+ // A MODEL is a MODEL rather than a CLASS, and the node id hashes the kind
112
+ // (DEC-004), so the kind has to be settled BEFORE the id exists. Both
113
+ // extractors also read across files — a `DbSet<Order>` names a type declared
114
+ // elsewhere, and the evidence making a class a controller is usually on a base
115
+ // in another file — so neither can run per-unit.
116
+ const sources = input.sources ?? new Map();
117
+ const frameworkFiles = input.files
118
+ .map((parsed) => ({ parsed, source: sources.get(parsed.file) ?? "" }))
119
+ .filter((each) => each.source !== "" && !isGenerated(each.parsed.file, each.source));
120
+ /**
121
+ * The NRT context governing a file, resolved from its directives alone here.
122
+ *
123
+ * The project and the imported props are the adapter's to supply and are not
124
+ * reachable from a `CsFile`, so a file with no directive resolves to `absent`
125
+ * and its reference-typed columns come out `unresolved` and counted. That is
126
+ * the honest degradation: unknown is not false, and DEC-180 §2 is the whole
127
+ * reason this is not allowed to guess.
128
+ */
129
+ const contextOf = (file) => {
130
+ if (input.nullableContextOf !== undefined)
131
+ return input.nullableContextOf(file);
132
+ const source = sources.get(file);
133
+ if (source === undefined)
134
+ return "absent";
135
+ return resolveNullable({ directives: nullableDirectives(source), project: "absent", directoryProps: "absent" }, source.split("\n").length).context;
136
+ };
137
+ const orm = efModels({ files: frameworkFiles, contextOf });
138
+ /** D-FIX-3's client-base extractor (DEC-164), the `AddHttpClient<T>(...)` half — `client-base.ts`'s own header has the account. */
139
+ const typedClientBaseMap = typedClientBases(frameworkFiles);
140
+ /**
141
+ * **Qualified** type name -> its mapping, for the CLASS/MODEL decision below.
142
+ *
143
+ * Keyed on the simple name until a resolved collision showed what that costs:
144
+ * two types named `Basket` in one repository both became `MODEL`, and the
145
+ * Razor `ViewComponent` carried the entity's columns.
146
+ */
147
+ const modelsByName = new Map(orm.models.map((model) => [model.fqn, model]));
148
+ const push = (edge, level) => {
149
+ if (level > input.reached)
150
+ return false;
151
+ if (edge.from === edge.to)
152
+ return true;
153
+ const key = edgeId(edge.from, edge.to, edge.type);
154
+ if (seenEdge.has(key))
155
+ return true;
156
+ seenEdge.add(key);
157
+ edges.push({ ...edge, producedBy: input.producedBy, resolution: level, confidence: CONFIDENCE[level] ?? 0.5 });
158
+ return true;
159
+ };
160
+ const ledger = (from, edgeType, rawTarget, unit, line, reason, attrs) => {
161
+ unresolved.push({
162
+ fromNodeId: from,
163
+ edgeType,
164
+ rawTarget: rawTarget.slice(0, 120),
165
+ file: unit.file,
166
+ line,
167
+ producedBy: input.producedBy,
168
+ reason,
169
+ ...(attrs === undefined ? {} : { attrs }),
170
+ });
171
+ };
172
+ // -------------------------------------------------------------------------
173
+ // Nodes — types first, so partial parts merge before any member is placed
174
+ // -------------------------------------------------------------------------
175
+ /** Keyed by `assembly::FullyQualifiedName`, which is what makes it identity. */
176
+ const types = new Map();
177
+ /**
178
+ * The same names, keyed by fully-qualified name alone.
179
+ *
180
+ * Identity and resolution want different indexes and conflating them is a
181
+ * defect in both directions. The assembly separates two declarations of one
182
+ * name, so it belongs in the id. But a `using` reaches *across* projects — a
183
+ * test project referencing the app project is the ordinary case — so keying
184
+ * resolution by assembly too would silently drop every cross-project edge.
185
+ *
186
+ * `<ProjectReference>` is not read, so which projects may see each other is
187
+ * unknown here. The rule that follows is the precision rule: prefer a match in
188
+ * the referencing file's own assembly, and otherwise accept a match only when
189
+ * exactly one assembly declares the name. Two claimants is an ambiguity to
190
+ * disclose, not a tie to break.
191
+ */
192
+ const byFqn = new Map();
193
+ const moduleIdOf = new Map();
194
+ const moduleKeys = new Map();
195
+ // Sorted, so the primary declaration of a partial type is a property of the
196
+ // repository and not of filesystem enumeration order. DEC-083 requires this:
197
+ // "lexicographically first" is only reproducible if the input is ordered.
198
+ const units = [...input.files].sort((a, b) => a.file.localeCompare(b.file));
199
+ const keyOf = (unit, fqn) => `${unit.assembly}::${fqn}`;
200
+ for (const unit of units) {
201
+ const stem = unit.file.slice(unit.file.lastIndexOf("/") + 1).replace(/\.cs$/i, "");
202
+ const holder = unit.assembly === "" ? "(no project)" : unit.assembly;
203
+ const namespaceKey = unit.namespace === "" ? "(global)" : unit.namespace;
204
+ const key = `${holder}::${namespaceKey}::${stem}`;
205
+ const owner = moduleKeys.get(key);
206
+ const disambiguated = owner !== undefined && owner !== unit.file;
207
+ if (owner === undefined)
208
+ moduleKeys.set(key, unit.file);
209
+ const directory = unit.file.slice(0, Math.max(0, unit.file.lastIndexOf("/")));
210
+ const segment = disambiguated ? (directory === "" ? unit.file : `${directory}/${stem}`) : stem;
211
+ const moduleId = nodeId(scope, "MODULE", symbolQsp(`${holder}::${namespaceKey}`, [segment]), LANGUAGE);
212
+ moduleIdOf.set(unit.file, moduleId);
213
+ nodes.push({
214
+ id: moduleId,
215
+ type: "MODULE",
216
+ name: `${stem}.cs`,
217
+ file: unit.file,
218
+ range: { startLine: 1, endLine: 1 },
219
+ language: LANGUAGE,
220
+ producedBy: input.producedBy,
221
+ resolution: 0,
222
+ attrs: {
223
+ ...(unit.hasError ? { parsedWithErrors: true } : {}),
224
+ ...(disambiguated ? { disambiguatedBy: "directory" } : {}),
225
+ ...(unit.namespace === "" ? {} : { namespace: unit.namespace }),
226
+ ...(unit.assembly === "" ? { noProject: true } : { assembly: unit.assembly }),
227
+ },
228
+ });
229
+ for (const part of unit.types) {
230
+ const fqn = qualify(unit.namespace, part.path);
231
+ const key_ = keyOf(unit, fqn);
232
+ const existing = types.get(key_);
233
+ if (existing !== undefined) {
234
+ existing.parts.push(part);
235
+ if (!existing.files.includes(unit.file))
236
+ existing.files.push(unit.file);
237
+ continue;
238
+ }
239
+ // The kind is decided here and the id is minted from it. DEC-004 hashes
240
+ // the kind, so a MODEL cannot be patched onto a CLASS afterwards.
241
+ const kind = modelsByName.has(fqn) ? "MODEL" : "CLASS";
242
+ const declared = {
243
+ kind,
244
+ id: nodeId(scope, kind, symbolQsp(holder, [fqn]), LANGUAGE),
245
+ fqn,
246
+ parts: [part],
247
+ files: [unit.file],
248
+ unit,
249
+ members: new Map(),
250
+ methods: new Map(),
251
+ };
252
+ types.set(key_, declared);
253
+ byFqn.set(fqn, [...(byFqn.get(fqn) ?? []), declared]);
254
+ }
255
+ }
256
+ // DEC-083: one node per type, `file` the lexicographically first declaring
257
+ // path, every part in `attrs.declaredIn`.
258
+ for (const declared of types.values()) {
259
+ const primary = declared.parts[0];
260
+ if (primary === undefined)
261
+ continue;
262
+ const files = [...declared.files].sort();
263
+ const partial = declared.parts.some((part) => part.isPartial);
264
+ for (const part of declared.parts) {
265
+ for (const [member, written] of part.members)
266
+ declared.members.set(member, { type: written });
267
+ }
268
+ const model = declared.kind === "MODEL" ? modelsByName.get(declared.fqn) : undefined;
269
+ nodes.push({
270
+ id: declared.id,
271
+ type: declared.kind,
272
+ name: primary.name,
273
+ file: files[0] ?? declared.unit.file,
274
+ range: { startLine: primary.startLine, endLine: primary.endLine },
275
+ language: LANGUAGE,
276
+ producedBy: input.producedBy,
277
+ resolution: 0,
278
+ attrs: {
279
+ declarationForm: primary.form,
280
+ ...(primary.isAbstract ? { abstract: true } : {}),
281
+ ...(primary.attributes.length > 0 ? { attributes: primary.attributes } : {}),
282
+ // Written whenever the type has more than one part, so an adjudicator
283
+ // that searches only `file` has something to search instead.
284
+ ...(files.length > 1 || partial ? { partial: true, declaredIn: files } : {}),
285
+ ...(model === undefined
286
+ ? {}
287
+ : {
288
+ orm: "efcore",
289
+ ...(model.table === null ? {} : { table: model.table }),
290
+ // **Not gated on `input.reached`.** A mapped column is a
291
+ // declaration the framework itself enforces, not an inference, so
292
+ // its resolution is the evidence's rather than the run's (DEC-058)
293
+ // — the same exception `adapter-java` records for JPA.
294
+ ...(model.columns.length > 0
295
+ ? {
296
+ fields: model.columns.map((column) => ({
297
+ name: column.name,
298
+ // `undefined` is dropped by JSON, so an unresolved column
299
+ // must say so rather than vanish into a missing key.
300
+ ...(column.nullable === undefined
301
+ ? { nullable: null }
302
+ : { nullable: column.nullable }),
303
+ nullableFrom: column.nullableFrom,
304
+ })),
305
+ }
306
+ : {}),
307
+ admittedBy: model.admittedBy,
308
+ }),
309
+ },
310
+ });
311
+ }
312
+ /** A type is a test suite when any of its methods carries a test attribute. */
313
+ const suites = new Set();
314
+ for (const unit of units) {
315
+ for (const fn of unit.funcs) {
316
+ if (fn.ownerPath.length === 0)
317
+ continue;
318
+ if (!fn.attributes.some((attribute) => TEST_ATTRIBUTES.has(attribute)))
319
+ continue;
320
+ suites.add(keyOf(unit, qualify(unit.namespace, fn.ownerPath)));
321
+ }
322
+ }
323
+ for (const unit of units) {
324
+ const holder = unit.assembly === "" ? "(no project)" : unit.assembly;
325
+ for (const fn of unit.funcs) {
326
+ if (fn.ownerPath.length === 0)
327
+ continue;
328
+ const ownerFqn = qualify(unit.namespace, fn.ownerPath);
329
+ const owner = types.get(keyOf(unit, ownerFqn));
330
+ if (owner === undefined)
331
+ continue;
332
+ const isCase = suites.has(keyOf(unit, ownerFqn)) && fn.attributes.some((attribute) => TEST_ATTRIBUTES.has(attribute));
333
+ const id = isCase
334
+ ? nodeId(scope, "TEST_CASE", testCaseQsp(holder, [unit.namespace, ...fn.ownerPath].filter((s) => s !== ""), fn.name), LANGUAGE)
335
+ : nodeId(scope, "FUNCTION", symbolQsp(holder, [ownerFqn, fn.name]), LANGUAGE);
336
+ // An overload set is one node. C# picks between overloads by argument
337
+ // types, which is R3 evidence this adapter does not have — so an edge to
338
+ // "the method called Format" is the honest claim, and pretending to have
339
+ // chosen an overload would be a claim it cannot support.
340
+ if (owner.methods.has(fn.name))
341
+ continue;
342
+ owner.methods.set(fn.name, { id, fn, unit, ownerFqn, isTest: isCase });
343
+ functionNodeIndex.set(id, nodes.length);
344
+ nodes.push({
345
+ id,
346
+ type: isCase ? "TEST_CASE" : "FUNCTION",
347
+ name: fn.name,
348
+ file: unit.file,
349
+ range: { startLine: fn.startLine, endLine: fn.endLine },
350
+ language: LANGUAGE,
351
+ producedBy: input.producedBy,
352
+ resolution: 0,
353
+ attrs: {
354
+ ...(fn.isStatic ? { static: true } : {}),
355
+ ...(fn.isAbstract ? { abstract: true } : {}),
356
+ ...(fn.attributes.length > 0 ? { attributes: fn.attributes } : {}),
357
+ },
358
+ });
359
+ }
360
+ }
361
+ // -------------------------------------------------------------------------
362
+ // Resolution
363
+ // -------------------------------------------------------------------------
364
+ /**
365
+ * Every fully-qualified name a written name could denote, in this file.
366
+ *
367
+ * C# searches the enclosing namespaces outward and then the imports, and a
368
+ * name found under two imports is genuinely ambiguous without the full type
369
+ * environment. Candidates are collected rather than short-circuited so that
370
+ * ambiguity can be *seen* instead of silently resolved to whichever rule ran
371
+ * first.
372
+ */
373
+ /**
374
+ * `global using` declarations, by assembly.
375
+ *
376
+ * They are in force for every file in their project, so they are gathered once
377
+ * and consulted for every unit — the alternative is that a name introduced by
378
+ * one resolves nowhere else, which is the whole point of the construct.
379
+ */
380
+ const globalUsings = new Map();
381
+ for (const unit of units) {
382
+ for (const using of unit.usings) {
383
+ if (!using.isGlobal)
384
+ continue;
385
+ globalUsings.set(unit.assembly, [...(globalUsings.get(unit.assembly) ?? []), using]);
386
+ }
387
+ }
388
+ const usingsFor = (unit) => {
389
+ const global = globalUsings.get(unit.assembly);
390
+ return global === undefined ? unit.usings : [...unit.usings, ...global];
391
+ };
392
+ function candidatesFor(raw, unit, host) {
393
+ const name = raw.trim().replace(/^global::/, "");
394
+ if (name === "")
395
+ return [];
396
+ const found = new Set();
397
+ const visible = usingsFor(unit);
398
+ // **Enclosing TYPES are searched before enclosing namespaces.** A nested
399
+ // type written by its simple name from inside the type that declares it —
400
+ // `DbLock.EnterWrite(...)` inside `PessimisticLockBehavior`, which declares
401
+ // `private sealed class DbLock` — resolved nowhere, because only the
402
+ // namespace chain was walked. Innermost wins outright, as in C#: returning
403
+ // here rather than adding to `found` keeps a nested `Foo` from colliding
404
+ // with a namespace-level `Foo` and being reported ambiguous.
405
+ if (host !== undefined) {
406
+ let scope_ = host.fqn;
407
+ while (scope_ !== "") {
408
+ if (byFqn.has(scope_)) {
409
+ const nested = `${scope_}.${name}`;
410
+ if (byFqn.has(nested))
411
+ return [nested];
412
+ }
413
+ const cut = scope_.lastIndexOf(".");
414
+ scope_ = cut === -1 ? "" : scope_.slice(0, cut);
415
+ }
416
+ }
417
+ const consider = (candidate) => {
418
+ if (byFqn.has(candidate))
419
+ found.add(candidate);
420
+ };
421
+ // An alias binds one name outright and wins over everything else.
422
+ const head = name.includes(".") ? name.slice(0, name.indexOf(".")) : name;
423
+ const tail = name.includes(".") ? name.slice(name.indexOf(".")) : "";
424
+ for (const using of visible) {
425
+ if (using.alias !== undefined && using.alias === head) {
426
+ const aliased = `${using.target}${tail}`;
427
+ if (byFqn.has(aliased))
428
+ return [aliased];
429
+ }
430
+ }
431
+ // Written as given — already fully qualified, or a nested type reached by
432
+ // its outer type's name.
433
+ consider(name);
434
+ // Enclosing namespaces, innermost outward. C#'s own rule.
435
+ const segments = unit.namespace === "" ? [] : unit.namespace.split(".");
436
+ for (let depth = segments.length; depth >= 0; depth -= 1) {
437
+ const prefix = segments.slice(0, depth).join(".");
438
+ consider(prefix === "" ? name : `${prefix}.${name}`);
439
+ }
440
+ // Imported namespaces. `using static` imports members, not types, and is
441
+ // handled where members are resolved.
442
+ for (const using of visible) {
443
+ if (using.alias !== undefined || using.isStatic)
444
+ continue;
445
+ consider(`${using.target}.${name}`);
446
+ }
447
+ return [...found];
448
+ }
449
+ function resolveType(raw, unit, host) {
450
+ const candidates = candidatesFor(raw, unit, host);
451
+ if (candidates.length === 0)
452
+ return { reason: REASONS.outside };
453
+ if (candidates.length > 1)
454
+ return { reason: REASONS.ambiguousName };
455
+ const claimants = byFqn.get(candidates[0]) ?? [];
456
+ if (claimants.length === 0)
457
+ return { reason: REASONS.outside };
458
+ // Own assembly first — that is where C# would look and where a `partial`
459
+ // sibling lives. Across assemblies, one claimant resolves and two do not.
460
+ const own = claimants.filter((candidate) => candidate.unit.assembly === unit.assembly);
461
+ if (own.length === 1)
462
+ return { declared: own[0] };
463
+ if (own.length > 1)
464
+ return { reason: REASONS.ambiguousName };
465
+ if (claimants.length === 1)
466
+ return { declared: claimants[0] };
467
+ return { reason: REASONS.ambiguousName };
468
+ }
469
+ /** Every type in a declaration's supertype chain, across all its parts. */
470
+ function* ancestry(start) {
471
+ const queue = [start];
472
+ const seen = new Set([start.fqn]);
473
+ let hops = 0;
474
+ while (queue.length > 0 && hops < INHERITANCE_HOPS) {
475
+ const current = queue.shift();
476
+ if (current === undefined)
477
+ break;
478
+ hops += 1;
479
+ yield current;
480
+ for (const part of current.parts) {
481
+ for (const raw of part.baseList) {
482
+ const parent = resolveType(raw, current.unit, current);
483
+ if ("declared" in parent && !seen.has(parent.declared.fqn)) {
484
+ seen.add(parent.declared.fqn);
485
+ queue.push(parent.declared);
486
+ }
487
+ }
488
+ }
489
+ }
490
+ }
491
+ function findMethod(start, name) {
492
+ for (const current of ancestry(start)) {
493
+ const found = current.methods.get(name);
494
+ if (found !== undefined)
495
+ return found;
496
+ }
497
+ return undefined;
498
+ }
499
+ function findMember(start, name) {
500
+ for (const current of ancestry(start)) {
501
+ const found = current.members.get(name);
502
+ if (found !== undefined)
503
+ return found.type;
504
+ }
505
+ return undefined;
506
+ }
507
+ // ---------------------------------------------------------------------------
508
+ // D-FIX-3's client-base extractor (DEC-164) — see `client-base.ts`'s own
509
+ // header for the shape and why it is scoped exactly here.
510
+ // ---------------------------------------------------------------------------
511
+ /**
512
+ * Does `rawName`'s own declaration — resolved in `unit`/`host`'s context,
513
+ * the same resolution every other type reference in this file goes
514
+ * through — carry `WebApplicationFactory<` in its written base list,
515
+ * directly or through one further in-repo indirection?
516
+ *
517
+ * `WebApplicationFactory<TEntryPoint>` is itself external (`Microsoft.
518
+ * AspNetCore.Mvc.Testing`), so `resolveType` never resolves it as a
519
+ * `DeclaredType` and `ancestry` never yields it — this reads the *written*
520
+ * text of each yielded type's own base list instead of trying to resolve
521
+ * that final hop, the same reason `baseListWritten` exists rather than
522
+ * `baseList` alone (see `CsType.baseListWritten`'s own doc).
523
+ */
524
+ function derivesFromWebApplicationFactory(rawName, unit, host) {
525
+ const resolved = resolveType(rawName, unit, host);
526
+ if (!("declared" in resolved))
527
+ return false;
528
+ for (const current of ancestry(resolved.declared)) {
529
+ for (const part of current.parts) {
530
+ if (part.baseListWritten.some((entry) => /^WebApplicationFactory\s*(<|$)/.test(entry.trim())))
531
+ return true;
532
+ }
533
+ }
534
+ return false;
535
+ }
536
+ /**
537
+ * The one traceable base-setting mechanism this row builds. `receiverType`
538
+ * is the *written* type name of whatever received a `.CreateClient()` call
539
+ * — see `client-base.ts`'s own header for why `IHttpClientFactory`'s named-
540
+ * client form and every other DI shape are deliberately left untouched.
541
+ */
542
+ function creatorBaseOf(receiverType, unit, host) {
543
+ if (receiverType === undefined)
544
+ return undefined;
545
+ if (!derivesFromWebApplicationFactory(receiverType, unit, host))
546
+ return undefined;
547
+ // `WebApplicationFactory<T>.CreateClient()`'s documented default
548
+ // `BaseAddress` is `http://localhost/` — origin only, no path. No
549
+ // corpus witness in this row overrides it (`WebApplicationFactoryClientOptions`
550
+ // is never constructed with its own `BaseAddress` in any of the six), so
551
+ // that override is not read here — disclosed, not silently assumed.
552
+ return { state: "resolved", path: "" };
553
+ }
554
+ /**
555
+ * `var client = _factory.CreateClient();` — resolved from `scope_.pending`
556
+ * *before* `settle()` runs, because `settle()`'s own fallback (`locals.set(name,
557
+ * null)`) is exactly the silence this row exists to fix: `CreateClient` is
558
+ * never declared in-repo (it is inherited from an external base), so
559
+ * `findMethod` never finds it and every such local stayed permanently
560
+ * unresolved before this function existed — invisible to `clientCalls`,
561
+ * not even ledgered. Mutates `scope_` in place (same contract `settle`
562
+ * already has) and returns the per-local bases this pass resolved.
563
+ */
564
+ function resolveClientBaseLocals(scope_, unit, host) {
565
+ const bases = new Map();
566
+ for (const [name, pending] of scope_.pending) {
567
+ if (pending.name !== "CreateClient")
568
+ continue;
569
+ const receiverType = pending.receiver === undefined
570
+ ? undefined
571
+ : definite(scope_.locals.get(pending.receiver) ?? findMemberOn(host, pending.receiver));
572
+ const base = creatorBaseOf(receiverType, unit, host);
573
+ if (base === undefined)
574
+ continue;
575
+ scope_.locals.set(name, "HttpClient");
576
+ scope_.pending.delete(name);
577
+ bases.set(name, base);
578
+ }
579
+ return bases;
580
+ }
581
+ /** `findMember`, tolerant of an absent `host` — every call site here already needs that. */
582
+ function findMemberOn(host, name) {
583
+ return host === undefined ? undefined : findMember(host, name);
584
+ }
585
+ /** `null` (ambiguous) collapses to `undefined` (unknown) — the same reader stance `writtenReceiverType` already takes. */
586
+ function definite(value) {
587
+ return value === null ? undefined : value;
588
+ }
589
+ /**
590
+ * A property or field's own base, traced once per `DeclaredType` and
591
+ * cached — a field's inline initialiser (`memberProvenance`, read at the
592
+ * declaration) or a `.ctor`'s own assignment (`propertyAssignments`, read
593
+ * from the constructor's body) are the two shapes measured; the first
594
+ * writer for a given name wins, matching `findMember`'s own "nearest
595
+ * declaration" precedence.
596
+ */
597
+ const memberClientBaseCache = new Map();
598
+ function memberClientBase(declared) {
599
+ const cached = memberClientBaseCache.get(declared.id);
600
+ if (cached !== undefined)
601
+ return cached;
602
+ const bases = new Map();
603
+ const consider = (name, call, scope_) => {
604
+ if (bases.has(name) || call.name !== "CreateClient")
605
+ return;
606
+ const receiverType = call.receiver === undefined
607
+ ? undefined
608
+ : definite((scope_ === undefined ? undefined : scope_.locals.get(call.receiver)) ?? findMemberOn(declared, call.receiver));
609
+ const base = creatorBaseOf(receiverType, declared.unit, declared);
610
+ if (base !== undefined)
611
+ bases.set(name, base);
612
+ };
613
+ for (const part of declared.parts) {
614
+ for (const [field, call] of part.memberProvenance)
615
+ consider(field, call, undefined);
616
+ }
617
+ const ctor = declared.methods.get(".ctor");
618
+ if (ctor !== undefined) {
619
+ for (const [prop, call] of ctor.fn.scope.propertyAssignments)
620
+ consider(prop, call, ctor.fn.scope);
621
+ }
622
+ memberClientBaseCache.set(declared.id, bases);
623
+ return bases;
624
+ }
625
+ function ownerOfType(raw, unit, host) {
626
+ if (raw === null || raw === undefined)
627
+ return { reason: REASONS.untypedReceiver };
628
+ if (raw === "this")
629
+ return host === undefined ? { reason: REASONS.noBaseContext } : { declared: host };
630
+ if (raw === "base") {
631
+ if (host === undefined)
632
+ return { reason: REASONS.noBaseContext };
633
+ // DEC-084: the base class, if there is one, is the *first* base-list entry.
634
+ for (const part of host.parts) {
635
+ const first = part.baseList[0];
636
+ if (first === undefined)
637
+ continue;
638
+ const parent = resolveType(first, host.unit, host);
639
+ if ("declared" in parent && parent.declared.parts[0]?.form !== "interface")
640
+ return parent;
641
+ }
642
+ return { reason: REASONS.noBaseContext };
643
+ }
644
+ return resolveType(raw, unit, host);
645
+ }
646
+ function ownerOfReceiver(written, scope_, unit, host) {
647
+ if (written === "this" || written === "base")
648
+ return ownerOfType(written, unit, host);
649
+ const local = scope_.locals.get(written);
650
+ if (local !== undefined)
651
+ return ownerOfType(local, unit, host);
652
+ if (host !== undefined) {
653
+ const member = findMember(host, written);
654
+ if (member !== undefined)
655
+ return ownerOfType(member, unit, host);
656
+ }
657
+ return resolveType(written, unit, host);
658
+ }
659
+ /**
660
+ * The receiver's type **as written**, without resolving it to a declaration —
661
+ * `HttpClient` has none in this repository, and `ownerOfReceiver` above would
662
+ * refuse it for exactly that reason. D-FIX-3 needs the name, not the node, so
663
+ * this stops one step earlier: same lookup order (`locals`, then the host
664
+ * type's own members), same `null`-is-poisoned handling, no `resolveType` call.
665
+ *
666
+ * `ref.receiverProperty` is resolved only through `this` — `this._client.Get()`
667
+ * — not through an arbitrary chain (`_service.Client.Get()`), which is a
668
+ * disclosed limit rather than an attempt to walk a chain this reader does not
669
+ * track elsewhere. `ref.receiverCall` (`Factory.CreateClient().Get()`) is left
670
+ * unresolved for the same reason Java's chained builder is: no receiver here
671
+ * has a written type, so nothing is silently guessed.
672
+ */
673
+ function writtenReceiverType(ref, scope_, host) {
674
+ if (ref.receiverType !== undefined)
675
+ return ref.receiverType;
676
+ if (ref.receiverProperty !== undefined) {
677
+ if (ref.receiverProperty.of !== "this" || host === undefined)
678
+ return undefined;
679
+ const member = findMember(host, ref.receiverProperty.name);
680
+ return member === null || member === undefined ? undefined : member;
681
+ }
682
+ if (ref.receiverCall !== undefined)
683
+ return undefined;
684
+ if (ref.receiver === "this" || ref.receiver === "base")
685
+ return undefined;
686
+ const local = scope_.locals.get(ref.receiver);
687
+ if (local !== undefined)
688
+ return local === null ? undefined : local;
689
+ if (host !== undefined) {
690
+ const member = findMember(host, ref.receiver);
691
+ if (member !== undefined)
692
+ return member === null ? undefined : member;
693
+ }
694
+ return undefined;
695
+ }
696
+ function settle(scope_, unit, host) {
697
+ for (let pass = 0; pass < PENDING_PASSES && scope_.pending.size > 0; pass += 1) {
698
+ let changed = false;
699
+ for (const [name, pending] of [...scope_.pending]) {
700
+ let returns;
701
+ if (pending.receiver === undefined) {
702
+ if (host !== undefined)
703
+ returns = findMethod(host, pending.name)?.fn.returns;
704
+ }
705
+ else {
706
+ const owner = ownerOfReceiver(pending.receiver, scope_, unit, host);
707
+ if ("declared" in owner)
708
+ returns = findMethod(owner.declared, pending.name)?.fn.returns;
709
+ }
710
+ if (returns === undefined)
711
+ continue;
712
+ scope_.pending.delete(name);
713
+ scope_.locals.set(name, returns);
714
+ changed = true;
715
+ }
716
+ if (!changed)
717
+ break;
718
+ }
719
+ for (const name of scope_.pending.keys())
720
+ scope_.locals.set(name, null);
721
+ scope_.pending.clear();
722
+ }
723
+ // -------------------------------------------------------------------------
724
+ // Edges
725
+ // -------------------------------------------------------------------------
726
+ for (const unit of units) {
727
+ const moduleId = moduleIdOf.get(unit.file);
728
+ if (moduleId === undefined)
729
+ continue;
730
+ // IMPORTS. A C# `using` names a *namespace*, so like Go's package import it
731
+ // fans out — to every file declaring a type in that namespace. That is not
732
+ // an approximation: importing a namespace makes every type in it visible.
733
+ for (const using of unit.usings) {
734
+ const targets = new Set();
735
+ for (const declared of types.values()) {
736
+ const owner = declared.fqn.slice(0, Math.max(0, declared.fqn.lastIndexOf(".")));
737
+ if (using.alias !== undefined || using.isStatic) {
738
+ if (declared.fqn === using.target)
739
+ for (const file of declared.files)
740
+ targets.add(file);
741
+ }
742
+ else if (owner === using.target) {
743
+ for (const file of declared.files)
744
+ targets.add(file);
745
+ }
746
+ }
747
+ if (targets.size === 0) {
748
+ ledger(moduleId, "IMPORTS", using.target, unit, using.line, REASONS.outside);
749
+ continue;
750
+ }
751
+ for (const file of targets) {
752
+ const id = moduleIdOf.get(file);
753
+ if (id !== undefined)
754
+ push({ from: moduleId, to: id, type: "IMPORTS" }, 1);
755
+ }
756
+ }
757
+ for (const part of unit.types) {
758
+ const declared = types.get(keyOf(unit, qualify(unit.namespace, part.path)));
759
+ if (declared === undefined)
760
+ continue;
761
+ emitBaseList(declared, part, unit);
762
+ for (const ref of part.typeRefs)
763
+ emitType(declared.id, ref, unit, declared, undefined);
764
+ }
765
+ for (const fn of unit.funcs) {
766
+ if (fn.ownerPath.length === 0)
767
+ continue;
768
+ const host = types.get(keyOf(unit, qualify(unit.namespace, fn.ownerPath)));
769
+ const declared = host?.methods.get(fn.name);
770
+ if (declared === undefined || declared.unit.file !== unit.file || declared.fn !== fn)
771
+ continue;
772
+ // D-FIX-3's client-base extractor (DEC-164): `var client =
773
+ // _factory.CreateClient();` must be read out of `fn.scope.pending`
774
+ // *before* `settle()` runs — `settle()`'s own fallback nulls out
775
+ // anything it cannot resolve, and `CreateClient` is never declared
776
+ // in-repo (inherited from an external base), so every such local
777
+ // stayed permanently, silently unresolved before this pass existed.
778
+ const localClientBase = resolveClientBaseLocals(fn.scope, unit, host);
779
+ settle(fn.scope, unit, host);
780
+ // The caller's half of the HTTP boundary (DEC-241 §4). Read alongside
781
+ // the general ref loop below rather than inside it — `clientCalls` owns
782
+ // its own candidate predicate (`candidateClientRefs`), the same
783
+ // separation Java's `client.ts` keeps from `extract.ts`'s general
784
+ // `CALLS` handling.
785
+ {
786
+ const clientBaseOf = (ref) => {
787
+ // A local traced through `resolveClientBaseLocals` above.
788
+ const local = localClientBase.get(ref.receiver);
789
+ if (local !== undefined)
790
+ return local;
791
+ // `this.Client` / a bare `Client` reaching a member on the host type.
792
+ // A bare receiver only ever names a member here when it is not
793
+ // *also* a local or parameter — same precedence `writtenReceiverType`
794
+ // already applies, so a local that happens to shadow a same-named
795
+ // member is never misattributed to the member's own base.
796
+ const propertyName = ref.receiverProperty !== undefined
797
+ ? ref.receiverProperty.of === "this"
798
+ ? ref.receiverProperty.name
799
+ : undefined
800
+ : ref.receiverType === undefined && ref.receiverCall === undefined && !fn.scope.locals.has(ref.receiver)
801
+ ? ref.receiver
802
+ : undefined;
803
+ if (propertyName !== undefined && host !== undefined) {
804
+ const member = memberClientBase(host).get(propertyName);
805
+ if (member !== undefined)
806
+ return member;
807
+ }
808
+ // `AddHttpClient<TClient>(...)` — DI hands the configured client
809
+ // straight to `TClient`'s own constructor, so a call inside
810
+ // `TClient` whose receiver reached here untraced is exactly that
811
+ // typed client. Matched against the host's own simple name — see
812
+ // `typedClientBases`'s own doc for why this is textual rather than
813
+ // a full type resolution.
814
+ if (host !== undefined) {
815
+ const simple = host.fqn.slice(host.fqn.lastIndexOf(".") + 1);
816
+ const typed = typedClientBaseMap.get(simple);
817
+ if (typed !== undefined)
818
+ return typed;
819
+ }
820
+ return { state: "none" };
821
+ };
822
+ const read = clientCalls(fn.scope.refs, declared.id, (ref) => writtenReceiverType(ref, fn.scope, host), declared.isTest, fn.scope.parameterNames, clientBaseOf);
823
+ clientCallSites.push(...read.calls);
824
+ for (const call of read.calls) {
825
+ if (!functionClientBase.has(call.fromId))
826
+ functionClientBase.set(call.fromId, call.clientBase);
827
+ }
828
+ for (const refusal of read.refusals) {
829
+ // A refusal still carries `clientBase` when DEC-164's own
830
+ // `unresolved` state is *why* it refused — golden pattern 08b binds
831
+ // its `caller_unresolved_base` role to the `FUNCTION` even though
832
+ // no `USES_API` edge exists to look at, so the node is the only
833
+ // place that fact can live.
834
+ if (refusal.clientBase !== undefined && !functionClientBase.has(refusal.fromId)) {
835
+ functionClientBase.set(refusal.fromId, refusal.clientBase);
836
+ }
837
+ ledger(refusal.fromId, "USES_API", refusal.raw, unit, refusal.line, refusal.reason,
838
+ // Unset `refusalClass` stays legal and means unclassified — DEC-242 — so
839
+ // `attrs` is omitted entirely rather than sent with an `undefined` field.
840
+ refusal.refusalClass === undefined
841
+ ? undefined
842
+ : {
843
+ blockedBy: refusal.blockedBy,
844
+ refusalClass: refusal.refusalClass,
845
+ ...(refusal.argumentKind === undefined ? {} : { argumentKind: refusal.argumentKind }),
846
+ ...(refusal.clientBase === undefined ? {} : { clientBase: refusal.clientBase }),
847
+ });
848
+ }
849
+ }
850
+ for (const ref of fn.scope.refs) {
851
+ switch (ref.kind) {
852
+ case "dynamic":
853
+ ledger(declared.id, "CALLS", ref.raw, unit, ref.line, REASONS.valueTarget);
854
+ break;
855
+ case "type":
856
+ emitType(declared.id, ref, unit, host, fn.scope);
857
+ break;
858
+ case "new":
859
+ emitType(declared.id, ref, unit, host, fn.scope);
860
+ emitConstructor(declared.id, ref, unit, host, declared.isTest);
861
+ break;
862
+ case "call":
863
+ emitCall(declared.id, fn, ref, unit, host, declared.isTest);
864
+ break;
865
+ default:
866
+ break;
867
+ }
868
+ }
869
+ }
870
+ }
871
+ /**
872
+ * DEC-084 in one function.
873
+ *
874
+ * Position is a specification guarantee, not a convention: a class has at most
875
+ * one base class and it must come first. So only the first entry of a class
876
+ * base list is ever ambiguous, and everything else is decided by the language.
877
+ */
878
+ function emitBaseList(declared, part, unit) {
879
+ part.baseList.forEach((raw, index) => {
880
+ const target = resolveType(raw, unit, declared);
881
+ const first = index === 0;
882
+ const canInherit = first && (part.form === "class" || part.form === "record");
883
+ if (!("declared" in target)) {
884
+ ledger(declared.id, canInherit ? "INHERITS" : "IMPLEMENTS", raw, unit, part.startLine, target.reason);
885
+ return;
886
+ }
887
+ const targetForm = target.declared.parts[0]?.form;
888
+ const type = canInherit && targetForm !== "interface" ? "INHERITS" : "IMPLEMENTS";
889
+ push({ from: declared.id, to: target.declared.id, type }, 2);
890
+ });
891
+ }
892
+ function emitType(from, ref, unit, host, scope_) {
893
+ if (ref.speculative === true) {
894
+ // `order.Total` and `Status.Pending` are the same shape. If the holder is
895
+ // a local, a parameter or a member, it is a value and not a type — which
896
+ // is not an unresolved reference, so nothing is written to the ledger.
897
+ if (scope_?.locals.has(ref.name) === true)
898
+ return;
899
+ if (host !== undefined && findMember(host, ref.name) !== undefined)
900
+ return;
901
+ const target = resolveType(ref.name, unit, host);
902
+ if ("declared" in target)
903
+ push({ from, to: target.declared.id, type: "USES_TYPE" }, 2);
904
+ return;
905
+ }
906
+ if (ref.name === "this" || ref.name === "base")
907
+ return;
908
+ const target = resolveType(ref.name, unit, host);
909
+ if (!("declared" in target)) {
910
+ ledger(from, "USES_TYPE", ref.name, unit, ref.line, target.reason);
911
+ return;
912
+ }
913
+ push({ from, to: target.declared.id, type: "USES_TYPE" }, 2);
914
+ }
915
+ function emitConstructor(from, ref, unit, host, isTest) {
916
+ const owner = ownerOfType(ref.name, unit, host);
917
+ if (!("declared" in owner))
918
+ return;
919
+ const constructor = owner.declared.methods.get(".ctor");
920
+ if (constructor !== undefined)
921
+ push({ from, to: constructor.id, type: isTest ? "TESTS" : "CALLS" }, 2);
922
+ }
923
+ function emitCall(from, fn, ref, unit, host, isTest) {
924
+ const edgeType = isTest ? "TESTS" : "CALLS";
925
+ // An unqualified call is a member of the enclosing type or something it
926
+ // inherits. It may also be a `using static` import, which is a member the
927
+ // name alone cannot separate from an inherited one — so a miss is disclosed.
928
+ if (ref.receiver === undefined &&
929
+ ref.receiverType === undefined &&
930
+ ref.receiverProperty === undefined &&
931
+ ref.receiverCall === undefined) {
932
+ // `handler(payload)` where `handler` is a local holding a delegate. The
933
+ // name is a *value*, not a method, and a type that happens to declare a
934
+ // method of the same name would otherwise collect a wrong edge — which is
935
+ // golden pattern 12's failure mode arriving through an unqualified call.
936
+ if (fn.scope.locals.has(ref.name)) {
937
+ ledger(from, edgeType, ref.raw, unit, ref.line, REASONS.valueTarget);
938
+ return;
939
+ }
940
+ const target = host === undefined ? undefined : findMethod(host, ref.name);
941
+ if (target === undefined) {
942
+ ledger(from, edgeType, ref.raw, unit, ref.line, host === undefined ? REASONS.outside : REASONS.unmodelled);
943
+ return;
944
+ }
945
+ push({ from, to: target.id, type: edgeType }, 2);
946
+ return;
947
+ }
948
+ const owner = ownerOf(ref, fn.scope, unit, host);
949
+ if (!("declared" in owner)) {
950
+ ledger(from, edgeType, ref.raw, unit, ref.line, owner.reason);
951
+ return;
952
+ }
953
+ const target = findMethod(owner.declared, ref.name);
954
+ if (target === undefined) {
955
+ ledger(from, edgeType, ref.raw, unit, ref.line, REASONS.unmodelled);
956
+ return;
957
+ }
958
+ push({ from, to: target.id, type: edgeType }, 2);
959
+ }
960
+ function ownerOf(ref, scope_, unit, host) {
961
+ if (ref.receiverType !== undefined)
962
+ return ownerOfType(ref.receiverType, unit, host);
963
+ if (ref.receiverCall !== undefined) {
964
+ const { receiver, name } = ref.receiverCall;
965
+ const holder = receiver === undefined
966
+ ? host === undefined
967
+ ? undefined
968
+ : { declared: host }
969
+ : ownerOfReceiver(receiver, scope_, unit, host);
970
+ if (holder === undefined || !("declared" in holder))
971
+ return { reason: REASONS.untypedReceiver };
972
+ const method = findMethod(holder.declared, name);
973
+ if (method === undefined)
974
+ return { reason: REASONS.unmodelled };
975
+ return ownerOfType(method.fn.returns, method.unit, holder.declared);
976
+ }
977
+ if (ref.receiver === undefined)
978
+ return { reason: REASONS.untypedReceiver };
979
+ // The written receiver may be a whole dotted path — `Shop.Orders.Money` is a
980
+ // namespace-qualified type, `this._repository` is a member chain. Trying the
981
+ // path as a type first is what separates them, and it costs nothing when it
982
+ // fails because the property route is tried immediately after.
983
+ const asType = resolveType(ref.receiver, unit, host);
984
+ if ("declared" in asType)
985
+ return asType;
986
+ if (ref.receiverProperty !== undefined) {
987
+ const holder = ownerOfReceiver(ref.receiverProperty.of, scope_, unit, host);
988
+ if (!("declared" in holder))
989
+ return holder;
990
+ const declaredType = findMember(holder.declared, ref.receiverProperty.name);
991
+ if (declaredType === undefined)
992
+ return { reason: REASONS.unmodelled };
993
+ return ownerOfType(declaredType, unit, holder.declared);
994
+ }
995
+ return ownerOfReceiver(ref.receiver, scope_, unit, host);
996
+ }
997
+ // -------------------------------------------------------------------------
998
+ // Routes
999
+ // -------------------------------------------------------------------------
1000
+ //
1001
+ // R2 and no lower, for the reason `adapter-go` records and `adapter-java`
1002
+ // repeats: telling a controller from an ordinary class is provenance, and a
1003
+ // route emitted from the R0 or R1 rung is a claim stronger than the run that
1004
+ // produced it — which the Normaliser rejects as RESOLUTION_EXCEEDS_BATCH.
1005
+ //
1006
+ // Gated on `reached >= 2` alone, not on `frameworkFiles.length > 0` — a
1007
+ // repository can make outbound `HttpClient` calls without being an ASP.NET
1008
+ // server itself (a pure API-consuming client, an integration test project),
1009
+ // so the caller's half below must not depend on route files existing. Only
1010
+ // the route loop itself stays gated on ASP.NET files being present.
1011
+ if (input.reached >= 2) {
1012
+ const routeNodes = new Map();
1013
+ const endpointNodes = new Map();
1014
+ if (frameworkFiles.length > 0) {
1015
+ const census = aspNetRoutes({ files: frameworkFiles });
1016
+ // D-FIX-3's own finding: `census.unresolvedBaseSites` was computed
1017
+ // correctly and never reached this ledger before this row — a real,
1018
+ // disclosed-by-the-adapter-internally refusal that was nonetheless a
1019
+ // true silence from `emit()`'s own caller's point of view. `SERVES_API`
1020
+ // is the edge type this class's controller status would have produced
1021
+ // evidence for, had the base resolved.
1022
+ const unitByFile = new Map(units.map((each) => [each.file, each]));
1023
+ for (const site of census.unresolvedBaseSites) {
1024
+ const unit = unitByFile.get(site.file);
1025
+ if (unit === undefined)
1026
+ continue;
1027
+ const declared = types.get(keyOf(unit, qualify(unit.namespace, site.path)));
1028
+ if (declared === undefined)
1029
+ continue;
1030
+ ledger(declared.id, "SERVES_API", site.base, unit, site.line, `this class's controller status could not be determined: its base chain reaches \`${site.base}\`, ` +
1031
+ `which is not a declaration this run analysed — an external NuGet package (most often ` +
1032
+ `Ardalis.ApiEndpoints), or a declaration in another language in this same repository. Neither an ` +
1033
+ `API_ROUTE nor its absence is claimed for this class: this run genuinely does not know.`);
1034
+ }
1035
+ for (const route of census.routes) {
1036
+ const template = normaliseEndpointPath(route.template);
1037
+ const name = `${route.method} ${route.written}`;
1038
+ const routeId = nodeId(scope, "API_ROUTE", name, LANGUAGE);
1039
+ if (!routeNodes.has(routeId)) {
1040
+ routeNodes.set(routeId, {
1041
+ id: routeId,
1042
+ type: "API_ROUTE",
1043
+ name,
1044
+ file: route.file,
1045
+ range: { startLine: route.line, endLine: route.line },
1046
+ language: LANGUAGE,
1047
+ producedBy: input.producedBy,
1048
+ resolution: 2,
1049
+ attrs: {
1050
+ method: route.method,
1051
+ pathTemplate: template,
1052
+ rawTemplate: route.written,
1053
+ mechanism: route.mechanism,
1054
+ framework: route.framework,
1055
+ },
1056
+ });
1057
+ }
1058
+ // Workspace-scoped, fileless, language-less, and minted with the SAME
1059
+ // `endpointQsp` every other producer uses — DEC-115's one hard
1060
+ // constraint. Two producers that mint different ids do not conflict,
1061
+ // they silently fail to join.
1062
+ const endpointId = nodeId(scope, "API_ENDPOINT", endpointQsp(route.method, route.written), null);
1063
+ if (!endpointNodes.has(endpointId)) {
1064
+ endpointNodes.set(endpointId, {
1065
+ id: endpointId,
1066
+ type: "API_ENDPOINT",
1067
+ name: `${route.method} ${template}`,
1068
+ file: null,
1069
+ range: null,
1070
+ language: null,
1071
+ producedBy: input.producedBy,
1072
+ resolution: 2,
1073
+ attrs: { method: route.method, pathTemplate: template },
1074
+ });
1075
+ }
1076
+ push({ from: routeId, to: endpointId, type: "SERVES_API" }, 2);
1077
+ }
1078
+ }
1079
+ // --- The caller's half ---------------------------------------------------
1080
+ //
1081
+ // Mints into the SAME `endpointNodes` map, so a call to a route this run
1082
+ // also read produces one node with two edges rather than two nodes with
1083
+ // one each. A call to a route this run did *not* read still mints its
1084
+ // endpoint — either side can exist without the other (golden 08's note).
1085
+ for (const call of clientCallSites) {
1086
+ const endpoint = endpointFor(scope, call, input.producedBy, normaliseEndpointPath);
1087
+ if (!endpointNodes.has(endpoint.id))
1088
+ endpointNodes.set(endpoint.id, endpoint);
1089
+ push({
1090
+ from: call.fromId,
1091
+ to: endpoint.id,
1092
+ type: "USES_API",
1093
+ // DEC-164's contract: every `USES_API` edge this adapter mints now
1094
+ // carries `clientBase` — `resolved` (a traced base composed per
1095
+ // DEC-097) or `none` (no traced mechanism; the call's own literal
1096
+ // path stood alone, unchanged from before this row). See
1097
+ // `client-base.ts`'s own header for why `unresolved` is never
1098
+ // produced by this build.
1099
+ attrs: { callerKind: call.callerKind, via: call.method, clientBase: call.clientBase },
1100
+ }, 2);
1101
+ }
1102
+ nodes.push(...routeNodes.values(), ...endpointNodes.values());
1103
+ }
1104
+ // D-FIX-3's client-base extractor (DEC-164): patch `attrs.clientBase` onto
1105
+ // the caller `FUNCTION`/`TEST_CASE` node itself, not just the `USES_API`
1106
+ // edge — golden patterns 08a/08b/08c bind their `caller_*` role to the
1107
+ // node, and 08b's refused call has no edge for the fact to live on at all.
1108
+ // Done here, after every unit's calls and refusals are known, because
1109
+ // `functionNodeIndex` and `functionClientBase` are both built incrementally
1110
+ // across the whole loop above.
1111
+ for (const [fnId, base] of functionClientBase) {
1112
+ const index = functionNodeIndex.get(fnId);
1113
+ const node = index === undefined ? undefined : nodes[index];
1114
+ if (node === undefined)
1115
+ continue;
1116
+ nodes[index] = { ...node, attrs: { ...node.attrs, clientBase: base } };
1117
+ }
1118
+ return { nodes, edges, unresolved };
1119
+ }
1120
+ //# sourceMappingURL=extract.js.map