@descryy/adapter-go 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +98 -10
- package/dist/adapter.js.map +1 -1
- package/dist/client-base.d.ts +83 -0
- package/dist/client-base.d.ts.map +1 -0
- package/dist/client-base.js +79 -0
- package/dist/client-base.js.map +1 -0
- package/dist/client.d.ts +26 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +208 -2
- package/dist/client.js.map +1 -1
- package/dist/extract.d.ts +53 -0
- package/dist/extract.d.ts.map +1 -1
- package/dist/extract.js +443 -25
- package/dist/extract.js.map +1 -1
- package/dist/go-types.d.ts +75 -0
- package/dist/go-types.d.ts.map +1 -0
- package/dist/go-types.js +198 -0
- package/dist/go-types.js.map +1 -0
- package/dist/gorm.d.ts +81 -0
- package/dist/gorm.d.ts.map +1 -0
- package/dist/gorm.js +140 -0
- package/dist/gorm.js.map +1 -0
- package/dist/grpc.d.ts +29 -0
- package/dist/grpc.d.ts.map +1 -1
- package/dist/grpc.js +21 -2
- package/dist/grpc.js.map +1 -1
- package/dist/parse.d.ts +41 -0
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +134 -16
- package/dist/parse.js.map +1 -1
- package/dist/routes.d.ts +18 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +156 -19
- package/dist/routes.js.map +1 -1
- package/goshapes/go.mod +17 -0
- package/goshapes/main.go +596 -0
- package/package.json +8 -7
package/dist/extract.js
CHANGED
|
@@ -3,11 +3,21 @@
|
|
|
3
3
|
* share a namespace completely). Import path is declared, so `qsp` needs no
|
|
4
4
|
* anchor inference (DEC-047). `IMPLEMENTS` never emitted — structural in Go.
|
|
5
5
|
*/
|
|
6
|
-
import { edgeId, endpointQsp, nodeId, normaliseEndpointPath, symbolQsp, testCaseQsp, } from "@descryy/ir";
|
|
6
|
+
import { edgeId, endpointQsp, externalQsp, nodeId, normaliseEndpointPath, symbolQsp, testCaseQsp, } from "@descryy/ir";
|
|
7
|
+
import { fileNode } from "@descryy/adapter-common";
|
|
7
8
|
import { attributeExternal } from "./external.js";
|
|
8
9
|
import { isUniverse } from "./parse.js";
|
|
10
|
+
import { fieldReadsIn as gormFieldReadsIn, gormShapeOf, isGormModel } from "./gorm.js";
|
|
9
11
|
import { endpointFor } from "./client.js";
|
|
10
12
|
export const LANGUAGE = "go";
|
|
13
|
+
/** Which packaging world an external module name belongs to. Go has one, and
|
|
14
|
+
* the standard library is in it — `crypto/subtle` and a module path are both
|
|
15
|
+
* named by the same `import` statement and resolved by the same toolchain. */
|
|
16
|
+
const ECOSYSTEM = "go";
|
|
17
|
+
/** An external node is attributed from the file's own `import` block, which is
|
|
18
|
+
* module resolution and nothing more. R1 exactly: never R0 (the import *is*
|
|
19
|
+
* the resolution), never R4 (nothing observed it run). */
|
|
20
|
+
const EXTERNAL_RESOLUTION = 1;
|
|
11
21
|
const CONFIDENCE = { 0: 0.5, 1: 0.7, 2: 0.9, 3: 0.95 };
|
|
12
22
|
const REASONS = {
|
|
13
23
|
outside: "names a package this run did not analyse — the standard library, a module in the build " +
|
|
@@ -24,7 +34,87 @@ const REASONS = {
|
|
|
24
34
|
routeIdCollision: "this route's method and path are identical to one already emitted, so it collapsed onto " +
|
|
25
35
|
"the same API_ROUTE node and this declaration's own SERVES_API edge and location were " +
|
|
26
36
|
"dropped. The endpoint is real; this specific declaration is not the one the graph kept.",
|
|
37
|
+
grpcImplUnreadable: "the registration's implementation argument is not an inline `Type{}` (optionally `&Type{}`) value and " +
|
|
38
|
+
"not a plain name — a wrapper call, a selector expression, or a form this reader does not read. The " +
|
|
39
|
+
"route is real; the code implementing it is not established.",
|
|
40
|
+
grpcImplUntyped: "the registration's implementation argument names a value with no declared type this reader could " +
|
|
41
|
+
"trace to a composite literal — a function parameter, an interface-typed value with no concrete local " +
|
|
42
|
+
"binding, or a name declared outside the analysed set. The route is real; the code implementing it is " +
|
|
43
|
+
"not established.",
|
|
27
44
|
};
|
|
45
|
+
/**
|
|
46
|
+
* The fallback graph when `extract()` throws mid-run, per
|
|
47
|
+
* `DEC-NEXT-degraded-graph-shape-for-adapters-without-one.md`.
|
|
48
|
+
*
|
|
49
|
+
* `MODULE`, not `FILE` — unlike C#/Java (whose module identity is a
|
|
50
|
+
* declaration *inside* the file, exactly the machinery a mid-extraction
|
|
51
|
+
* throw means just failed), Go's import path is, in this adapter's own
|
|
52
|
+
* words, "declared, so `qsp` needs no anchor inference": it is resolved from
|
|
53
|
+
* `go.mod`'s `module` line plus the file's own directory (`modules.ts`'s
|
|
54
|
+
* `importPathOf`), never from parsing the file's content. `prepare()`
|
|
55
|
+
* already computed it, successfully, for every unit that reaches this
|
|
56
|
+
* function — `isolateFile` keeps a per-file parse/walk throw from ever
|
|
57
|
+
* populating `input.files` in the first place, so a throw here is
|
|
58
|
+
* necessarily further downstream (cross-file resolution, edge building), not
|
|
59
|
+
* a sign the identity below is unreliable. Minting `MODULE` with the exact
|
|
60
|
+
* id computation `extract()`'s own healthy path uses (`namespaceOf` +
|
|
61
|
+
* `symbolQsp`) means a degraded run's nodes merge with a later healthy run's
|
|
62
|
+
* for the same files, rather than forking against them the way a generic
|
|
63
|
+
* `FILE` node would.
|
|
64
|
+
*
|
|
65
|
+
* The per-file `try`/`catch` is defensive only: `namespaceOf` reads fields
|
|
66
|
+
* `prepare()` already resolved without error, so it should be unreachable in
|
|
67
|
+
* practice. If some future caller ever hands this function a unit whose
|
|
68
|
+
* fields are not what `prepare()` guarantees, falling back to the shared,
|
|
69
|
+
* language-blind `fileNode` (`@descryy/adapter-common`) for that one file
|
|
70
|
+
* keeps the fallback itself from becoming a second uncaught throw.
|
|
71
|
+
*
|
|
72
|
+
* One disclosure row, not one per file — the fact is about the run. Anchored
|
|
73
|
+
* to the first file's node because the ledger requires a `fromNodeId`.
|
|
74
|
+
*/
|
|
75
|
+
export function degradedGraph(input) {
|
|
76
|
+
const scope = { repo: input.repo, workspace: input.workspace };
|
|
77
|
+
const nodes = input.files.map((unit) => {
|
|
78
|
+
try {
|
|
79
|
+
const namespace = namespaceOf(unit);
|
|
80
|
+
const stem = unit.file.slice(unit.file.lastIndexOf("/") + 1).replace(/\.go$/, "");
|
|
81
|
+
return {
|
|
82
|
+
id: nodeId(scope, "MODULE", symbolQsp(namespace, [stem]), LANGUAGE),
|
|
83
|
+
type: "MODULE",
|
|
84
|
+
name: `${stem}.go`,
|
|
85
|
+
file: unit.file,
|
|
86
|
+
range: { startLine: 1, endLine: 1 },
|
|
87
|
+
language: LANGUAGE,
|
|
88
|
+
producedBy: input.producedBy,
|
|
89
|
+
resolution: 0,
|
|
90
|
+
attrs: { parsed: false },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return fileNode(scope, unit.file, input.producedBy);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
const anchor = nodes[0];
|
|
98
|
+
const unresolved = anchor === undefined
|
|
99
|
+
? []
|
|
100
|
+
: [
|
|
101
|
+
{
|
|
102
|
+
fromNodeId: anchor.id,
|
|
103
|
+
edgeType: "IMPORTS",
|
|
104
|
+
rawTarget: "(entire repository)",
|
|
105
|
+
file: anchor.file,
|
|
106
|
+
line: 1,
|
|
107
|
+
producedBy: input.producedBy,
|
|
108
|
+
reason: `NO GRAPH WAS PRODUCED for ${String(input.files.length)} Go file(s). ${input.cause}. ` +
|
|
109
|
+
"Modules are listed because their import paths are a fact about the filesystem " +
|
|
110
|
+
"(go.mod plus directory); their contents are absent. Every finding, coverage figure " +
|
|
111
|
+
"and 'not affected' statement over this repository is UNSUPPORTED — this is a failed " +
|
|
112
|
+
"analysis, not an empty repository.",
|
|
113
|
+
attrs: { refusalClass: "capability-gap" },
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
return { nodes, edges: [], unresolved };
|
|
117
|
+
}
|
|
28
118
|
/** How many hops of `var A = B; var B = C` to follow before giving up. */
|
|
29
119
|
const ALIAS_PASSES = 4;
|
|
30
120
|
/** Usually the import path, except Go's external test package (`package storage_test`) is distinct — collapsing them lost `contextDone`/`makeInt64Pointer` on prometheus. */
|
|
@@ -91,26 +181,49 @@ export function extract(input) {
|
|
|
91
181
|
const packages = new Map();
|
|
92
182
|
const moduleIdOf = new Map();
|
|
93
183
|
const ownModulePaths = input.ownModulePaths ?? [];
|
|
184
|
+
// --- Go/types shape evidence (R3) --------------------------------------
|
|
185
|
+
//
|
|
186
|
+
// `input.goShapes.structs` is already the "wanted" set — `goshapes/main.go`
|
|
187
|
+
// only ever records a struct a handler actually named as a request or
|
|
188
|
+
// response (`extractor.remember`, mirrored precision-over-recall stance to
|
|
189
|
+
// `adapter-python`'s DTO promotion never emitting an unreferenced class).
|
|
190
|
+
// Keyed by `${importPath}::${typeName}`, matching this file's own
|
|
191
|
+
// `${namespace}::${type.name}` key everywhere else a Go type is looked up.
|
|
192
|
+
const goStructsReady = input.reached >= 3 && input.goShapes !== undefined;
|
|
193
|
+
const goStructByKey = new Map();
|
|
194
|
+
if (goStructsReady) {
|
|
195
|
+
for (const s of input.goShapes.structs)
|
|
196
|
+
goStructByKey.set(`${s.importPath}::${s.name}`, s);
|
|
197
|
+
}
|
|
198
|
+
/** Populated only as each DTO node is actually minted below — the one
|
|
199
|
+
* source of truth for "does this id exist in `nodes`", so the handler ->
|
|
200
|
+
* DTO pass a bit further down can never point `USES_TYPE` at a node this
|
|
201
|
+
* run did not emit (a dangling edge is exactly the wrong-edge corruption
|
|
202
|
+
* rule 2 exists to prevent). */
|
|
203
|
+
const dtoIdByKey = new Map();
|
|
94
204
|
/**
|
|
95
205
|
* A boundary row, with the package behind it named when the file's own
|
|
96
206
|
* `import` block settles which one it is.
|
|
97
207
|
*
|
|
98
208
|
* `REASONS.outside` says the callee is a scope boundary rather than an
|
|
99
209
|
* analysis gap — correct, and this does not change it. What it adds is the
|
|
100
|
-
* fact the boundary *is*:
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
210
|
+
* fact the boundary *is*: which dependency control left into, and which
|
|
211
|
+
* symbol in it. When `external.ts` settles that from a binding the developer
|
|
212
|
+
* wrote, the site **mints a `FUNCTION` node standing for the dependency's
|
|
213
|
+
* symbol and a `CALLS` edge into it**, and files no ledger row — a site that
|
|
214
|
+
* resolves to a node is not an `UnresolvedRef`, and filing both would count
|
|
215
|
+
* it twice. When `external.ts` declines, the row is filed unchanged and says
|
|
216
|
+
* only that control left; the reasons it declines are in that module, each a
|
|
217
|
+
* recall cost paid for the 1-in-1,818 precision bar.
|
|
105
218
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* and a
|
|
113
|
-
*
|
|
219
|
+
* Three settled rules govern the node
|
|
220
|
+
* (`DEC-NEXT-external-symbol-nodes-at-the-dependency-boundary`): the qsp
|
|
221
|
+
* carries an `ext:` marker (`externalQsp`), the node may claim R1–R3 but
|
|
222
|
+
* never R0 or R4 — attribution *is* module resolution, so R1 is its floor —
|
|
223
|
+
* and it owns no outgoing edge, because nothing read its body. `thirdParty`
|
|
224
|
+
* rides in `attrs`, never in the hash: a package that moves between the
|
|
225
|
+
* standard library and a module would otherwise change identity with no line
|
|
226
|
+
* of source changing.
|
|
114
227
|
*/
|
|
115
228
|
const boundary = (from, unit, scope, ref) => {
|
|
116
229
|
const attribution = attributeExternal({
|
|
@@ -126,16 +239,53 @@ export function extract(input) {
|
|
|
126
239
|
ledger(from, "CALLS", ref.raw, unit, ref.line, REASONS.outside);
|
|
127
240
|
return;
|
|
128
241
|
}
|
|
129
|
-
|
|
130
|
-
|
|
242
|
+
const to = mintExternal(attribution);
|
|
243
|
+
if (to === null) {
|
|
244
|
+
// R0: nothing resolved a module, so there is no binding to have read and
|
|
245
|
+
// the node would claim more than the run earned. The boundary is still a
|
|
246
|
+
// fact and stays a row.
|
|
247
|
+
ledger(from, "CALLS", ref.raw, unit, ref.line, REASONS.outside);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
push({ from, to, type: "CALLS" }, EXTERNAL_RESOLUTION);
|
|
251
|
+
};
|
|
252
|
+
/** Node ids already minted for a dependency symbol — one node per symbol, not
|
|
253
|
+
* per call site. Measured node growth is +6.9% (prometheus) to +13.0% (gin). */
|
|
254
|
+
const externalIds = new Map();
|
|
255
|
+
/**
|
|
256
|
+
* The `FUNCTION` node standing for one symbol in a dependency, or `null` when
|
|
257
|
+
* this run did not reach the resolution such a node requires.
|
|
258
|
+
*/
|
|
259
|
+
const mintExternal = (attribution) => {
|
|
260
|
+
if (input.reached < EXTERNAL_RESOLUTION)
|
|
261
|
+
return null;
|
|
262
|
+
const qsp = externalQsp(attribution.moduleOrNamespace, attribution.symbolPath);
|
|
263
|
+
const existing = externalIds.get(qsp);
|
|
264
|
+
if (existing !== undefined)
|
|
265
|
+
return existing;
|
|
266
|
+
const id = nodeId(scope, "FUNCTION", qsp, LANGUAGE);
|
|
267
|
+
externalIds.set(qsp, id);
|
|
268
|
+
nodes.push({
|
|
269
|
+
id,
|
|
270
|
+
type: "FUNCTION",
|
|
271
|
+
name: attribution.symbolPath[attribution.symbolPath.length - 1] ?? "",
|
|
272
|
+
// No file and no range: the symbol lives in a dependency, and a node that
|
|
273
|
+
// named a file here would be invalidated by a source file it has nothing
|
|
274
|
+
// to do with.
|
|
275
|
+
file: null,
|
|
276
|
+
range: null,
|
|
277
|
+
language: LANGUAGE,
|
|
278
|
+
producedBy: input.producedBy,
|
|
279
|
+
resolution: EXTERNAL_RESOLUTION,
|
|
280
|
+
attrs: { thirdParty: attribution.thirdParty },
|
|
131
281
|
external: {
|
|
282
|
+
ecosystem: ECOSYSTEM,
|
|
132
283
|
// Identity, and it is the import path the source wrote — never a
|
|
133
284
|
// module-cache path, never a `require` line's version.
|
|
134
285
|
moduleOrNamespace: attribution.moduleOrNamespace,
|
|
135
|
-
symbol: attribution.symbolPath.join("."),
|
|
136
|
-
thirdParty: attribution.thirdParty,
|
|
137
286
|
},
|
|
138
287
|
});
|
|
288
|
+
return id;
|
|
139
289
|
};
|
|
140
290
|
/** Node id -> other files declaring it under a different build constraint, disclosed on the node. */
|
|
141
291
|
const alternates = new Map();
|
|
@@ -144,6 +294,29 @@ export function extract(input) {
|
|
|
144
294
|
/** Two `t.Run` calls sharing a literal name are two subtests; `occurrence` keeps both. */
|
|
145
295
|
const testOccurrence = new Map();
|
|
146
296
|
const describe = (unit) => unit.buildConstraint === undefined ? unit.file : `${unit.file} (//go:build ${unit.buildConstraint})`;
|
|
297
|
+
/**
|
|
298
|
+
* Methods by `package::Type`, collected before anything is minted.
|
|
299
|
+
*
|
|
300
|
+
* A Go method lives wherever its author put it, routinely in a different
|
|
301
|
+
* file from its type — `TableName()` in `table.go` next to `Order` in
|
|
302
|
+
* `order.go` is ordinary. Deciding the ORM shape inside the per-file loop
|
|
303
|
+
* below would read whichever half happened to come first.
|
|
304
|
+
*/
|
|
305
|
+
const methodsByType = new Map();
|
|
306
|
+
for (const unit of input.files) {
|
|
307
|
+
if (unit.importPath === "")
|
|
308
|
+
continue;
|
|
309
|
+
for (const fn of unit.funcs) {
|
|
310
|
+
if (fn.receiver === undefined)
|
|
311
|
+
continue;
|
|
312
|
+
const key = `${namespaceOf(unit)}::${fn.receiver}`;
|
|
313
|
+
const found = methodsByType.get(key);
|
|
314
|
+
if (found === undefined)
|
|
315
|
+
methodsByType.set(key, [fn]);
|
|
316
|
+
else
|
|
317
|
+
found.push(fn);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
147
320
|
for (const unit of input.files) {
|
|
148
321
|
if (unit.importPath === "")
|
|
149
322
|
continue;
|
|
@@ -170,24 +343,83 @@ export function extract(input) {
|
|
|
170
343
|
attrs: unit.hasError ? { parsedWithErrors: true } : {},
|
|
171
344
|
});
|
|
172
345
|
for (const type of unit.types) {
|
|
173
|
-
|
|
346
|
+
// MODEL is a framework claim and the only evidence for it is a `gorm`
|
|
347
|
+
// struct tag — see `gorm.ts`. DTO is the same kind of promotion, on
|
|
348
|
+
// go/types evidence instead: a struct is a DTO here iff the Go helper
|
|
349
|
+
// itself named it as some handler's decoded/encoded shape — never
|
|
350
|
+
// guessed from the struct's own name or fields. MODEL wins where both
|
|
351
|
+
// somehow apply (a type that is both persisted and decoded at a
|
|
352
|
+
// boundary is primarily the table, mirroring `adapter-python`'s own
|
|
353
|
+
// tie-break — a wrong `MODEL` claims a database, a wrong `DTO` claims a
|
|
354
|
+
// wire format). The node type is part of the hash (DEC-004), so it has
|
|
355
|
+
// to be decided before the id is minted.
|
|
356
|
+
const isModel = isGormModel(type);
|
|
357
|
+
const goShape = !isModel ? goStructByKey.get(`${namespace}::${type.name}`) : undefined;
|
|
358
|
+
const isDto = goShape !== undefined;
|
|
359
|
+
const id = nodeId(scope, isModel ? "MODEL" : isDto ? "DTO" : "CLASS", symbolQsp(namespace, [type.name]), LANGUAGE);
|
|
174
360
|
// Legal when behind mutually exclusive build constraints (`gin`'s `Binding`) — one node, alternatives disclosed.
|
|
175
361
|
const existing = pkg.types.get(type.name);
|
|
176
362
|
if (existing !== undefined) {
|
|
177
363
|
alternates.set(id, [...(alternates.get(id) ?? []), describe(unit)]);
|
|
178
364
|
continue;
|
|
179
365
|
}
|
|
180
|
-
pkg.types.set(type.name, { id, type, unit });
|
|
366
|
+
pkg.types.set(type.name, { id, type, unit, isModel });
|
|
367
|
+
if (isDto)
|
|
368
|
+
dtoIdByKey.set(`${namespace}::${type.name}`, id);
|
|
369
|
+
const ormAttrs = {};
|
|
370
|
+
const dtoAttrs = {};
|
|
371
|
+
if (isDto && goShape !== undefined) {
|
|
372
|
+
// **R3, unconditionally** — `goStructsReady` already gated whether
|
|
373
|
+
// `goShape` could exist at all (`input.reached >= 3`), so every DTO
|
|
374
|
+
// this branch reaches is real go/types evidence, never a hint the
|
|
375
|
+
// run has to wait on the way `adapter-python`'s annotation-derived
|
|
376
|
+
// DTO does (DEC-062's tradeoff does not apply here: this shape was
|
|
377
|
+
// CHECKED, not merely written down).
|
|
378
|
+
//
|
|
379
|
+
// `fields` is names and `fieldDetail` is shapes — `adapter-openapi`'s
|
|
380
|
+
// convention, matched rather than reinvented (golden 05 compares a
|
|
381
|
+
// list of names; two producers describing the same construct two
|
|
382
|
+
// ways is the defect DEC-115 exists to prevent for endpoints).
|
|
383
|
+
dtoAttrs["schema"] = "go-types";
|
|
384
|
+
dtoAttrs["fields"] = goShape.fields.filter((f) => f.skip !== true).map((f) => f.name);
|
|
385
|
+
dtoAttrs["fieldDetail"] = goShape.fields.map((f) => ({
|
|
386
|
+
name: f.name,
|
|
387
|
+
type: f.type,
|
|
388
|
+
...(f.jsonName === undefined ? {} : { jsonName: f.jsonName }),
|
|
389
|
+
...(f.embedded === true ? { embedded: true } : {}),
|
|
390
|
+
...(f.skip === true ? { skip: true } : {}),
|
|
391
|
+
}));
|
|
392
|
+
}
|
|
393
|
+
if (isModel) {
|
|
394
|
+
const shape = gormShapeOf(type, methodsByType.get(`${namespace}::${type.name}`) ?? []);
|
|
395
|
+
ormAttrs["orm"] = "gorm";
|
|
396
|
+
if (shape.table !== null)
|
|
397
|
+
ormAttrs["table"] = shape.table;
|
|
398
|
+
else {
|
|
399
|
+
ormAttrs["tableUnresolved"] = true;
|
|
400
|
+
ormAttrs["tableUnresolvedReason"] =
|
|
401
|
+
"the type declares no `TableName()` returning a literal, so only GORM's NamingStrategy " +
|
|
402
|
+
"would say — configuration set in application code this adapter never reads, refused " +
|
|
403
|
+
"rather than guessed";
|
|
404
|
+
}
|
|
405
|
+
if (shape.fields.length > 0)
|
|
406
|
+
ormAttrs["fields"] = shape.fields;
|
|
407
|
+
// No DATABASE_TABLE/DATABASE_COLUMN. A `column:` tag does declare a
|
|
408
|
+
// physical name, but it is optional and usually omitted, so minting on
|
|
409
|
+
// the tagged subset yields a node that reads as a complete table and is
|
|
410
|
+
// not one. `gorm.ts`'s header has the full argument.
|
|
411
|
+
ormAttrs["databaseTableRefused"] = "column-tags-are-optional-so-the-set-is-incomplete";
|
|
412
|
+
}
|
|
181
413
|
nodes.push({
|
|
182
414
|
id,
|
|
183
|
-
type: "CLASS",
|
|
415
|
+
type: isModel ? "MODEL" : isDto ? "DTO" : "CLASS",
|
|
184
416
|
name: type.name,
|
|
185
417
|
file: unit.file,
|
|
186
418
|
range: { startLine: type.startLine, endLine: type.endLine },
|
|
187
419
|
language: LANGUAGE,
|
|
188
420
|
producedBy: input.producedBy,
|
|
189
421
|
resolution: 0,
|
|
190
|
-
attrs: { declarationForm: type.form },
|
|
422
|
+
attrs: { declarationForm: type.form, ...ormAttrs, ...dtoAttrs },
|
|
191
423
|
});
|
|
192
424
|
}
|
|
193
425
|
for (const fn of unit.funcs) {
|
|
@@ -234,6 +466,53 @@ export function extract(input) {
|
|
|
234
466
|
}
|
|
235
467
|
}
|
|
236
468
|
}
|
|
469
|
+
// --- USES_TYPE, handler -> its go/types-confirmed request/response DTO ---
|
|
470
|
+
//
|
|
471
|
+
// **R3, and the reason it can be:** unlike a route's own written contract
|
|
472
|
+
// (a Python `response_model=`, a Java annotation), nothing at a Go
|
|
473
|
+
// registration site names which struct a handler decodes or encodes —
|
|
474
|
+
// that fact lives inside the function body, in a `Decode`/`Bind`/`JSON`
|
|
475
|
+
// call, and finding it needs data flow through a type checker, not a name
|
|
476
|
+
// to resolve. That is a stronger claim than R2's reference resolution, and
|
|
477
|
+
// it is exactly what `goShapes` supplies.
|
|
478
|
+
//
|
|
479
|
+
// Correlated by `${importPath}::${receiver}.${funcName}` against `packages`'
|
|
480
|
+
// own `funcs` map — the SAME map `route.handlerRaw` resolution already
|
|
481
|
+
// trusts a few hundred lines up — and by `dtoIdByKey` for the DTO side, so
|
|
482
|
+
// an edge is only ever pushed at an id this run actually minted a node
|
|
483
|
+
// for. Anything that fails to correlate (the go/types pass named a
|
|
484
|
+
// package or struct this run's own tree-sitter pass never read, or the
|
|
485
|
+
// struct lived under a namespace the two readers computed differently)
|
|
486
|
+
// is silently not an edge — a disclosed recall gap between two readers of
|
|
487
|
+
// the same repository, never a guess at which node was meant.
|
|
488
|
+
if (goStructsReady) {
|
|
489
|
+
for (const found of input.goShapes.handlers) {
|
|
490
|
+
const pkg = packages.get(found.importPath);
|
|
491
|
+
if (pkg === undefined)
|
|
492
|
+
continue;
|
|
493
|
+
const key = found.receiver === undefined ? found.funcName : `${found.receiver}.${found.funcName}`;
|
|
494
|
+
const fn = pkg.funcs.get(key);
|
|
495
|
+
if (fn === undefined)
|
|
496
|
+
continue;
|
|
497
|
+
const rolesByDto = new Map();
|
|
498
|
+
const addRole = (ref, role) => {
|
|
499
|
+
if (ref === undefined)
|
|
500
|
+
return;
|
|
501
|
+
const dtoId = dtoIdByKey.get(`${ref.importPath}::${ref.name}`);
|
|
502
|
+
if (dtoId === undefined)
|
|
503
|
+
return;
|
|
504
|
+
const roles = rolesByDto.get(dtoId) ?? [];
|
|
505
|
+
if (!roles.includes(role))
|
|
506
|
+
roles.push(role);
|
|
507
|
+
rolesByDto.set(dtoId, roles);
|
|
508
|
+
};
|
|
509
|
+
addRole(found.request, "request");
|
|
510
|
+
addRole(found.response, "response");
|
|
511
|
+
for (const [dtoId, roles] of rolesByDto) {
|
|
512
|
+
push({ from: fn.id, to: dtoId, type: "USES_TYPE", attrs: { roles } }, 3);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
237
516
|
/** A written type name, split into its package qualifier and its own name. */
|
|
238
517
|
const split = (written) => {
|
|
239
518
|
const cut = written.indexOf(".");
|
|
@@ -442,6 +721,14 @@ export function extract(input) {
|
|
|
442
721
|
}
|
|
443
722
|
/** The caller's half of the HTTP boundary, minted with the routes below. */
|
|
444
723
|
const clientCallSites = [];
|
|
724
|
+
/**
|
|
725
|
+
* DEC-164: caller node id -> the base this reader traced for it. First
|
|
726
|
+
* writer wins, so a function making two calls is described by the first one
|
|
727
|
+
* read rather than by whichever was walked last. Patched onto the node
|
|
728
|
+
* after the walk, the way `adapter-csharp` does it — the node is already in
|
|
729
|
+
* `disclosed` by the time its calls are read.
|
|
730
|
+
*/
|
|
731
|
+
const functionClientBase = new Map();
|
|
445
732
|
for (const unit of input.files) {
|
|
446
733
|
const moduleId = moduleIdOf.get(unit.file);
|
|
447
734
|
if (moduleId === undefined) {
|
|
@@ -490,11 +777,39 @@ export function extract(input) {
|
|
|
490
777
|
if (subtestId !== undefined)
|
|
491
778
|
emitRefs(subtestId, subtest, true, unit);
|
|
492
779
|
}
|
|
780
|
+
// `order.TotalAmount` where `var order orders.Order` — golden 07. Go's
|
|
781
|
+
// parser already records `X.Y` as a `member` ref, so the only thing
|
|
782
|
+
// added here is the receiver's DECLARED type. `order := fetch(id)`
|
|
783
|
+
// states nothing at the binding site and produces nothing.
|
|
784
|
+
for (const read of gormFieldReadsIn(fn, (written) => {
|
|
785
|
+
const resolved = resolveType(unit, written);
|
|
786
|
+
return "declared" in resolved && resolved.declared.isModel === true
|
|
787
|
+
? resolved.declared.id
|
|
788
|
+
: undefined;
|
|
789
|
+
})) {
|
|
790
|
+
if (read.model === declared.id)
|
|
791
|
+
continue;
|
|
792
|
+
push({ from: declared.id, to: read.model, type: "READS", attrs: { fields: read.fields } },
|
|
793
|
+
// Both halves are written names resolved to a declaration — R2, the
|
|
794
|
+
// rung every other edge here rests on. The SHAPE lives on the MODEL
|
|
795
|
+
// node; the edge claims no more than it saw.
|
|
796
|
+
2);
|
|
797
|
+
}
|
|
493
798
|
// The caller's half of the HTTP boundary — DEC-262's fork, Go's half.
|
|
494
799
|
// Collected here, where the enclosing FUNCTION's own id is finally known,
|
|
495
800
|
// and emitted with the routes below so both halves mint one API_ENDPOINT.
|
|
496
|
-
for (const call of fn.clientCalls)
|
|
801
|
+
for (const call of fn.clientCalls) {
|
|
497
802
|
clientCallSites.push({ ...call, fromId: declared.id });
|
|
803
|
+
if (!functionClientBase.has(declared.id))
|
|
804
|
+
functionClientBase.set(declared.id, call.clientBase);
|
|
805
|
+
}
|
|
806
|
+
for (const refusal of fn.clientRefusals) {
|
|
807
|
+
// A refusal still states a base when DEC-164 reached one — 08b's whole
|
|
808
|
+
// point is that `unresolved` is a reading, not a silence.
|
|
809
|
+
if (refusal.clientBase !== undefined && !functionClientBase.has(declared.id)) {
|
|
810
|
+
functionClientBase.set(declared.id, refusal.clientBase);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
498
813
|
for (const refusal of fn.clientRefusals) {
|
|
499
814
|
ledger(declared.id, "USES_API", refusal.raw, unit, refusal.line, refusal.reason,
|
|
500
815
|
// Unset `refusalClass` stays legal and means unclassified — DEC-242 — so
|
|
@@ -505,6 +820,8 @@ export function extract(input) {
|
|
|
505
820
|
blockedBy: refusal.blockedBy,
|
|
506
821
|
refusalClass: refusal.refusalClass,
|
|
507
822
|
...(refusal.argumentKind === undefined ? {} : { argumentKind: refusal.argumentKind }),
|
|
823
|
+
...(refusal.clientBase === undefined ? {} : { clientBase: refusal.clientBase }),
|
|
824
|
+
...(refusal.callPath === undefined ? {} : { callPath: refusal.callPath }),
|
|
508
825
|
});
|
|
509
826
|
}
|
|
510
827
|
}
|
|
@@ -688,9 +1005,24 @@ export function extract(input) {
|
|
|
688
1005
|
// one node, and the node says which files it stood in for.
|
|
689
1006
|
const disclosed = nodes.map((node) => {
|
|
690
1007
|
const others = alternates.get(node.id);
|
|
691
|
-
|
|
1008
|
+
// DEC-164's client base. NOT behind the R2 gate the `USES_API` edge sits
|
|
1009
|
+
// behind: the golden asserts `attrs.clientBase` at R2 and the field is a
|
|
1010
|
+
// reading of the source, not of a rung. `adapter-python` shipped this
|
|
1011
|
+
// patch after an `input.reached` guard and it was green at R3 and absent
|
|
1012
|
+
// at R2 — only the monotonic sweep saw it.
|
|
1013
|
+
const base = functionClientBase.get(node.id);
|
|
1014
|
+
// The field's ABSENCE is DEC-164's fourth state: a function no HTTP call
|
|
1015
|
+
// was read from says nothing about a base, rather than saying `none`.
|
|
1016
|
+
if (others === undefined && base === undefined)
|
|
692
1017
|
return node;
|
|
693
|
-
return {
|
|
1018
|
+
return {
|
|
1019
|
+
...node,
|
|
1020
|
+
attrs: {
|
|
1021
|
+
...node.attrs,
|
|
1022
|
+
...(others === undefined ? {} : { buildConstrainedAlternates: others }),
|
|
1023
|
+
...(base === undefined ? {} : { clientBase: base }),
|
|
1024
|
+
},
|
|
1025
|
+
};
|
|
694
1026
|
});
|
|
695
1027
|
// Routes (DEC-117), last: every ledger row attributes to a MODULE the pass above already minted.
|
|
696
1028
|
const routeNodes = new Map();
|
|
@@ -822,6 +1154,52 @@ export function extract(input) {
|
|
|
822
1154
|
}
|
|
823
1155
|
}
|
|
824
1156
|
}
|
|
1157
|
+
/** The Go function whose body contains this line, innermost first — a registration inside a closure resolves against the closure's own locals, not its enclosing function's. */
|
|
1158
|
+
const enclosingFunc = (unit, line) => {
|
|
1159
|
+
let found;
|
|
1160
|
+
for (const fn of unit.funcs) {
|
|
1161
|
+
if (fn.startLine <= line && line <= fn.endLine) {
|
|
1162
|
+
if (found === undefined || fn.endLine - fn.startLine < found.endLine - found.startLine)
|
|
1163
|
+
found = fn;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
return found;
|
|
1167
|
+
};
|
|
1168
|
+
/**
|
|
1169
|
+
* `Register<Service>Server(server, impl)`'s `impl` argument, resolved to a
|
|
1170
|
+
* declared type the same way every other receiver in this file is: an
|
|
1171
|
+
* inline composite literal (`&orderServer{}`) names its type directly;
|
|
1172
|
+
* a bare name is looked up against its enclosing function's locals, a
|
|
1173
|
+
* pending call-result binding, then the package's own vars — the same
|
|
1174
|
+
* single-segment case `typeOfPath` already handles, read out here because
|
|
1175
|
+
* the registration site (`grpc.ts`) has no function scope of its own to
|
|
1176
|
+
* hand back.
|
|
1177
|
+
*
|
|
1178
|
+
* Never fails silently: every branch that cannot resolve returns a reason,
|
|
1179
|
+
* and the gRPC loop below discloses it once per route rather than dropping
|
|
1180
|
+
* the registration the way it silently did before (`DEC-NEXT-grpc-handler-
|
|
1181
|
+
* edge-silent-gap`).
|
|
1182
|
+
*/
|
|
1183
|
+
const resolveGrpcImplType = (unit, registration) => {
|
|
1184
|
+
const impl = registration.impl;
|
|
1185
|
+
if (impl === undefined)
|
|
1186
|
+
return { reason: REASONS.grpcImplUnreadable };
|
|
1187
|
+
if (impl.kind === "literal")
|
|
1188
|
+
return resolveType(unit, impl.typeWritten);
|
|
1189
|
+
const fn = enclosingFunc(unit, registration.line);
|
|
1190
|
+
const local = fn?.locals.get(impl.name);
|
|
1191
|
+
if (local !== undefined && local !== null)
|
|
1192
|
+
return resolveType(unit, local);
|
|
1193
|
+
if (fn !== undefined && fn.pending.has(impl.name)) {
|
|
1194
|
+
const fromCall = resultTypeOf(unit, fn, impl.name, 0);
|
|
1195
|
+
if (fromCall !== undefined)
|
|
1196
|
+
return fromCall;
|
|
1197
|
+
}
|
|
1198
|
+
const packageLevel = packageVarType(unit, impl.name);
|
|
1199
|
+
if (packageLevel !== undefined)
|
|
1200
|
+
return resolveType(unit, packageLevel);
|
|
1201
|
+
return { reason: REASONS.grpcImplUntyped };
|
|
1202
|
+
};
|
|
825
1203
|
// --- gRPC routes — see `grpc.ts`'s own header for the wire-path-is-read, not-
|
|
826
1204
|
// invented rationale and the `method: "RPC"` sentinel. Cross-file by nature:
|
|
827
1205
|
// a `_FullMethodName` const and the `Register<Service>Server(...)` call site
|
|
@@ -858,6 +1236,9 @@ export function extract(input) {
|
|
|
858
1236
|
const methods = targetNamespace === undefined ? undefined : grpcMethodsByNamespace.get(targetNamespace)?.get(registration.service);
|
|
859
1237
|
if (methods === undefined || methods.length === 0)
|
|
860
1238
|
continue;
|
|
1239
|
+
// Computed once per registration, not per method: every RPC this
|
|
1240
|
+
// registration serves is implemented by the SAME `impl` value.
|
|
1241
|
+
const implResolution = resolveGrpcImplType(unit, registration);
|
|
861
1242
|
for (const method of methods) {
|
|
862
1243
|
const template = normaliseEndpointPath(method.path);
|
|
863
1244
|
const name = `RPC ${method.path}`;
|
|
@@ -901,6 +1282,25 @@ export function extract(input) {
|
|
|
901
1282
|
});
|
|
902
1283
|
}
|
|
903
1284
|
push({ from: routeId, to: endpointId, type: "SERVES_API" }, 2);
|
|
1285
|
+
// --- Which method implements it ------------------------------------
|
|
1286
|
+
//
|
|
1287
|
+
// Unlike an HTTP handler, a gRPC method is always named — the const
|
|
1288
|
+
// this route was minted from already names it. What was missing is
|
|
1289
|
+
// resolving `impl`'s type and finding that name on it. `SERVES_API`
|
|
1290
|
+
// stopping here and nothing else being said was the bug: every route
|
|
1291
|
+
// below gets either a CALLS edge or a disclosed reason, never neither.
|
|
1292
|
+
if ("declared" in implResolution) {
|
|
1293
|
+
const target = findMethod(implResolution.declared, method.method);
|
|
1294
|
+
if (target !== undefined) {
|
|
1295
|
+
push({ from: routeId, to: target.id, type: "CALLS" }, 2);
|
|
1296
|
+
}
|
|
1297
|
+
else {
|
|
1298
|
+
ledger(routeId, "CALLS", `${registration.service}.${method.method}`, unit, registration.line, REASONS.unmodelled);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
else {
|
|
1302
|
+
ledger(routeId, "CALLS", `${registration.service}.${method.method}`, unit, registration.line, implResolution.reason);
|
|
1303
|
+
}
|
|
904
1304
|
}
|
|
905
1305
|
}
|
|
906
1306
|
}
|
|
@@ -925,6 +1325,24 @@ export function extract(input) {
|
|
|
925
1325
|
}
|
|
926
1326
|
}
|
|
927
1327
|
const withRoutes = [...disclosed, ...routeNodes.values(), ...endpointNodes.values()];
|
|
1328
|
+
// A caller that asked for R3 and did not get it hears why, the same way a
|
|
1329
|
+
// whole-run degradation does (`degradedGraph`, above) — just scoped to the
|
|
1330
|
+
// one capability that fell back, not the whole graph.
|
|
1331
|
+
if (input.goShapesUnavailableReason !== undefined) {
|
|
1332
|
+
const anchor = withRoutes[0];
|
|
1333
|
+
if (anchor !== undefined) {
|
|
1334
|
+
unresolved.push({
|
|
1335
|
+
fromNodeId: anchor.id,
|
|
1336
|
+
edgeType: "USES_TYPE",
|
|
1337
|
+
rawTarget: "(go/types shape extraction)",
|
|
1338
|
+
file: anchor.file,
|
|
1339
|
+
line: 1,
|
|
1340
|
+
producedBy: input.producedBy,
|
|
1341
|
+
reason: input.goShapesUnavailableReason,
|
|
1342
|
+
attrs: { refusalClass: "capability-gap" },
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
928
1346
|
return { nodes: withRoutes, edges, unresolved };
|
|
929
1347
|
}
|
|
930
1348
|
export { REASONS };
|