@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.
- package/dist/adapter.d.ts +31 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +263 -0
- package/dist/adapter.js.map +1 -0
- package/dist/aspnetcore.d.ts +207 -0
- package/dist/aspnetcore.d.ts.map +1 -0
- package/dist/aspnetcore.js +410 -0
- package/dist/aspnetcore.js.map +1 -0
- package/dist/client-base.d.ts +123 -0
- package/dist/client-base.d.ts.map +1 -0
- package/dist/client-base.js +123 -0
- package/dist/client-base.js.map +1 -0
- package/dist/client.d.ts +168 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +387 -0
- package/dist/client.js.map +1 -0
- package/dist/efcore.d.ts +198 -0
- package/dist/efcore.d.ts.map +1 -0
- package/dist/efcore.js +611 -0
- package/dist/efcore.js.map +1 -0
- package/dist/extract.d.ts +83 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +1120 -0
- package/dist/extract.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/nullability.d.ts +117 -0
- package/dist/nullability.d.ts.map +1 -0
- package/dist/nullability.js +152 -0
- package/dist/nullability.js.map +1 -0
- package/dist/parse.d.ts +240 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +861 -0
- package/dist/parse.js.map +1 -0
- package/dist/projects.d.ts +87 -0
- package/dist/projects.d.ts.map +1 -0
- package/dist/projects.js +226 -0
- package/dist/projects.js.map +1 -0
- package/dist/tree-sitter-c_sharp.wasm +0 -0
- package/package.json +35 -0
package/dist/extract.js
ADDED
|
@@ -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
|