@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/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*: `attrs.external` carries the import path the source
101
- * wrote and the symbol chain, so the row states which dependency control left
102
- * into instead of only that it left. A row with no `attrs.external` is one
103
- * this adapter declined to attribute, and the reasons it declines are in
104
- * `external.ts` each is a recall cost paid for the 1-in-1,818 precision bar.
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
- * **This does not yet mint a node.** The IR gained the `external`
107
- * discriminator that lets a `FUNCTION` stand for a symbol in a dependency;
108
- * this package is pinned to the release before it, and emitting such a node
109
- * against that release would have it rejected `MISSING_FILE` and every edge
110
- * to it rejected `EDGE_ENDPOINT_DROPPED` losing the ledger row too. The
111
- * attribution is computed at the right site so that turning it into a node
112
- * and a `CALLS` edge is the only step left. See
113
- * `DEC-NEXT-external-symbol-nodes-at-the-dependency-boundary`.
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
- ledger(from, "CALLS", ref.raw, unit, ref.line, REASONS.outside, {
130
- refusalClass: "out-of-scope",
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
- const id = nodeId(scope, "CLASS", symbolQsp(namespace, [type.name]), LANGUAGE);
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
- if (others === undefined)
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 { ...node, attrs: { ...node.attrs, buildConstrainedAlternates: others } };
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 };