carrick 0.3.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/LICENSE.md +99 -0
  2. package/README.md +79 -0
  3. package/bin/carrick.mjs +168 -0
  4. package/dist/channel.d.ts +16 -0
  5. package/dist/channel.js +37 -0
  6. package/dist/channel.js.map +1 -0
  7. package/dist/cli.d.ts +29 -0
  8. package/dist/cli.js +60 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/contract.d.ts +154 -0
  11. package/dist/contract.js +141 -0
  12. package/dist/contract.js.map +1 -0
  13. package/dist/diagnostics.d.ts +54 -0
  14. package/dist/diagnostics.js +199 -0
  15. package/dist/diagnostics.js.map +1 -0
  16. package/dist/hook/post-edit.d.ts +13 -0
  17. package/dist/hook/post-edit.js +76 -0
  18. package/dist/hook/post-edit.js.map +1 -0
  19. package/dist/hook/session-start.d.ts +2 -0
  20. package/dist/hook/session-start.js +44 -0
  21. package/dist/hook/session-start.js.map +1 -0
  22. package/dist/init/identity.d.ts +20 -0
  23. package/dist/init/identity.js +60 -0
  24. package/dist/init/identity.js.map +1 -0
  25. package/dist/init/repos.d.ts +19 -0
  26. package/dist/init/repos.js +46 -0
  27. package/dist/init/repos.js.map +1 -0
  28. package/dist/init/run.d.ts +11 -0
  29. package/dist/init/run.js +228 -0
  30. package/dist/init/run.js.map +1 -0
  31. package/dist/init/settings.d.ts +50 -0
  32. package/dist/init/settings.js +138 -0
  33. package/dist/init/settings.js.map +1 -0
  34. package/dist/log.d.ts +2 -0
  35. package/dist/log.js +35 -0
  36. package/dist/log.js.map +1 -0
  37. package/dist/native.d.ts +48 -0
  38. package/dist/native.js +127 -0
  39. package/dist/native.js.map +1 -0
  40. package/dist/render.d.ts +65 -0
  41. package/dist/render.js +334 -0
  42. package/dist/render.js.map +1 -0
  43. package/dist/root.d.ts +30 -0
  44. package/dist/root.js +82 -0
  45. package/dist/root.js.map +1 -0
  46. package/dist/server.d.ts +2 -0
  47. package/dist/server.js +255 -0
  48. package/dist/server.js.map +1 -0
  49. package/dist/templates.d.ts +18 -0
  50. package/dist/templates.js +66 -0
  51. package/dist/templates.js.map +1 -0
  52. package/package.json +72 -0
  53. package/plugin/.claude-plugin/plugin.json +6 -0
  54. package/plugin/.lsp.json +14 -0
  55. package/plugin/hooks/hooks.json +27 -0
  56. package/sidecar/dist/src/bundler.d.ts +141 -0
  57. package/sidecar/dist/src/bundler.js +680 -0
  58. package/sidecar/dist/src/capture/anchors.d.ts +61 -0
  59. package/sidecar/dist/src/capture/anchors.js +1132 -0
  60. package/sidecar/dist/src/capture/api.d.ts +378 -0
  61. package/sidecar/dist/src/capture/api.js +10 -0
  62. package/sidecar/dist/src/capture/augmentations.d.ts +20 -0
  63. package/sidecar/dist/src/capture/augmentations.js +60 -0
  64. package/sidecar/dist/src/capture/check-classify.d.ts +58 -0
  65. package/sidecar/dist/src/capture/check-classify.js +189 -0
  66. package/sidecar/dist/src/capture/check-deep.d.ts +32 -0
  67. package/sidecar/dist/src/capture/check-deep.js +91 -0
  68. package/sidecar/dist/src/capture/check-poison.d.ts +40 -0
  69. package/sidecar/dist/src/capture/check-poison.js +155 -0
  70. package/sidecar/dist/src/capture/check-probe.d.ts +70 -0
  71. package/sidecar/dist/src/capture/check-probe.js +147 -0
  72. package/sidecar/dist/src/capture/check-scrub.d.ts +31 -0
  73. package/sidecar/dist/src/capture/check-scrub.js +71 -0
  74. package/sidecar/dist/src/capture/check-workspace.d.ts +52 -0
  75. package/sidecar/dist/src/capture/check-workspace.js +218 -0
  76. package/sidecar/dist/src/capture/check.d.ts +39 -0
  77. package/sidecar/dist/src/capture/check.js +450 -0
  78. package/sidecar/dist/src/capture/deep-walk.d.ts +62 -0
  79. package/sidecar/dist/src/capture/deep-walk.js +243 -0
  80. package/sidecar/dist/src/capture/index.d.ts +36 -0
  81. package/sidecar/dist/src/capture/index.js +477 -0
  82. package/sidecar/dist/src/capture/lockfile.d.ts +48 -0
  83. package/sidecar/dist/src/capture/lockfile.js +490 -0
  84. package/sidecar/dist/src/capture/machinery.d.ts +59 -0
  85. package/sidecar/dist/src/capture/machinery.js +160 -0
  86. package/sidecar/dist/src/capture/node-builder.d.ts +37 -0
  87. package/sidecar/dist/src/capture/node-builder.js +123 -0
  88. package/sidecar/dist/src/capture/paths-rewrite.d.ts +34 -0
  89. package/sidecar/dist/src/capture/paths-rewrite.js +100 -0
  90. package/sidecar/dist/src/capture/self-check.d.ts +38 -0
  91. package/sidecar/dist/src/capture/self-check.js +317 -0
  92. package/sidecar/dist/src/capture/specifiers.d.ts +39 -0
  93. package/sidecar/dist/src/capture/specifiers.js +56 -0
  94. package/sidecar/dist/src/definition-resolver.d.ts +57 -0
  95. package/sidecar/dist/src/definition-resolver.js +153 -0
  96. package/sidecar/dist/src/index.d.ts +14 -0
  97. package/sidecar/dist/src/index.js +564 -0
  98. package/sidecar/dist/src/monorepo-builder.d.ts +129 -0
  99. package/sidecar/dist/src/monorepo-builder.js +584 -0
  100. package/sidecar/dist/src/project-loader.d.ts +130 -0
  101. package/sidecar/dist/src/project-loader.js +399 -0
  102. package/sidecar/dist/src/type-inferrer.d.ts +940 -0
  103. package/sidecar/dist/src/type-inferrer.js +3540 -0
  104. package/sidecar/dist/src/type-structural-expander.d.ts +61 -0
  105. package/sidecar/dist/src/type-structural-expander.js +283 -0
  106. package/sidecar/dist/src/type-text-canonicalizer.d.ts +40 -0
  107. package/sidecar/dist/src/type-text-canonicalizer.js +297 -0
  108. package/sidecar/dist/src/types.d.ts +669 -0
  109. package/sidecar/dist/src/types.js +5 -0
  110. package/sidecar/dist/src/validators.d.ts +2214 -0
  111. package/sidecar/dist/src/validators.js +336 -0
  112. package/sidecar/package.json +6 -0
  113. package/templates/carrick.json +7 -0
  114. package/templates/carrick.yml +45 -0
@@ -0,0 +1,3540 @@
1
+ /**
2
+ * Type Inferrer - Scope-based type inference with payload unwrapping
3
+ *
4
+ * This module extracts types even when developers don't write explicit
5
+ * annotations. It uses span-based node lookup (no line windows) to target
6
+ * precise expressions provided by the Rust/LLM pipeline.
7
+ *
8
+ * Key feature: Agent-informed payload unwrapping
9
+ * - Extracts payload types from machinery wrappers (Response<T>, AxiosResponse<T>, etc.)
10
+ * - Supports union/intersection composition
11
+ * - Recursive unwrapping with depth limits
12
+ *
13
+ * Framework agnosticism: The LLM emits the payload subexpression directly (e.g.,
14
+ * the `users` in `res.json(users)`, `ctx.body = users`, `h.response(users)`, or
15
+ * `return users`). The sidecar resolves that node and reads its type — no
16
+ * framework-specific method-name lists live here. For payload-less handlers
17
+ * (redirects, 204s), the LLM emits null and we fall back to the containing
18
+ * function's return type.
19
+ */
20
+ import { Node, SyntaxKind, ts, } from 'ts-morph';
21
+ import { validateInferRequestItem } from './validators.js';
22
+ import { expandTypeStructural } from './type-structural-expander.js';
23
+ /**
24
+ * TS/lib globals and primitives that must never be emitted as a deterministic
25
+ * type anchor (`primary_type_symbol`). A payload whose resolved symbol is one of
26
+ * these is library machinery, not a user contract — mirror of the Rust-side
27
+ * `is_builtin_type` filter in `socket_io.rs` so the HTTP-inference anchor and the
28
+ * socket anchor reject the same set.
29
+ */
30
+ /**
31
+ * Async-iterator / generator transport wrappers. A resolver written as an async
32
+ * generator (`async function* x(): AsyncGenerator<Order>`) carries its contract
33
+ * in the yield position; these symbol names gate `unwrapAsyncIterableType`, which
34
+ * peels them down to the yield type before structural expansion. They are also
35
+ * folded into `BUILTIN_ANCHOR_SYMBOLS` so the wrapper itself can never become a
36
+ * `primary_type_symbol` anchor.
37
+ */
38
+ const ASYNC_ITERABLE_SYMBOLS = new Set([
39
+ 'AsyncGenerator',
40
+ 'AsyncIterableIterator',
41
+ 'AsyncIterator',
42
+ 'Generator',
43
+ 'IterableIterator',
44
+ ]);
45
+ const BUILTIN_ANCHOR_SYMBOLS = new Set([
46
+ ...ASYNC_ITERABLE_SYMBOLS,
47
+ 'any',
48
+ 'unknown',
49
+ 'never',
50
+ 'void',
51
+ 'object',
52
+ 'string',
53
+ 'number',
54
+ 'boolean',
55
+ 'bigint',
56
+ 'symbol',
57
+ 'undefined',
58
+ 'null',
59
+ 'Array',
60
+ 'Promise',
61
+ 'Record',
62
+ 'Map',
63
+ 'Set',
64
+ 'Date',
65
+ 'Object',
66
+ 'String',
67
+ 'Number',
68
+ 'Boolean',
69
+ 'Symbol',
70
+ 'BigInt',
71
+ 'Function',
72
+ 'RegExp',
73
+ 'Error',
74
+ ]);
75
+ /**
76
+ * Strongly-discriminating member names of HTTP transport machinery — the
77
+ * fetch/DOM `Response` & `Request`, a Node `http.ServerResponse`, a framework
78
+ * reply object. A type declared in a lib or `node_modules` origin that carries
79
+ * a subset of these is framework machinery, never a user contract: the
80
+ * PRODUCER-side structural mirror of the consumer `machineryIndicators`
81
+ * (ExtractionConfig), used to reject a wrapper envelope whose response field is
82
+ * a raw `Response` (carrick#371) instead of emitting it as a comparable type.
83
+ *
84
+ * Framework-agnostic by construction: no framework NAME appears here, only the
85
+ * shared HTTP-message surface. The names are deliberately the ones no
86
+ * JSON payload ever carries (`ok`, `redirected`, `bodyUsed`, `arrayBuffer`,
87
+ * `writeHead`, ...), so the origin gate + `MACHINERY_INDICATOR_THRESHOLD` never
88
+ * fire on real data. Kept in lockstep with the capture-seam mirror
89
+ * `capture/machinery.ts` (the seam forbids sharing a module across it, same as
90
+ * `BUILTIN_ANCHOR_SYMBOLS` mirrors `socket_io.rs`). Exported so a drift-guard
91
+ * test (`machinery-indicator-mirror.test.ts`) asserts the two sets stay equal.
92
+ */
93
+ export const MACHINERY_MEMBER_INDICATORS = new Set([
94
+ // fetch / DOM Response & Request body-consumer surface
95
+ 'ok',
96
+ 'redirected',
97
+ 'bodyUsed',
98
+ 'arrayBuffer',
99
+ 'blob',
100
+ 'formData',
101
+ 'clone',
102
+ 'json',
103
+ 'statusText',
104
+ // Node http ServerResponse / reply-object surface
105
+ 'statusCode',
106
+ 'statusMessage',
107
+ 'setHeader',
108
+ 'getHeader',
109
+ 'removeHeader',
110
+ 'writeHead',
111
+ 'flushHeaders',
112
+ ]);
113
+ /** Machinery needs at least this many indicator members to be recognized. */
114
+ const MACHINERY_INDICATOR_THRESHOLD = 3;
115
+ /**
116
+ * How far the response-helper recovery (carrick#631) descends through nested
117
+ * calls looking for the payload argument. `cors(request, json(payload))` needs
118
+ * two; the bound is a backstop against a pathologically nested return.
119
+ */
120
+ const RESPONSE_HELPER_MAX_DEPTH = 4;
121
+ /**
122
+ * Members an options object uses to state an HTTP status. A >= 400 status marks
123
+ * the branch an error path, whose shape is not the endpoint's contract.
124
+ */
125
+ const STATUS_MEMBER_NAMES = ['status', 'statusCode'];
126
+ /**
127
+ * Print a `Type` to its string form WITHOUT the compiler's default truncation.
128
+ *
129
+ * `Type.getText()` truncates large/anonymous object types to ~160 chars and inserts
130
+ * `...`, which yields a syntactically-invalid or structurally-wrong surface that then
131
+ * produces false type-drift verdicts downstream. `NoTruncation` disables that; this is
132
+ * the same flag set `definition-resolver.ts` already uses for expanded definitions.
133
+ */
134
+ const TYPE_TEXT_FLAGS = ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.InTypeAlias;
135
+ function typeText(type, enclosingNode) {
136
+ return type.getText(enclosingNode, TYPE_TEXT_FLAGS);
137
+ }
138
+ /**
139
+ * TypeInferrer - Extracts types from source code, both explicit and inferred
140
+ *
141
+ * Usage:
142
+ * const inferrer = new TypeInferrer({ project });
143
+ * const result = inferrer.infer(requests, extractionConfig);
144
+ */
145
+ export class TypeInferrer {
146
+ project;
147
+ constructor(options) {
148
+ this.project = options.project;
149
+ }
150
+ /**
151
+ * Infer types for the given requests
152
+ *
153
+ * @param requests - Array of inference requests
154
+ * @param extractionConfig - Agent-generated extraction config for payload unwrapping
155
+ * @returns InferResult with inferred types or errors
156
+ */
157
+ infer(requests, extractionConfig) {
158
+ const inferredTypes = [];
159
+ const errors = [];
160
+ for (const request of requests) {
161
+ // Plain JavaScript has no type annotations to extract, and `checkJs` is
162
+ // off, so inferring against a `.js` file yields nothing useful — it only
163
+ // crashes deep in the compiler API on undefined symbols (`escapedName`,
164
+ // `flags`) and floods the log with the resulting error strings. Skip it.
165
+ // `allowJs` stays on so `.ts` files can still resolve `.js` imports.
166
+ if (/\.(js|jsx|mjs|cjs)$/i.test(request.file_path)) {
167
+ continue;
168
+ }
169
+ try {
170
+ const loc = this.formatRequestLocation(request);
171
+ const itemError = validateInferRequestItem(request);
172
+ if (itemError) {
173
+ errors.push(`Invalid infer item at ${request.file_path}:${loc} (${request.infer_kind}): ${itemError}`);
174
+ continue;
175
+ }
176
+ const result = this.inferSingle(request, extractionConfig);
177
+ if (result) {
178
+ inferredTypes.push(result);
179
+ }
180
+ else {
181
+ errors.push(`Could not infer type at ${request.file_path}:${loc} (${request.infer_kind})`);
182
+ }
183
+ }
184
+ catch (err) {
185
+ const error = err instanceof Error ? err.message : String(err);
186
+ const loc = this.formatRequestLocation(request);
187
+ errors.push(`Error inferring type at ${request.file_path}:${loc}: ${error}`);
188
+ }
189
+ }
190
+ return {
191
+ success: errors.length === 0 || inferredTypes.length > 0,
192
+ inferred_types: inferredTypes.length > 0 ? inferredTypes : undefined,
193
+ errors: errors.length > 0 ? errors : undefined,
194
+ };
195
+ }
196
+ /**
197
+ * Infer a single type from a request
198
+ */
199
+ inferSingle(request, extractionConfig) {
200
+ const sourceFile = this.getSourceFile(request.file_path);
201
+ if (!sourceFile) {
202
+ this.logError(`Source file not found: ${request.file_path}`);
203
+ return null;
204
+ }
205
+ switch (request.infer_kind) {
206
+ case 'function_return':
207
+ return this.inferFunctionReturn(sourceFile, request, extractionConfig);
208
+ case 'response_body':
209
+ return this.inferResponseBody(sourceFile, request, extractionConfig);
210
+ case 'call_result':
211
+ return this.inferCallResult(sourceFile, request, extractionConfig);
212
+ case 'variable':
213
+ return this.inferVariable(sourceFile, request, extractionConfig);
214
+ case 'expression':
215
+ return this.inferExpression(sourceFile, request, extractionConfig);
216
+ case 'request_body':
217
+ return this.inferRequestBody(sourceFile, request, extractionConfig);
218
+ case 'signature_return':
219
+ return this.inferSignatureReturn(sourceFile, request);
220
+ case 'function_param':
221
+ return this.inferFunctionParam(sourceFile, request);
222
+ case 'receiver_type':
223
+ return this.inferReceiverType(sourceFile, request);
224
+ default:
225
+ this.logError(`Unknown infer kind: ${request.infer_kind}`);
226
+ return null;
227
+ }
228
+ }
229
+ /**
230
+ * Get or add a source file to the project
231
+ */
232
+ getSourceFile(filePath) {
233
+ let sourceFile = this.project.getSourceFile(filePath);
234
+ if (!sourceFile) {
235
+ try {
236
+ sourceFile = this.project.addSourceFileAtPath(filePath);
237
+ }
238
+ catch (err) {
239
+ this.logError(`Failed to add source file: ${err instanceof Error ? err.message : String(err)}`);
240
+ return undefined;
241
+ }
242
+ }
243
+ return sourceFile;
244
+ }
245
+ // ===========================================================================
246
+ // Inference Methods by Kind
247
+ // ===========================================================================
248
+ inferFunctionReturn(sourceFile, request, extractionConfig) {
249
+ // A `function_return` request can point at a route REGISTRATION: that is
250
+ // the shape the scanner sends for a route whose handler RETURNS its
251
+ // payload. The containing-function walk below then resolves the function
252
+ // that registers the route, not the handler — so a registration declaring
253
+ // its response contract is read from that declaration first.
254
+ const registration = this.routeRegistrationForRequest(sourceFile, request);
255
+ if (registration) {
256
+ const declared = this.declaredResponseInferredType(request, registration);
257
+ if (declared) {
258
+ return declared;
259
+ }
260
+ }
261
+ const func = this.resolveContainingFunction(sourceFile, request);
262
+ if (!func) {
263
+ this.log(`No function found for request at ${request.file_path}:${request.line_number}`);
264
+ return null;
265
+ }
266
+ return this.buildFunctionReturnInferredType(request, func, extractionConfig);
267
+ }
268
+ /**
269
+ * Build a `function_return` inferred type from an already-resolved function
270
+ * node: unwrap `Promise<…>` / async-iterator transport, apply the extraction
271
+ * config to the awaited type, and expand a bare named return structurally.
272
+ * Extracted from `inferFunctionReturn` so the route-registration path
273
+ * (`inferResponseBody` following a handler) can reuse the exact same logic
274
+ * against the handler function instead of the containing function.
275
+ */
276
+ buildFunctionReturnInferredType(request, func, extractionConfig, treatVoidAsUnresolved = false) {
277
+ const returnTypeNode = func.getReturnTypeNode();
278
+ const isExplicit = returnTypeNode !== undefined;
279
+ let returnType = func.getReturnType();
280
+ let typeString = typeText(returnType, func);
281
+ /** Reasons for any `any`/`unknown` this inference ends up publishing. */
282
+ let provenance;
283
+ // Apply the agent-generated extraction config to the AWAITED type: an
284
+ // async handler's return is Promise<Wrapper<T>>, whose symbol is
285
+ // `Promise` — a rule naming the wrapper could never match it, and the
286
+ // textual unwrapPromise below runs too late for the rules to see T.
287
+ //
288
+ // Then peel any async-iterator transport: an async generator resolver
289
+ // resolves to `AsyncGenerator<Order>` (or `Promise<AsyncGenerator<Order>>`),
290
+ // whose contract lives in the yield position. Unwrap it to the yield type so
291
+ // both forms reduce to `Order` before structural expansion / rule matching.
292
+ const awaitedType = this.unwrapAsyncIterableType(this.unwrapPromiseType(returnType));
293
+ const unwrapResult = this.unwrapTypeWithConfig(awaitedType, func, extractionConfig);
294
+ if (unwrapResult.wasUnwrapped && !unwrapResult.verifiedMachinery) {
295
+ typeString = unwrapResult.typeString;
296
+ // carrick#166: the union read some branches and not others, so the
297
+ // `unknown` it answered with is a decision, not an absence. Say which
298
+ // branches were unread — a reader can act on "this route also returns a
299
+ // wrapper nothing could see inside" in a way they cannot act on a shrug.
300
+ if (unwrapResult.unreadUnionBranches) {
301
+ const unread = unwrapResult.unreadUnionBranches;
302
+ // The COUNT is of branches; the names are deduped, because two
303
+ // branches of the same wrapper carry the same name and listing it
304
+ // twice says nothing.
305
+ const names = [...new Set(unread)].sort().join(', ');
306
+ provenance = [
307
+ {
308
+ path: '',
309
+ kind: 'unknown',
310
+ reason: 'machinery_envelope',
311
+ detail: `${unread.length} of this handler's return branches resolved to transport ` +
312
+ `no rule could read a payload out of (${names}), so publishing the branches ` +
313
+ 'that did read would state part of the contract as the whole of it',
314
+ },
315
+ ];
316
+ }
317
+ }
318
+ else {
319
+ // Either no wrapper rule fired, or one verified the wrapper's identity
320
+ // and recovered no payload from it. Either way the resolved return
321
+ // carries no contract of its own — it is transport machinery, or
322
+ // `any`/`unknown` because the callee that produced it has no installed
323
+ // declaration on a bare checkout. The contract may still be one level
324
+ // in, in the ARGUMENT the handler handed the callee (carrick#631). Read
325
+ // the handler's own `return` statements before abstaining.
326
+ //
327
+ // How much of the argument is trusted depends on WHY the return carries
328
+ // nothing. Machinery means the callee is a known transport helper, so
329
+ // its argument is the payload and any resolved type is read — whether
330
+ // the structural check recognised it or a wrapper rule verified the
331
+ // wrapper's symbol and origin and found no payload inside it. `any` /
332
+ // `unknown` means the callee is simply unresolvable, and on a bare
333
+ // checkout that describes most imported callees — `db.findMany({ where
334
+ // })`, `client.send(params)` — whose argument is a query, not a
335
+ // response. There the recovery is restricted to an argument the SOURCE
336
+ // annotates (`satisfies` / `as` / `<T>`): the developer stated the
337
+ // contract, so it is a claim rather than a guess.
338
+ const machinery = unwrapResult.verifiedMachinery === true ||
339
+ this.typeIsOrContainsResponseMachinery(awaitedType);
340
+ const unresolvable = awaitedType.isAny() || awaitedType.isUnknown();
341
+ if (machinery || unresolvable) {
342
+ const recovered = this.recoverPayloadFromReturnStatements(func, !machinery);
343
+ if (recovered) {
344
+ this.log(`Return type at ${request.file_path}:${request.line_number} carries no ` +
345
+ "contract; recovered the payload from the response helper's argument");
346
+ const recoveredAnchor = recovered.anchorType
347
+ ? this.unwrapArrayLevels(recovered.anchorType)
348
+ : undefined;
349
+ const resolvedSymbol = recoveredAnchor
350
+ ? this.primaryTypeSymbol(recoveredAnchor.element)
351
+ : undefined;
352
+ // carrick#768: the resolved type of a stated annotation carries no
353
+ // symbol when the alias resolves to an INSTANTIATED type (the
354
+ // schema-first `type Body = Infer<typeof Schema>` shape) — the
355
+ // compiler answers the synthetic `__type` and the route loses the
356
+ // one name a reader could import. The annotation as WRITTEN still
357
+ // names it, so read the anchor off the type node when the resolved
358
+ // type had none. Fallback only: a resolved symbol is the better
359
+ // answer and keeps its precedence.
360
+ const stated = recovered.statedTypeNode;
361
+ const writtenAnchor = resolvedSymbol === undefined && stated
362
+ ? this.writtenAnchorOf(stated)
363
+ : undefined;
364
+ return this.createInferredType(request, recovered.typeString, recovered.isExplicit, this.getNodeLocation(recovered.node), undefined, resolvedSymbol ?? writtenAnchor?.symbol, writtenAnchor ? writtenAnchor.depth : recoveredAnchor?.depth, writtenAnchor?.source);
365
+ }
366
+ // Nothing recoverable. Reject a wrapper envelope that IS or CONTAINS
367
+ // framework machinery (carrick#371): `withApiWrapper({ handler: () =>
368
+ // ({ response: Response, error })})` resolves the handler's return to
369
+ // `{ response: Response; ... }`, which is transport machinery, not a
370
+ // payload. Emitting it manufactures a false compat mismatch against
371
+ // the consumer's real payload — so abstain (honest `unknown`).
372
+ if (machinery) {
373
+ this.log(`Return type at ${request.file_path}:${request.line_number} is or contains ` +
374
+ 'framework machinery (Response/Request-shaped); abstaining rather than ' +
375
+ 'capturing the wrapper envelope as a response contract');
376
+ return null;
377
+ }
378
+ this.log(`Return type at ${request.file_path}:${request.line_number} resolved to ` +
379
+ `${awaitedType.isAny() ? 'any' : 'unknown'} and no returned call stated ` +
380
+ 'its payload type in source; leaving it unresolved');
381
+ // The honest `any` still reaches the manifest, so it carries the
382
+ // reason with it (carrick#376): the callee has no resolvable
383
+ // declaration and nothing in the handler says what it is, which is a
384
+ // different answer from "the type really is `any`" and points at a
385
+ // different fix (install the dependency, or annotate the payload).
386
+ provenance = [
387
+ {
388
+ path: '',
389
+ kind: awaitedType.isAny() ? 'any' : 'unknown',
390
+ reason: 'no_payload_evidence',
391
+ detail: "the handler returns a call whose callee has no resolvable declaration, and nothing states what it is: no returned sibling call hands it a body plus a status, and no returned argument carries a 'satisfies'/'as' annotation",
392
+ },
393
+ ];
394
+ }
395
+ // No wrapper rule fired: the (awaited) return resolved to its own type.
396
+ // A named object return (`async (): Promise<Payment> => …`) renders as the
397
+ // bare name `Payment`, which dangles in the source-less cross-repo bundle.
398
+ // Expand the resolved object structurally so the real members reach the
399
+ // bundle. `unwrapPromise` below is then a no-op on the structural form.
400
+ typeString = this.expandResolvedTypeStructural(awaitedType, typeString);
401
+ }
402
+ typeString = this.unwrapPromise(typeString, returnType);
403
+ // Only the route-registration handler-following RESPONSE path treats a
404
+ // void/empty return as payload-less (null) — a redirect / 204 / streaming
405
+ // handler must stay unresolved, not become a spurious `void` type. The
406
+ // regular `function_return` infer keeps `void` (a function that genuinely
407
+ // returns void should report it, e.g. the signature-hint pass).
408
+ const trimmed = typeString.trim();
409
+ if (treatVoidAsUnresolved &&
410
+ (trimmed === 'void' ||
411
+ trimmed === 'undefined' ||
412
+ trimmed === 'never' ||
413
+ trimmed === '')) {
414
+ return null;
415
+ }
416
+ const anchor = this.unwrapArrayLevels(awaitedType);
417
+ const inferred = this.createInferredType(request, typeString, isExplicit, this.getNodeLocation(func), unwrapResult.wasUnwrapped ? unwrapResult.typeString : undefined, this.primaryTypeSymbol(anchor.element), anchor.depth);
418
+ if (provenance && provenance.length > 0) {
419
+ inferred.any_provenance = provenance;
420
+ }
421
+ return inferred;
422
+ }
423
+ /**
424
+ * Infer a function's return type for the signature hint. Unlike
425
+ * `inferFunctionReturn`, this does NOT unwrap Promise or apply wrapper
426
+ * rules — a function that returns `Promise<AuthResult>` should show exactly
427
+ * that in its signature. Used by the function-signature collection pass.
428
+ */
429
+ inferSignatureReturn(sourceFile, request) {
430
+ const func = this.resolveContainingFunction(sourceFile, request);
431
+ if (!func) {
432
+ this.log(`No function found for request at ${request.file_path}:${request.line_number}`);
433
+ return null;
434
+ }
435
+ const isExplicit = func.getReturnTypeNode() !== undefined;
436
+ const typeString = typeText(func.getReturnType(), func);
437
+ return this.createInferredType(request, typeString, isExplicit, this.getNodeLocation(func));
438
+ }
439
+ /**
440
+ * Infer the type of a single named parameter. `is_explicit` reflects whether
441
+ * the parameter carries a source annotation; the type string is the
442
+ * compiler's view either way (so contextually-typed callback params resolve
443
+ * even without an annotation). Uses ts-morph's default `getText()` form,
444
+ * which keeps named types as names and bounds depth via the compiler's own
445
+ * truncation.
446
+ */
447
+ inferFunctionParam(sourceFile, request) {
448
+ const func = this.resolveContainingFunction(sourceFile, request);
449
+ if (!func) {
450
+ this.log(`No function found for request at ${request.file_path}:${request.line_number}`);
451
+ return null;
452
+ }
453
+ if (!request.param_name) {
454
+ this.logError(`function_param request missing param_name at ${request.file_path}:${request.line_number}`);
455
+ return null;
456
+ }
457
+ const target = this.resolveParamTarget(func, request.param_name);
458
+ if (!target) {
459
+ this.log(`Parameter "${request.param_name}" not found at ${request.file_path}:${request.line_number}`);
460
+ return null;
461
+ }
462
+ const isExplicit = target.param.getTypeNode() !== undefined;
463
+ const paramType = target.node.getType();
464
+ const typeString = typeText(paramType, target.node);
465
+ // Deterministic anchor for the pub/sub two-anchor arbitration
466
+ // (carrick#413): report the payload type's root symbol, its declaration
467
+ // file, and the peeled array depth. The type STRING deliberately keeps
468
+ // ts-morph's default named form (see the doc comment above) — the
469
+ // scanner re-aims the explicit bundle at the root symbol instead of
470
+ // pasting this string, so the alias's definition stays self-contained.
471
+ const anchor = this.unwrapArrayLevels(this.unwrapPromiseType(paramType));
472
+ return this.createInferredType(request, typeString, isExplicit, this.getNodeLocation(target.node), undefined, this.primaryTypeSymbol(anchor.element), anchor.depth, this.primaryTypeSymbolSource(anchor.element));
473
+ }
474
+ /**
475
+ * Resolve a `function_param` locator against a function's parameter list.
476
+ * Three shapes, tried in order:
477
+ *
478
+ * 1. A parameter whose name matches exactly (`(payload) => …` ← "payload").
479
+ * 2. A parameter whose DESTRUCTURED BINDING PATTERN matches the locator
480
+ * text under whitespace normalization (`({ time, run }) => …` ←
481
+ * "{ time, run }") — the handler destructures the payload itself, so the
482
+ * pattern's own type IS the payload type.
483
+ * 3. A named BINDING ELEMENT inside a destructured parameter
484
+ * (`({ payload }) => …` ← "payload") — the payload is one property of an
485
+ * envelope param, and the checker projects the element's type
486
+ * (catalog-worker handlers: `params: { id, payload: Infer<…> }`).
487
+ *
488
+ * All three read the type off a node the checker has already instantiated,
489
+ * so generic wrappers (topic-map emitters, schema catalogs, channel handles)
490
+ * resolve without any named payload symbol existing anywhere.
491
+ */
492
+ resolveParamTarget(func, paramName) {
493
+ const params = func.getParameters();
494
+ // 1. Exact parameter name.
495
+ const byName = params.find((p) => p.getName() === paramName);
496
+ if (byName) {
497
+ return { param: byName, node: byName };
498
+ }
499
+ // 2. Whole binding pattern, whitespace-normalized.
500
+ const normalizedTarget = this.normalizeWhitespace(paramName);
501
+ for (const param of params) {
502
+ const nameNode = param.getNameNode();
503
+ if ((Node.isObjectBindingPattern(nameNode) || Node.isArrayBindingPattern(nameNode)) &&
504
+ this.normalizeWhitespace(nameNode.getText()) === normalizedTarget) {
505
+ return { param, node: param };
506
+ }
507
+ }
508
+ // 3. Binding element inside a destructured parameter.
509
+ for (const param of params) {
510
+ const nameNode = param.getNameNode();
511
+ if (!Node.isObjectBindingPattern(nameNode))
512
+ continue;
513
+ for (const element of nameNode.getElements()) {
514
+ if (element.getName() === paramName) {
515
+ return { param, node: element };
516
+ }
517
+ }
518
+ }
519
+ return undefined;
520
+ }
521
+ inferResponseBody(sourceFile, request, extractionConfig) {
522
+ const node = this.resolveTargetNode(sourceFile, request);
523
+ if (!node) {
524
+ // Line-only anchor (no span, no expression text) — the shape the scanner
525
+ // sends for a named-handler route registration whose handler is declared
526
+ // away from the registration line. Follow the handler at that line before
527
+ // the generic function-return fallback, which can't reach a handler more
528
+ // than a couple of lines from the registration.
529
+ const atLine = this.registrationAtLine(sourceFile, request.line_number);
530
+ if (atLine) {
531
+ const declared = this.declaredResponseInferredType(request, atLine.registration);
532
+ if (declared) {
533
+ return declared;
534
+ }
535
+ this.log(`Line ${request.line_number} is a route registration; following handler return`);
536
+ return this.buildFunctionReturnInferredType(request, atLine.handler, extractionConfig, true);
537
+ }
538
+ // No locator, or locator didn't resolve — likely a payload-less handler
539
+ // (redirect, 204, streaming). Infer the containing function's return type.
540
+ this.log(`No payload node found for request at ${request.file_path}:${request.line_number}; falling back to function return`);
541
+ return this.inferFunctionReturn(sourceFile, request, extractionConfig);
542
+ }
543
+ // Route-registry object literal: the locator lands on the registry entry
544
+ // `{ method, path, handler: healthCheckHandler }`, whose response contract
545
+ // is the handler's RETURN type — one indirection away, NOT the object's own
546
+ // `{ method; path; handler }` shape. Follow the handler before treating the
547
+ // object as a payload. (A call-shaped registration is handled below.)
548
+ if (!Node.isCallExpression(node)) {
549
+ const registryHandler = this.resolveRegisteredHandler(node);
550
+ if (registryHandler) {
551
+ const declared = this.declaredResponseInferredType(request, node);
552
+ if (declared) {
553
+ return declared;
554
+ }
555
+ this.log(`Span resolves to a route-registry object literal at ${request.file_path}:${request.line_number}; following handler return`);
556
+ return this.buildFunctionReturnInferredType(request, registryHandler, extractionConfig, true);
557
+ }
558
+ }
559
+ // The resolved node IS the payload subexpression in the MVP schema.
560
+ // Transitional fallback: if a caller still supplies a bare call expression
561
+ // (e.g., `res.json(users)`), drill to its first argument. No method-name list.
562
+ let payloadNode = node;
563
+ if (Node.isCallExpression(node)) {
564
+ const args = node.getArguments();
565
+ // A call that receives a function is a callback registration (e.g. an
566
+ // endpoint registration like `app.get('/path', handler)`) — its first
567
+ // argument is the route path, not a payload. The span locator falls
568
+ // back to exactly this shape when no payload expression was reported,
569
+ // so drilling here would put the path literal's type in the manifest.
570
+ const registersCallback = args.some((arg) => Node.isArrowFunction(arg) || Node.isFunctionExpression(arg));
571
+ if (registersCallback) {
572
+ // A registration that DECLARES its response contract in a schema needs
573
+ // no indirection: the declaration is the contract the framework
574
+ // serializes against, so it wins over whatever the handler happens to
575
+ // return.
576
+ const declared = this.declaredResponseInferredType(request, node);
577
+ if (declared) {
578
+ return declared;
579
+ }
580
+ // The locator lands on a route registration whose handler carries the
581
+ // response contract in its RETURN type — one indirection away. Follow
582
+ // the handler and infer its return instead of dropping the payload.
583
+ const handler = this.resolveRegisteredHandler(node);
584
+ if (handler) {
585
+ this.log(`Span resolves to a callback-registration call at ${request.file_path}:${request.line_number}; following handler return`);
586
+ return this.buildFunctionReturnInferredType(request, handler, extractionConfig, true);
587
+ }
588
+ this.log(`Span resolves to a callback-registration call at ${request.file_path}:${request.line_number}; no payload to infer`);
589
+ return null;
590
+ }
591
+ if (args.length > 0) {
592
+ payloadNode = args[0];
593
+ }
594
+ }
595
+ const payloadType = payloadNode.getType();
596
+ let typeString = typeText(payloadType, payloadNode);
597
+ const unwrapResult = this.unwrapTypeWithConfig(payloadType, payloadNode, extractionConfig);
598
+ if (unwrapResult.wasUnwrapped) {
599
+ typeString = unwrapResult.typeString;
600
+ }
601
+ else {
602
+ const resolved = this.unwrapPromiseType(payloadType);
603
+ // Same carrick#371 fail-closed guard as the function-return path: a
604
+ // payload that IS or CONTAINS framework machinery (a raw `Response`, a
605
+ // `{ response: Response; ... }` envelope) is not a contract. No rule
606
+ // recovered a payload, so abstain rather than emit the machinery.
607
+ if (this.typeIsOrContainsResponseMachinery(resolved)) {
608
+ this.log(`Response payload at ${request.file_path}:${request.line_number} is or contains ` +
609
+ 'framework machinery (Response/Request-shaped); abstaining rather than ' +
610
+ 'capturing it as a response contract');
611
+ return null;
612
+ }
613
+ // No wrapper rule fired: the payload resolved straight to its own type.
614
+ // If that's a named object (`res.json(payment)` with `payment: Payment`),
615
+ // `typeText` keeps the bare name `Payment`, which dangles in the
616
+ // source-less cross-repo bundle → `any` → unverifiable. Expand the
617
+ // resolved object structurally so the real members land in the bundle.
618
+ typeString = this.expandResolvedTypeStructural(resolved, typeString);
619
+ }
620
+ const anchor = this.unwrapArrayLevels(this.unwrapPromiseType(payloadType));
621
+ return this.createInferredType(request, typeString, false, this.getNodeLocation(payloadNode), unwrapResult.wasUnwrapped ? unwrapResult.typeString : undefined, this.primaryTypeSymbol(anchor.element), anchor.depth);
622
+ }
623
+ inferCallResult(sourceFile, request, extractionConfig) {
624
+ const callExpr = this.resolveTargetCallExpression(sourceFile, request);
625
+ if (!callExpr) {
626
+ return this.inferExpression(sourceFile, request, extractionConfig);
627
+ }
628
+ // Walk up from the already-found call expression instead of re-searching
629
+ const func = this.findContainingFunctionForNode(callExpr);
630
+ const terminalNode = this.resolveCallResultTerminalNode(callExpr, func);
631
+ const returnType = terminalNode.getType();
632
+ let typeString = typeText(returnType, terminalNode);
633
+ let isExplicit = false;
634
+ const unwrapResult = this.unwrapTypeWithConfig(returnType, terminalNode, extractionConfig);
635
+ if (unwrapResult.wasUnwrapped) {
636
+ typeString = unwrapResult.typeString;
637
+ isExplicit = unwrapResult.isExplicit;
638
+ }
639
+ const explicitType = this.extractExplicitTypeFromAncestor(terminalNode);
640
+ if (explicitType) {
641
+ typeString = explicitType;
642
+ isExplicit = true;
643
+ }
644
+ typeString = this.unwrapPromise(typeString, returnType);
645
+ // #336: consumer analogue of the #306 producer anchor. Anchor on the
646
+ // CALL's own payload (the rule-extracted Type, else the awaited call
647
+ // type), peeling array levels to the element symbol + depth. Without the
648
+ // depth, `apply_inferred_array_depth` (Rust) has nothing to copy onto the
649
+ // explicit SymbolRequest that pre-claims this alias, and the bundle for
650
+ // `axios.get<Order[]>` renders the bare `Order` interface with the `[]`
651
+ // gone. The anchor must come from the call expression, NOT the terminal
652
+ // node: the def-use walk follows the binding to its last use, and in the
653
+ // live repro that is `ordersResponse.data.length` — a `number` with no
654
+ // symbol and no depth — while the response contract the manifest alias
655
+ // describes is the call's payload (`Order[]`). A wrapper that unwrapped
656
+ // to no single payload (union join, verified machinery) carries no
657
+ // payloadType and anchors nothing; the wrapper's own symbol must never
658
+ // anchor via that path.
659
+ const callPayloadType = this.unwrapPromiseType(callExpr.getType());
660
+ const callUnwrap = this.unwrapTypeWithConfig(callPayloadType, callExpr, extractionConfig);
661
+ const anchorSource = callUnwrap.wasUnwrapped
662
+ ? callUnwrap.payloadType
663
+ : callPayloadType;
664
+ let anchor = anchorSource
665
+ ? this.unwrapArrayLevels(this.unwrapPromiseType(anchorSource))
666
+ : undefined;
667
+ // #336 CI shape: the scanned checkout often has NO node_modules (the
668
+ // GitHub Action scans a bare checkout), so the client library resolves to
669
+ // `any`, the call's semantic type carries no symbol, and the anchor above
670
+ // is empty — erasing the depth even though the caller wrote it out. The
671
+ // payload claim is still in the AST: a SINGLE explicit call generic
672
+ // (`axios.get<Order[]>`), whose type resolves against the repo's own
673
+ // sources regardless of the untyped client. This is the same generic the
674
+ // LLM's `primary_type_symbol` schema contract extracts, and the Rust
675
+ // depth-copy's symbol-agreement guard makes a mismatched fallback inert.
676
+ // Multi-generic calls are ambiguous and anchor nothing here.
677
+ if (!anchor || !this.primaryTypeSymbol(anchor.element)) {
678
+ const typeArgs = callExpr.getTypeArguments();
679
+ if (typeArgs.length === 1) {
680
+ const argType = this.unwrapPromiseType(typeArgs[0].getType());
681
+ const argUnwrap = this.unwrapTypeWithConfig(argType, typeArgs[0], extractionConfig);
682
+ const argSource = argUnwrap.wasUnwrapped
683
+ ? argUnwrap.payloadType
684
+ : argType;
685
+ if (argSource) {
686
+ const argAnchor = this.unwrapArrayLevels(this.unwrapPromiseType(argSource));
687
+ if (this.primaryTypeSymbol(argAnchor.element)) {
688
+ anchor = argAnchor;
689
+ }
690
+ }
691
+ }
692
+ }
693
+ return this.createInferredType(request, typeString, isExplicit, this.getNodeLocation(terminalNode), unwrapResult.wasUnwrapped ? unwrapResult.typeString : undefined, anchor ? this.primaryTypeSymbol(anchor.element) : undefined, anchor?.depth);
694
+ }
695
+ inferVariable(sourceFile, request, extractionConfig) {
696
+ const node = this.resolveTargetNode(sourceFile, request);
697
+ if (!node) {
698
+ return null;
699
+ }
700
+ const varDecl = Node.isVariableDeclaration(node)
701
+ ? node
702
+ : node.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
703
+ if (!varDecl) {
704
+ return this.inferExpression(sourceFile, request, extractionConfig);
705
+ }
706
+ const typeNode = varDecl.getTypeNode();
707
+ const isExplicit = typeNode !== undefined;
708
+ let varType = varDecl.getType();
709
+ let typeString = typeText(varType, varDecl);
710
+ // Apply extraction config
711
+ const unwrapResult = this.unwrapTypeWithConfig(varType, varDecl, extractionConfig);
712
+ if (unwrapResult.wasUnwrapped) {
713
+ typeString = unwrapResult.typeString;
714
+ }
715
+ typeString = this.unwrapPromise(typeString, varType);
716
+ return this.createInferredType(request, typeString, isExplicit, this.getNodeLocation(varDecl), unwrapResult.wasUnwrapped ? unwrapResult.typeString : undefined);
717
+ }
718
+ inferExpression(sourceFile, request, extractionConfig) {
719
+ const node = this.resolveTargetNode(sourceFile, request);
720
+ if (!node) {
721
+ return null;
722
+ }
723
+ const type = node.getType();
724
+ let typeString = typeText(type, node);
725
+ // Apply extraction config
726
+ const unwrapResult = this.unwrapTypeWithConfig(type, node, extractionConfig);
727
+ if (unwrapResult.wasUnwrapped) {
728
+ typeString = unwrapResult.typeString;
729
+ }
730
+ typeString = this.unwrapPromise(typeString, type);
731
+ // Deterministic anchor for the pub/sub two-anchor arbitration
732
+ // (carrick#413). When an unwrap rule fired (envelope publishers), the
733
+ // anchor follows the extracted PAYLOAD type, so a correct explicit
734
+ // anchor naming the inner contract type reads as agreement, never as a
735
+ // disagreement to arbitrate.
736
+ const anchorSource = unwrapResult.wasUnwrapped && unwrapResult.payloadType
737
+ ? unwrapResult.payloadType
738
+ : type;
739
+ const anchor = this.unwrapArrayLevels(this.unwrapPromiseType(anchorSource));
740
+ return this.createInferredType(request, typeString, false, this.getNodeLocation(node), unwrapResult.wasUnwrapped ? unwrapResult.typeString : undefined, this.primaryTypeSymbol(anchor.element), anchor.depth, this.primaryTypeSymbolSource(anchor.element));
741
+ }
742
+ inferRequestBody(sourceFile, request, extractionConfig) {
743
+ let located = this.resolveTargetNode(sourceFile, request);
744
+ if (!located) {
745
+ // Line-only anchor: the scanner points a request infer at the route
746
+ // registration line with no span/text. Follow the handler at that line and
747
+ // read the route's declared request contract — the handler's parameter
748
+ // annotation, the registration's schema, or the first typed request
749
+ // expression in the body (`c.req.json<T>()`, `req.body as T`).
750
+ const atLine = this.registrationAtLine(sourceFile, request.line_number);
751
+ if (atLine) {
752
+ const requestType = this.requestContractFromRegistration(atLine.registration, atLine.handler);
753
+ if (requestType) {
754
+ return this.createInferredType(request, requestType, true, this.getNodeLocation(atLine.handler), undefined, undefined);
755
+ }
756
+ }
757
+ return null;
758
+ }
759
+ // Inline-handler registration (`app.post('/x', async (c) => { … })`) or a
760
+ // route-registry object literal: the locator lands on the registration, not
761
+ // on a request expression, so the request contract lives ONE indirection
762
+ // away — the first typed request-body read inside the handler body
763
+ // (`c.req.json<T>()`, `req.body as T`, `const b: T = …`). Follow the handler
764
+ // and scan its body before falling through to the direct-expression path
765
+ // (which would otherwise read the registration call's useless return type).
766
+ // Gated on the located node structurally BEING a registration, so the
767
+ // consumer `JSON.stringify(payload)` / `req.body as T` paths are untouched.
768
+ const registrationHandler = this.registrationHandlerAt(located);
769
+ if (registrationHandler) {
770
+ const requestType = this.requestContractFromRegistration(this.unwrapExpressionNode(located), registrationHandler);
771
+ if (requestType) {
772
+ return this.createInferredType(request, requestType, true, this.getNodeLocation(registrationHandler), undefined, undefined);
773
+ }
774
+ // A genuinely payload-less handler (no typed request read): do NOT fall
775
+ // through to read the registration call's return type, which would emit a
776
+ // spurious framework type. Report unresolved, as before this capability.
777
+ this.log(`Route registration at ${request.file_path}:${request.line_number} has no typed request read; leaving unresolved`);
778
+ return null;
779
+ }
780
+ // The locator resolved to an expression that is NOT the registration, but
781
+ // the request's line still is one: this is the live producer shape, where
782
+ // the analyzer reported a payload expression and the scanner sends it as a
783
+ // text locator in preference to the registration span
784
+ // (`file_orchestrator.rs`, request-body block). For a forwarding
785
+ // schema-first route the only request-shaped text in the file is the
786
+ // handler's request OBJECT (`request`), whose own type is the framework's
787
+ // request machinery, or the forwarded controller call, whose type is the
788
+ // RESPONSE. Either resolves to something, so the registration branch above
789
+ // never runs and the route's declared contract is bypassed — the request
790
+ // ships as machinery and decays to `any` in the cross-repo surface.
791
+ //
792
+ // A DECLARED contract outranks whatever expression the locator picked,
793
+ // exactly as it does on the response side, so read anchors (a) and (b)
794
+ // first and fall through to the expression only when the route declares
795
+ // its request nowhere. The third registration anchor — the typed request
796
+ // READ inside the handler body — is deliberately not consulted here: that
797
+ // one is itself an expression, so the locator's own expression stays
798
+ // authoritative when nothing is declared.
799
+ const declaredAt = this.registrationAtLine(sourceFile, request.line_number);
800
+ if (declaredAt) {
801
+ const declared = this.requestBodyFromHandlerParams(declaredAt.handler) ??
802
+ this.routeSchemaContractText(declaredAt.registration, 'body');
803
+ if (declared) {
804
+ this.log(`Route registration at ${request.file_path}:${request.line_number} declares its ` +
805
+ 'request contract; using the declaration over the located expression');
806
+ return this.createInferredType(request, declared, true, this.getNodeLocation(declaredAt.handler), undefined, undefined);
807
+ }
808
+ }
809
+ // A text locator (Gemini `expression_text` + `expression_line`, as opposed
810
+ // to a byte span) can land on the property itself in `{ body:
811
+ // JSON.stringify(body) }` — either the whole PropertyAssignment (locator
812
+ // text is the full `key: value` source) or the property NAME identifier
813
+ // (locator text is a bare word that exact-matches the name over the
814
+ // value). Both type as the assigned value's own type — here the useless
815
+ // `string` result of `JSON.stringify` — not the payload. A property name
816
+ // is a label, not the contract expression: redirect to the value so the
817
+ // unwraps below read the real request body. Shorthand (`{ body }`) is
818
+ // left alone — its identifier already IS the value.
819
+ if (Node.isPropertyAssignment(located)) {
820
+ located = located.getInitializer() ?? located;
821
+ }
822
+ else if (Node.isIdentifier(located) &&
823
+ Node.isPropertyAssignment(located.getParent()) &&
824
+ located.getParent().getNameNode() === located) {
825
+ const parent = located.getParent();
826
+ located = parent.getInitializer() ?? located;
827
+ }
828
+ // Mirror inferCallResult: strip `await`/`as`/parens/`!` so the inner
829
+ // expression's type (not the surrounding `Promise<any>`) is read.
830
+ const unwrapped = this.unwrapExpressionNode(located);
831
+ // A `fetch` body is almost always `JSON.stringify(payload)`, whose own type
832
+ // is the useless `string`. Drill to the serialized argument so the consumer
833
+ // request shape is the payload's type, not `string`. General: any
834
+ // `JSON.stringify(x)` resolves to the type of `x`.
835
+ let node = this.unwrapJsonStringifyArg(unwrapped);
836
+ // A locator can also land on a serialized IDENTIFIER one value-hop from
837
+ // the payload — `const body = JSON.stringify(payload); sendBeacon(url,
838
+ // body)` — whose own declared type is `string`, the same useless result
839
+ // the direct-call unwrap above already handles. Follow the identifier to
840
+ // its declaration and, only when that declaration's initializer is itself
841
+ // a `JSON.stringify(...)` call, resolve through to the serialized
842
+ // argument. One hop only: an identifier declared from anything else
843
+ // already carries its own correct type and must not be rewritten.
844
+ if (Node.isIdentifier(node)) {
845
+ for (const def of node.getDefinitionNodes()) {
846
+ const varDecl = Node.isVariableDeclaration(def)
847
+ ? def
848
+ : def.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
849
+ const initializer = varDecl?.getInitializer();
850
+ if (!initializer)
851
+ continue;
852
+ const unwrappedInit = this.unwrapExpressionNode(initializer);
853
+ const stringifyArg = this.unwrapJsonStringifyArg(unwrappedInit);
854
+ if (stringifyArg !== unwrappedInit) {
855
+ node = stringifyArg;
856
+ break;
857
+ }
858
+ }
859
+ }
860
+ const payloadType = node.getType();
861
+ let typeString = typeText(payloadType, node);
862
+ let isExplicit = false;
863
+ // Apply extraction config
864
+ const unwrapResult = this.unwrapTypeWithConfig(payloadType, node, extractionConfig);
865
+ if (unwrapResult.wasUnwrapped) {
866
+ typeString = unwrapResult.typeString;
867
+ isExplicit = unwrapResult.isExplicit;
868
+ }
869
+ else {
870
+ // No wrapper rule fired: the payload resolved straight to its own type.
871
+ // For a consumer `fetch(url, { body: JSON.stringify(payload) })` with
872
+ // `payload: CreatePaymentRequest`, `node` is the unwrapped `payload`
873
+ // identifier and `payloadType` is the named object — `typeText` keeps the
874
+ // bare name `CreatePaymentRequest`, which dangles in the source-less
875
+ // cross-repo `.d.ts` bundle → resolves to `any` → unverifiable. Expand the
876
+ // resolved object structurally so the real members reach the bundle,
877
+ // mirroring `inferResponseBody`/`inferFunctionReturn`. A declared cast or
878
+ // typed binding (handled below) still wins and renders its own structural
879
+ // form via `extractExplicitTypeFromAncestor`.
880
+ const resolved = this.unwrapPromiseType(payloadType);
881
+ typeString = this.expandResolvedTypeStructural(resolved, typeString);
882
+ }
883
+ // A declared type — an `as T` cast or a typed variable binding/annotation
884
+ // on an ancestor — wins over the call's raw `Promise<any>` / `any`. Only
885
+ // recover when one is genuinely present; untyped `request.formData()`
886
+ // stays `FormData` / `any`.
887
+ //
888
+ // Use the unwrapped `node`, not `located`: when the locator lands ON the
889
+ // `(...) as T` cast itself, the cast is the located node (not an ancestor),
890
+ // so an ancestor walk from `located` would miss it. `node` is the inner
891
+ // expression whose ancestors include the `as T`, so the cast is recovered;
892
+ // the typed-binding and untyped-control cases are unaffected.
893
+ const explicitType = this.extractExplicitTypeFromAncestor(node);
894
+ if (explicitType) {
895
+ typeString = explicitType;
896
+ isExplicit = true;
897
+ }
898
+ typeString = this.unwrapPromise(typeString, payloadType);
899
+ return this.createInferredType(request, typeString, isExplicit, this.getNodeLocation(node), unwrapResult.wasUnwrapped ? unwrapResult.typeString : undefined);
900
+ }
901
+ // ===========================================================================
902
+ // Call Result Resolution
903
+ // ===========================================================================
904
+ resolveCallResultTerminalNode(callExpr, func) {
905
+ const returnStmt = callExpr.getFirstAncestorByKind(SyntaxKind.ReturnStatement);
906
+ if (returnStmt) {
907
+ const returnExpr = returnStmt.getExpression();
908
+ if (returnExpr) {
909
+ return returnExpr;
910
+ }
911
+ }
912
+ const binding = this.extractBindingFromCall(callExpr);
913
+ if (binding && func) {
914
+ let currentNames = binding.names;
915
+ let lastNode = binding.node;
916
+ const startPos = callExpr.getStart();
917
+ const candidates = this.collectDefUseNodes(func);
918
+ for (const expr of candidates) {
919
+ if (expr.getStart() <= startPos)
920
+ continue;
921
+ if (Node.isVariableDeclaration(expr)) {
922
+ const initializer = expr.getInitializer();
923
+ if (initializer &&
924
+ Node.isIdentifier(initializer) &&
925
+ this.expressionUsesNames(initializer, currentNames)) {
926
+ const names = this.extractBindingNames(expr.getNameNode());
927
+ currentNames = names;
928
+ lastNode = expr;
929
+ }
930
+ }
931
+ if (Node.isBinaryExpression(expr)) {
932
+ const left = expr.getLeft();
933
+ const right = expr.getRight();
934
+ if (Node.isIdentifier(right) &&
935
+ this.expressionUsesNames(right, currentNames)) {
936
+ if (Node.isIdentifier(left)) {
937
+ const names = [left.getText()];
938
+ currentNames = names;
939
+ lastNode = expr;
940
+ }
941
+ }
942
+ }
943
+ if (this.expressionUsesNames(expr, currentNames)) {
944
+ lastNode = expr;
945
+ }
946
+ }
947
+ return lastNode;
948
+ }
949
+ return callExpr;
950
+ }
951
+ extractBindingFromCall(callExpr) {
952
+ const varDecl = callExpr.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
953
+ if (varDecl) {
954
+ const initializer = varDecl.getInitializer();
955
+ if (initializer === callExpr || this.unwrapExpressionNode(initializer ?? callExpr) === callExpr) {
956
+ const names = this.extractBindingNames(varDecl.getNameNode());
957
+ const node = varDecl;
958
+ return { names, node };
959
+ }
960
+ }
961
+ const assignment = callExpr.getFirstAncestorByKind(SyntaxKind.BinaryExpression);
962
+ if (assignment) {
963
+ const right = assignment.getRight();
964
+ if (right === callExpr ||
965
+ this.unwrapExpressionNode(right) === callExpr) {
966
+ const left = assignment.getLeft();
967
+ const names = Node.isIdentifier(left) ? [left.getText()] : [];
968
+ const node = assignment;
969
+ return { names, node };
970
+ }
971
+ }
972
+ return null;
973
+ }
974
+ extractBindingNames(nameNode) {
975
+ if (Node.isIdentifier(nameNode)) {
976
+ return [nameNode.getText()];
977
+ }
978
+ if (Node.isObjectBindingPattern(nameNode) || Node.isArrayBindingPattern(nameNode)) {
979
+ const names = [];
980
+ for (const element of nameNode.getElements()) {
981
+ if (Node.isBindingElement(element)) {
982
+ const elementName = element.getNameNode();
983
+ names.push(...this.extractBindingNames(elementName));
984
+ }
985
+ }
986
+ return names;
987
+ }
988
+ return [];
989
+ }
990
+ getPrimaryBindingNode(nameNode) {
991
+ if (Node.isIdentifier(nameNode)) {
992
+ return nameNode;
993
+ }
994
+ if (Node.isObjectBindingPattern(nameNode) || Node.isArrayBindingPattern(nameNode)) {
995
+ const elements = nameNode.getElements();
996
+ if (elements.length > 0 && Node.isBindingElement(elements[0])) {
997
+ const elementName = elements[0].getNameNode();
998
+ const found = this.getPrimaryBindingNode(elementName);
999
+ return found;
1000
+ }
1001
+ }
1002
+ return nameNode;
1003
+ }
1004
+ collectDefUseNodes(func) {
1005
+ const candidates = [];
1006
+ func.forEachDescendant((node) => {
1007
+ if (Node.isIdentifier(node) ||
1008
+ Node.isVariableDeclaration(node) ||
1009
+ Node.isBinaryExpression(node)) {
1010
+ candidates.push(node);
1011
+ }
1012
+ });
1013
+ return candidates;
1014
+ }
1015
+ expressionUsesNames(expr, names) {
1016
+ const identifiers = expr.getDescendantsOfKind(SyntaxKind.Identifier);
1017
+ return identifiers.some((id) => this.isIdentifierUsage(id, names));
1018
+ }
1019
+ isIdentifierUsage(id, names) {
1020
+ if (!Node.isIdentifier(id)) {
1021
+ return false;
1022
+ }
1023
+ const text = id.getText();
1024
+ if (!names.includes(text)) {
1025
+ return false;
1026
+ }
1027
+ const parent = id.getParent();
1028
+ if (parent &&
1029
+ Node.isVariableDeclaration(parent) &&
1030
+ parent.getNameNode() === id) {
1031
+ return false;
1032
+ }
1033
+ if (parent &&
1034
+ Node.isBindingElement(parent) &&
1035
+ parent.getNameNode() === id) {
1036
+ return false;
1037
+ }
1038
+ return true;
1039
+ }
1040
+ isInFunctionScope(node, func) {
1041
+ const ancestor = node.getFirstAncestor((candidate) => Node.isFunctionDeclaration(candidate) ||
1042
+ Node.isArrowFunction(candidate) ||
1043
+ Node.isFunctionExpression(candidate) ||
1044
+ Node.isMethodDeclaration(candidate));
1045
+ return ancestor === func;
1046
+ }
1047
+ // ===========================================================================
1048
+ // Extraction Config-based Payload Unwrapping (NEW)
1049
+ // ===========================================================================
1050
+ /**
1051
+ * Resolve the awaited type: `Promise<X>` / `PromiseLike<X>` / `Awaited<X>` →
1052
+ * `X` at the type level (recursively, so `Awaited<Promise<X>>` → `X`); any
1053
+ * non-thenable type passes through UNCHANGED.
1054
+ *
1055
+ * ts-morph 25.0.1 does not expose the compiler's `getAwaitedType` on its
1056
+ * `Type` wrapper (only `AwaitableNode.isAwaited()` on AST nodes, which is
1057
+ * unrelated), so we resolve structurally on the symbol/alias name instead of
1058
+ * gating on the literal `'Promise'` symbol. This generalizes past `Promise<T>`
1059
+ * to `PromiseLike<T>` and the `Awaited<T>` utility type without over-unwrapping:
1060
+ * a non-thenable like `AsyncGenerator<T>` is NOT awaitable and is returned
1061
+ * unchanged, so the `unwrapAsyncIterableType` step that runs right after still
1062
+ * sees (and peels) the iterator wrapper as before.
1063
+ */
1064
+ unwrapPromiseType(type) {
1065
+ // Bounded recursion guard: a pathological self-referential alias must not
1066
+ // loop. Real promise nesting is shallow; 16 is far more than enough.
1067
+ let current = type;
1068
+ for (let depth = 0; depth < 16; depth++) {
1069
+ const symbolName = current.getSymbol()?.getName();
1070
+ const aliasName = current.getAliasSymbol()?.getName();
1071
+ // `Promise<X>` / `PromiseLike<X>` — single type argument is the value.
1072
+ if (symbolName === 'Promise' || symbolName === 'PromiseLike') {
1073
+ const args = current.getTypeArguments();
1074
+ if (args.length === 1) {
1075
+ current = args[0];
1076
+ continue;
1077
+ }
1078
+ return current;
1079
+ }
1080
+ // `Awaited<X>` utility type — unwrap its alias argument and keep resolving
1081
+ // (TS usually eager-resolves this, but handle the surfaced-alias form too).
1082
+ if (aliasName === 'Awaited') {
1083
+ const aliasArgs = current.getAliasTypeArguments();
1084
+ if (aliasArgs.length === 1) {
1085
+ current = aliasArgs[0];
1086
+ continue;
1087
+ }
1088
+ return current;
1089
+ }
1090
+ return current;
1091
+ }
1092
+ return current;
1093
+ }
1094
+ /**
1095
+ * `AsyncGenerator<T, …>` / `AsyncIterableIterator<T>` / `AsyncIterator<T>` /
1096
+ * `Generator<T, …>` / `IterableIterator<T>` → `T` (the yield type) at the type
1097
+ * level; any other type passes through. A GraphQL subscription resolver written
1098
+ * as `async function* x(): AsyncGenerator<Order>` carries its contract in the
1099
+ * yield position, so the iterator wrapper must be peeled the same way Promise is
1100
+ * before structural expansion — otherwise the response contract resolves to the
1101
+ * library `AsyncGenerator<…>` machinery instead of the bare `Order`.
1102
+ */
1103
+ unwrapAsyncIterableType(type) {
1104
+ const symbolName = (type.getSymbol() || type.getAliasSymbol())?.getName();
1105
+ if (symbolName && ASYNC_ITERABLE_SYMBOLS.has(symbolName)) {
1106
+ const args = type.getTypeArguments();
1107
+ if (args.length >= 1) {
1108
+ return args[0];
1109
+ }
1110
+ }
1111
+ return type;
1112
+ }
1113
+ /** The "leave the type as it is" result every bail-out path shares. */
1114
+ noUnwrap(type, node) {
1115
+ return {
1116
+ typeString: typeText(type, node),
1117
+ isExplicit: false,
1118
+ wasUnwrapped: false,
1119
+ };
1120
+ }
1121
+ /**
1122
+ * Unwrap a type using the agent-generated ExtractionConfig.
1123
+ */
1124
+ unwrapTypeWithConfig(type, node, extractionConfig) {
1125
+ if (!extractionConfig || extractionConfig.rules.length === 0) {
1126
+ return this.noUnwrap(type, node);
1127
+ }
1128
+ return this.unwrapType(type, node, extractionConfig, 0);
1129
+ }
1130
+ /**
1131
+ * Core unwrapping implementation with ExtractionConfig rules.
1132
+ *
1133
+ * Requirements:
1134
+ * 1. Exact wrapperSymbols match extracts (gated on originModuleGlobs when
1135
+ * the rule carries them — names like `Response` are shared by the DOM,
1136
+ * frameworks, and HTTP clients)
1137
+ * 2. machineryIndicators only trigger unwrap if originModuleGlobs also match
1138
+ * 3. Handle unions and intersections
1139
+ * 4. Support recursive unwrapping with depth limits
1140
+ * 5. A rule that matches but extracts nothing never blocks later rules;
1141
+ * only after every rule has run does an origin-verified match with no
1142
+ * recoverable payload collapse to `unknown`
1143
+ */
1144
+ unwrapType(type, node, config, depth) {
1145
+ const maxGlobalDepth = 10; // Safety limit
1146
+ if (depth >= maxGlobalDepth) {
1147
+ return this.noUnwrap(type, node);
1148
+ }
1149
+ // Handle union types: Response<A> | Response<B> → unwrap to A | B
1150
+ if (type.isUnion()) {
1151
+ const unionTypes = type.getUnionTypes();
1152
+ const unwrappedParts = [];
1153
+ /** Branches that read as `unknown`: verified machinery, no payload. */
1154
+ const unreadBranches = [];
1155
+ let anyUnwrapped = false;
1156
+ for (const unionType of unionTypes) {
1157
+ const result = this.unwrapType(unionType, node, config, depth + 1);
1158
+ unwrappedParts.push(result.typeString);
1159
+ if (result.wasUnwrapped) {
1160
+ anyUnwrapped = true;
1161
+ }
1162
+ if (result.typeString.trim() === 'unknown') {
1163
+ unreadBranches.push(this.pathFreeTypeLabel(unionType));
1164
+ }
1165
+ }
1166
+ if (anyUnwrapped) {
1167
+ const unique = [...new Set(unwrappedParts)];
1168
+ const informative = unique.filter((part) => part !== 'unknown');
1169
+ // Every branch collapsed, so the union as a whole recovered no
1170
+ // payload — the same standing as a single verified-machinery
1171
+ // collapse, and the caller's own recovery should still get a turn.
1172
+ if (informative.length === 0) {
1173
+ return {
1174
+ typeString: 'unknown',
1175
+ isExplicit: false,
1176
+ wasUnwrapped: true,
1177
+ verifiedMachinery: true,
1178
+ };
1179
+ }
1180
+ // carrick#166: SOME branches read and some did not. Joining the ones
1181
+ // that did states a partial contract as the whole one — on the live
1182
+ // repro, one guard branch's status envelope stood in for a handler
1183
+ // that also returns the real payload — and a contract known to be
1184
+ // partial is a false match, which is worse than the `any` it
1185
+ // replaced. Answer `unknown` and carry the reason instead. This is
1186
+ // NOT the verified-machinery verdict: the union did resolve
1187
+ // something, so the return-statement recovery is not the right next
1188
+ // move, and reading a payload out of the unread branches is its own
1189
+ // work.
1190
+ if (unreadBranches.length > 0) {
1191
+ return {
1192
+ typeString: 'unknown',
1193
+ isExplicit: false,
1194
+ wasUnwrapped: true,
1195
+ unreadUnionBranches: unreadBranches,
1196
+ };
1197
+ }
1198
+ return {
1199
+ typeString: informative.length === 1 ? informative[0] : informative.join(' | '),
1200
+ isExplicit: false,
1201
+ wasUnwrapped: true,
1202
+ };
1203
+ }
1204
+ }
1205
+ // Handle intersection types: Response<A> & X → try to unwrap Response<A>
1206
+ if (type.isIntersection()) {
1207
+ const intersectionTypes = type.getIntersectionTypes();
1208
+ for (const intersectType of intersectionTypes) {
1209
+ const result = this.unwrapType(intersectType, node, config, depth + 1);
1210
+ if (result.wasUnwrapped) {
1211
+ return result;
1212
+ }
1213
+ }
1214
+ }
1215
+ // Try each rule. A rule that verifies the wrapper's identity but cannot
1216
+ // recover a payload must not stop the loop — the model is encouraged to
1217
+ // emit several overlapping rules (e.g. a generic-index variant and a
1218
+ // property-path variant for the same wrapper), and a later one may still
1219
+ // extract. Only when every rule has had its chance does a verified match
1220
+ // collapse to `unknown`: the wrapper itself is never the contract, and
1221
+ // downstream treats `unknown` as unresolved instead of comparing it.
1222
+ let verifiedMachinery = false;
1223
+ for (const rule of config.rules) {
1224
+ const attempt = this.tryUnwrapWithRule(type, node, rule, config, depth);
1225
+ if (attempt.kind === 'extracted') {
1226
+ return attempt.result;
1227
+ }
1228
+ if (attempt.kind === 'verified-no-payload') {
1229
+ verifiedMachinery = true;
1230
+ }
1231
+ }
1232
+ if (verifiedMachinery) {
1233
+ return {
1234
+ typeString: 'unknown',
1235
+ isExplicit: false,
1236
+ wasUnwrapped: true,
1237
+ verifiedMachinery: true,
1238
+ };
1239
+ }
1240
+ return this.noUnwrap(type, node);
1241
+ }
1242
+ /**
1243
+ * Try to unwrap a type using a single ExtractionRule.
1244
+ */
1245
+ tryUnwrapWithRule(type, node, rule, config, depth) {
1246
+ const maxDepth = rule.maxDepth ?? 4;
1247
+ if (depth >= maxDepth) {
1248
+ return { kind: 'no-match' };
1249
+ }
1250
+ const symbol = type.getSymbol() || type.getAliasSymbol();
1251
+ const symbolName = symbol?.getName();
1252
+ // 1. Check exact wrapperSymbols match. When the rule also carries
1253
+ // originModuleGlobs, the symbol's declaration must come from a matching
1254
+ // module — names like `Response` are shared by the DOM, frameworks, and
1255
+ // HTTP clients, so a bare name match would unwrap unrelated types.
1256
+ if (rule.wrapperSymbols && symbolName && rule.wrapperSymbols.includes(symbolName)) {
1257
+ const originGated = !!(rule.originModuleGlobs && rule.originModuleGlobs.length > 0);
1258
+ if (!originGated || this.symbolOriginatesFromModules(symbol, rule.originModuleGlobs)) {
1259
+ const extracted = this.extractPayloadFromWrapper(type, node, rule, config, depth);
1260
+ if (extracted) {
1261
+ return { kind: 'extracted', result: extracted };
1262
+ }
1263
+ // A name-only match is not proof of machinery: a local type that
1264
+ // happens to share the name must keep its real structural type when
1265
+ // nothing was extracted. Only origin-verified matches may collapse
1266
+ // to `unknown`.
1267
+ return originGated ? { kind: 'verified-no-payload' } : { kind: 'no-match' };
1268
+ }
1269
+ return { kind: 'no-match' };
1270
+ }
1271
+ // 2. Check machineryIndicators + originModuleGlobs. Indicators alone are
1272
+ // too many false positives, so the origin gate is mandatory here — which
1273
+ // also means a match in this branch is always origin-verified.
1274
+ if (rule.machineryIndicators && rule.machineryIndicators.length > 0) {
1275
+ if (!rule.originModuleGlobs || rule.originModuleGlobs.length === 0) {
1276
+ return { kind: 'no-match' };
1277
+ }
1278
+ if (!this.typeHasMachineryIndicators(type, rule.machineryIndicators)) {
1279
+ return { kind: 'no-match' };
1280
+ }
1281
+ if (!this.symbolOriginatesFromModules(symbol, rule.originModuleGlobs)) {
1282
+ return { kind: 'no-match' };
1283
+ }
1284
+ const extracted = this.extractPayloadFromWrapper(type, node, rule, config, depth);
1285
+ if (extracted) {
1286
+ return { kind: 'extracted', result: extracted };
1287
+ }
1288
+ return { kind: 'verified-no-payload' };
1289
+ }
1290
+ return { kind: 'no-match' };
1291
+ }
1292
+ /**
1293
+ * Extract the payload type from a matched wrapper. Returns null when the
1294
+ * rule matched the wrapper but no payload is recoverable from generics or
1295
+ * property paths — the caller decides what a payload-less match means
1296
+ * (verified machinery collapses to `unknown` after every rule has run;
1297
+ * a name-only match leaves the type untouched).
1298
+ */
1299
+ extractPayloadFromWrapper(type, node, rule, config, depth) {
1300
+ // The outer extraction already succeeded on the paths below; a recursive
1301
+ // inner pass that finds nothing more must not demote the result back to
1302
+ // "not unwrapped" (which would discard the recovered payload). Only when
1303
+ // the inner pass unwrapped NOTHING is this level's payload the recovered
1304
+ // type; an inner pass that did unwrap already decided its own payloadType,
1305
+ // and its absence is deliberate — a union join has no single payload, and
1306
+ // a verified-machinery collapse to `unknown` recovered none, so this
1307
+ // level's (wrapper-shaped) payload must not resurface as an anchor.
1308
+ const recurse = (payload) => {
1309
+ const inner = this.unwrapType(payload, node, config, depth + 1);
1310
+ return {
1311
+ ...inner,
1312
+ wasUnwrapped: true,
1313
+ payloadType: inner.wasUnwrapped ? inner.payloadType : payload,
1314
+ };
1315
+ };
1316
+ // 1. Try generic type argument at payloadGenericIndex
1317
+ const genericIndex = rule.payloadGenericIndex ?? 0;
1318
+ const typeArgs = type.getTypeArguments();
1319
+ if (typeArgs.length > genericIndex) {
1320
+ const payloadArg = typeArgs[genericIndex];
1321
+ // Check if it's a useful type (not any/unknown/never)
1322
+ const argText = typeText(payloadArg, node);
1323
+ if (!this.isUselessType(argText)) {
1324
+ // Recursive unwrap if configured
1325
+ if (rule.unwrapRecursively) {
1326
+ return recurse(payloadArg);
1327
+ }
1328
+ return {
1329
+ typeString: argText,
1330
+ isExplicit: true,
1331
+ wasUnwrapped: true,
1332
+ payloadType: payloadArg,
1333
+ };
1334
+ }
1335
+ // Try "first useful generic" heuristic
1336
+ for (let i = 0; i < typeArgs.length; i++) {
1337
+ const argType = typeArgs[i];
1338
+ const text = typeText(argType, node);
1339
+ if (!this.isUselessType(text)) {
1340
+ if (rule.unwrapRecursively) {
1341
+ return recurse(argType);
1342
+ }
1343
+ return {
1344
+ typeString: text,
1345
+ isExplicit: true,
1346
+ wasUnwrapped: true,
1347
+ payloadType: argType,
1348
+ };
1349
+ }
1350
+ }
1351
+ }
1352
+ // 2. Try payloadPropertyPath
1353
+ if (rule.payloadPropertyPath && rule.payloadPropertyPath.length > 0) {
1354
+ let currentType = type;
1355
+ for (const propName of rule.payloadPropertyPath) {
1356
+ const prop = currentType.getProperty(propName);
1357
+ if (!prop) {
1358
+ break;
1359
+ }
1360
+ const propType = prop.getTypeAtLocation(node);
1361
+ currentType = propType;
1362
+ }
1363
+ if (currentType !== type) {
1364
+ const propText = typeText(currentType, node);
1365
+ if (!this.isUselessType(propText)) {
1366
+ if (rule.unwrapRecursively) {
1367
+ return recurse(currentType);
1368
+ }
1369
+ return {
1370
+ typeString: propText,
1371
+ isExplicit: false,
1372
+ wasUnwrapped: true,
1373
+ payloadType: currentType,
1374
+ };
1375
+ }
1376
+ }
1377
+ }
1378
+ return null;
1379
+ }
1380
+ /**
1381
+ * True when a producer RESPONSE type IS or CONTAINS framework transport
1382
+ * machinery (a fetch/DOM `Response`, a Node `ServerResponse`, a reply object)
1383
+ * — the artifact behind carrick#371, where a wrapped handler's literal return
1384
+ * envelope `{ response: Response; error?: undefined } | { ...; error: Error }`
1385
+ * was captured as the response contract. Machinery is never a comparable
1386
+ * contract, so a response path that resolves here abstains (honest `unknown`)
1387
+ * rather than emit a concrete-but-false type.
1388
+ *
1389
+ * DETECTS, exactly (see `typeIsOrContainsMachinery`):
1390
+ * 1. the type itself is machinery (`typeIsFrameworkMachinery`);
1391
+ * 2. a union/intersection member is machinery (the envelope union);
1392
+ * 3. a DIRECT property's type is machinery, ONE level of descent only
1393
+ * (`{ response: Response; error }`).
1394
+ *
1395
+ * DELIBERATELY NOT DETECTED — listed so this comment never overstates the
1396
+ * guarantee (an overstated safety comment is what bit sibling PR #442). Each is
1397
+ * a non-regression: it leaves the pre-existing verdict unchanged and never
1398
+ * manufactures a new wrong one, tracked as a follow-up:
1399
+ * - machinery nested deeper than one property level (a property whose type
1400
+ * is itself a nested object wrapping the machinery);
1401
+ * - a property typed `Promise<Response>` (the property type is NOT awaited /
1402
+ * Promise-unwrapped before the machinery check);
1403
+ * - an array element type: `Response[]` is not descended to its element;
1404
+ * - `interface X extends Response` declared in USER source — the origin gate
1405
+ * is lib/`node_modules` only, so a user-declared subtype reads as a real
1406
+ * contract, not machinery;
1407
+ * - a function / call-signature return type: the response paths that call
1408
+ * this resolve a handler's RETURN (an envelope/object), never a function
1409
+ * value, so no call-signature descent happens here. The capture-seam
1410
+ * mirror `capture/machinery.ts` DOES descend call signatures — that is
1411
+ * where the wrapper-FUNCTION type the Infer fallback resolves is caught.
1412
+ *
1413
+ * The origin gate in `typeIsFrameworkMachinery` keeps a user object whose
1414
+ * fields merely share a member name from tripping.
1415
+ */
1416
+ typeIsOrContainsResponseMachinery(type) {
1417
+ return this.typeIsOrContainsMachinery(type, 0);
1418
+ }
1419
+ typeIsOrContainsMachinery(type, depth) {
1420
+ if (this.typeIsFrameworkMachinery(type)) {
1421
+ return true;
1422
+ }
1423
+ // Union/intersection: any envelope member wrapping machinery taints it.
1424
+ if (type.isUnion()) {
1425
+ return type
1426
+ .getUnionTypes()
1427
+ .some((part) => this.typeIsOrContainsMachinery(part, depth));
1428
+ }
1429
+ if (type.isIntersection()) {
1430
+ return type
1431
+ .getIntersectionTypes()
1432
+ .some((part) => this.typeIsOrContainsMachinery(part, depth));
1433
+ }
1434
+ // One level of property descent: `{ response: Response; error?: undefined }`.
1435
+ if (depth < 1) {
1436
+ for (const prop of type.getProperties()) {
1437
+ const decl = prop.getDeclarations()[0];
1438
+ if (!decl)
1439
+ continue;
1440
+ const propType = prop.getTypeAtLocation(decl);
1441
+ if (this.typeIsOrContainsMachinery(propType, depth + 1)) {
1442
+ return true;
1443
+ }
1444
+ }
1445
+ }
1446
+ return false;
1447
+ }
1448
+ /**
1449
+ * True when `type` itself is an HTTP-machinery type: it structurally carries
1450
+ * at least `MACHINERY_INDICATOR_THRESHOLD` of the strongly-discriminating
1451
+ * `MACHINERY_MEMBER_INDICATORS`, AND its symbol is declared in a lib
1452
+ * (`lib.dom.d.ts`, ...) or `node_modules` origin. Both gates are required —
1453
+ * the indicator subset alone essentially never matches a JSON payload, and
1454
+ * the origin gate makes certain a user's own local type sharing those member
1455
+ * names is never mistaken for framework machinery (the advisor's guard).
1456
+ */
1457
+ typeIsFrameworkMachinery(type) {
1458
+ if (!this.hasMachineryIndicatorThreshold(type)) {
1459
+ return false;
1460
+ }
1461
+ const symbol = type.getSymbol() ?? type.getAliasSymbol();
1462
+ return this.symbolIsLibOrExternalOrigin(symbol);
1463
+ }
1464
+ /**
1465
+ * True once the type carries at least `MACHINERY_INDICATOR_THRESHOLD` DISTINCT
1466
+ * `MACHINERY_MEMBER_INDICATORS` (own + apparent). Deduplicates by name (own and
1467
+ * apparent property lists overlap) and early-returns the moment the threshold
1468
+ * is reached — the callers only need the boolean, never the full count.
1469
+ */
1470
+ hasMachineryIndicatorThreshold(type) {
1471
+ const matched = new Set();
1472
+ const consider = (name) => {
1473
+ if (MACHINERY_MEMBER_INDICATORS.has(name)) {
1474
+ matched.add(name);
1475
+ }
1476
+ return matched.size >= MACHINERY_INDICATOR_THRESHOLD;
1477
+ };
1478
+ for (const prop of type.getProperties()) {
1479
+ if (consider(prop.getName()))
1480
+ return true;
1481
+ }
1482
+ for (const prop of type.getApparentProperties()) {
1483
+ if (consider(prop.getName()))
1484
+ return true;
1485
+ }
1486
+ return false;
1487
+ }
1488
+ /**
1489
+ * True when the symbol is declared in a TypeScript lib file (`lib.*.d.ts`) or
1490
+ * under `node_modules` — i.e. framework/runtime machinery, not user source.
1491
+ * Works on a bare checkout: the DOM `Response`/`Request` resolve from the
1492
+ * bundled `lib.dom.d.ts` even with no installed dependencies.
1493
+ */
1494
+ symbolIsLibOrExternalOrigin(symbol) {
1495
+ if (!symbol) {
1496
+ return false;
1497
+ }
1498
+ const isExternalPath = (filePath) => {
1499
+ const normalized = filePath.replace(/\\/g, '/');
1500
+ return (normalized.includes('/node_modules/') ||
1501
+ /\/lib\.[^/]*\.d\.ts$/.test(normalized));
1502
+ };
1503
+ for (const decl of symbol.getDeclarations()) {
1504
+ if (isExternalPath(decl.getSourceFile().getFilePath())) {
1505
+ return true;
1506
+ }
1507
+ }
1508
+ try {
1509
+ const aliased = symbol.getAliasedSymbol?.();
1510
+ if (aliased && aliased !== symbol) {
1511
+ return this.symbolIsLibOrExternalOrigin(aliased);
1512
+ }
1513
+ }
1514
+ catch {
1515
+ // Ignore errors when resolving the aliased symbol.
1516
+ }
1517
+ return false;
1518
+ }
1519
+ /**
1520
+ * Check if a type has machinery indicator properties/methods.
1521
+ */
1522
+ typeHasMachineryIndicators(type, indicators) {
1523
+ const properties = type.getProperties();
1524
+ const propertyNames = properties.map((p) => p.getName());
1525
+ for (const indicator of indicators) {
1526
+ if (propertyNames.includes(indicator)) {
1527
+ return true;
1528
+ }
1529
+ }
1530
+ // Also check apparent properties (for interfaces, etc.)
1531
+ const apparentProperties = type.getApparentProperties();
1532
+ const apparentNames = apparentProperties.map((p) => p.getName());
1533
+ for (const indicator of indicators) {
1534
+ if (apparentNames.includes(indicator)) {
1535
+ return true;
1536
+ }
1537
+ }
1538
+ return false;
1539
+ }
1540
+ /**
1541
+ * Check if a symbol's declarations originate from modules matching the globs.
1542
+ */
1543
+ symbolOriginatesFromModules(symbol, moduleGlobs) {
1544
+ if (!symbol) {
1545
+ return false;
1546
+ }
1547
+ const declarations = symbol.getDeclarations();
1548
+ for (const decl of declarations) {
1549
+ const sourceFile = decl.getSourceFile();
1550
+ const filePath = sourceFile.getFilePath();
1551
+ for (const glob of moduleGlobs) {
1552
+ if (this.filePathMatchesModuleGlob(filePath, glob)) {
1553
+ return true;
1554
+ }
1555
+ }
1556
+ }
1557
+ // Also check aliased symbol
1558
+ try {
1559
+ const aliased = symbol.getAliasedSymbol?.();
1560
+ if (aliased && aliased !== symbol) {
1561
+ return this.symbolOriginatesFromModules(aliased, moduleGlobs);
1562
+ }
1563
+ }
1564
+ catch {
1565
+ // Ignore errors when getting aliased symbol
1566
+ }
1567
+ return false;
1568
+ }
1569
+ /**
1570
+ * Simple glob matching for module paths.
1571
+ * Supports: exact match, a trailing "*" wildcard, and "package/*" patterns.
1572
+ *
1573
+ * Matches are segment-bounded: the glob names a package (or package
1574
+ * subpath) under node_modules, and the match must end at a path-segment
1575
+ * boundary — `got` matches `node_modules/got/...` but never
1576
+ * `node_modules/got-scraping/...`. This matters because the exact-symbol
1577
+ * origin gate routes shared names like `Response` through here.
1578
+ */
1579
+ filePathMatchesModuleGlob(filePath, glob) {
1580
+ const normalizedPath = filePath.replace(/\\/g, '/');
1581
+ const candidates = [glob];
1582
+ if (!glob.startsWith('@types/')) {
1583
+ // Auto-try the DefinitelyTyped variant: pkg → @types/pkg,
1584
+ // @scope/pkg → @types/scope__pkg.
1585
+ candidates.push(glob.startsWith('@')
1586
+ ? `@types/${glob.slice(1).replace('/', '__')}`
1587
+ : `@types/${glob}`);
1588
+ }
1589
+ return candidates.some((candidate) => {
1590
+ const base = candidate.replace(/\/?\*+$/, '').replace(/\*/g, '');
1591
+ if (base === '') {
1592
+ return false;
1593
+ }
1594
+ const needle = `node_modules/${base}`;
1595
+ let idx = normalizedPath.indexOf(needle);
1596
+ while (idx !== -1) {
1597
+ const next = normalizedPath[idx + needle.length];
1598
+ if (next === undefined || next === '/') {
1599
+ return true;
1600
+ }
1601
+ idx = normalizedPath.indexOf(needle, idx + 1);
1602
+ }
1603
+ return false;
1604
+ });
1605
+ }
1606
+ /**
1607
+ * Check if a type string is "useless" for payload purposes.
1608
+ */
1609
+ isUselessType(typeString) {
1610
+ const useless = ['any', 'unknown', 'never', 'void', 'undefined', 'null', 'object', '{}'];
1611
+ const trimmed = typeString.trim();
1612
+ return useless.includes(trimmed) || trimmed === '';
1613
+ }
1614
+ unwrapExpressionNode(node) {
1615
+ let current = node;
1616
+ while (current) {
1617
+ if (Node.isParenthesizedExpression(current)) {
1618
+ current = current.getExpression();
1619
+ continue;
1620
+ }
1621
+ if (Node.isAwaitExpression(current)) {
1622
+ current = current.getExpression();
1623
+ continue;
1624
+ }
1625
+ if (Node.isAsExpression(current)) {
1626
+ current = current.getExpression();
1627
+ continue;
1628
+ }
1629
+ if (Node.isNonNullExpression(current)) {
1630
+ current = current.getExpression();
1631
+ continue;
1632
+ }
1633
+ break;
1634
+ }
1635
+ return current ?? node;
1636
+ }
1637
+ /**
1638
+ * If `node` is a `JSON.stringify(arg)` call, return the (expression-unwrapped)
1639
+ * first argument so its type is read instead of the call's `string` result.
1640
+ * Otherwise return `node` unchanged. Any non-`JSON.stringify` call, or a
1641
+ * `JSON.stringify()` with no argument, is left alone.
1642
+ */
1643
+ unwrapJsonStringifyArg(node) {
1644
+ if (!Node.isCallExpression(node)) {
1645
+ return node;
1646
+ }
1647
+ const callee = node.getExpression();
1648
+ if (!Node.isPropertyAccessExpression(callee)) {
1649
+ return node;
1650
+ }
1651
+ const obj = callee.getExpression();
1652
+ if (!Node.isIdentifier(obj) ||
1653
+ obj.getText() !== "JSON" ||
1654
+ callee.getName() !== "stringify") {
1655
+ return node;
1656
+ }
1657
+ const args = node.getArguments();
1658
+ if (args.length === 0) {
1659
+ return node;
1660
+ }
1661
+ return this.unwrapExpressionNode(args[0]);
1662
+ }
1663
+ extractExplicitTypeFromAncestor(node) {
1664
+ const varDecl = node.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
1665
+ if (varDecl) {
1666
+ const typeNode = varDecl.getTypeNode();
1667
+ if (typeNode) {
1668
+ return this.expandAnnotationTypeNode(typeNode);
1669
+ }
1670
+ }
1671
+ // Consider the node ITSELF as well as its ancestors: the `call_result`
1672
+ // path's terminal node for `return res.json() as Promise<T>` IS the
1673
+ // `as` cast (an ancestor walk from it would miss it), so the #257 consumer
1674
+ // shape would never be recovered. `as T` and `<T>x` assertions both apply.
1675
+ const asExpr = Node.isAsExpression(node)
1676
+ ? node
1677
+ : node.getFirstAncestorByKind(SyntaxKind.AsExpression);
1678
+ if (asExpr) {
1679
+ const typeNode = asExpr.getTypeNode();
1680
+ if (typeNode) {
1681
+ return this.expandAnnotationTypeNode(typeNode);
1682
+ }
1683
+ }
1684
+ const typeAssertion = Node.isTypeAssertion(node)
1685
+ ? node
1686
+ : node.getFirstAncestorByKind(SyntaxKind.TypeAssertionExpression);
1687
+ if (typeAssertion) {
1688
+ const typeNode = typeAssertion.getTypeNode();
1689
+ if (typeNode) {
1690
+ return this.expandAnnotationTypeNode(typeNode);
1691
+ }
1692
+ }
1693
+ return null;
1694
+ }
1695
+ /**
1696
+ * Render an explicit annotation (`as T`, `<T>`, or a typed binding) as
1697
+ * fully-structural text.
1698
+ *
1699
+ * `typeNode.getText()` keeps a named type as its bare identifier
1700
+ * (`OrderView`, `Promise<Payment>`). A bare name is fine inside the source
1701
+ * project but becomes a dangling reference in the cross-repo `.d.ts` bundle,
1702
+ * which carries only alias lines and no source declarations — it resolves to
1703
+ * `any` and the comparison reads `unverifiable`. Resolving the annotation to
1704
+ * its `Type`, stripping `Promise<…>` at the type level, and expanding the
1705
+ * object structurally (shared with `definition-resolver.ts`) lands the real
1706
+ * shape (`{ id: string; currency: string }`) in the bundle so the consumer
1707
+ * can actually be compared.
1708
+ *
1709
+ * Falls back to the bare annotation text when the resolved type can't be
1710
+ * expanded to a structural form (primitives, library types, unresolvable
1711
+ * references), so a non-object annotation behaves exactly as before.
1712
+ */
1713
+ expandAnnotationTypeNode(typeNode) {
1714
+ const fallback = typeNode.getText();
1715
+ try {
1716
+ const annotationType = this.unwrapPromiseType(typeNode.getType());
1717
+ const expanded = expandTypeStructural(annotationType);
1718
+ // Only prefer the structural form when expansion actually inlined an
1719
+ // object shape; otherwise keep the annotation text (e.g. a bare
1720
+ // primitive or a library type the expander leaves by name).
1721
+ return expanded.startsWith('{') ? expanded : fallback;
1722
+ }
1723
+ catch {
1724
+ return fallback;
1725
+ }
1726
+ }
1727
+ /**
1728
+ * Producer-side analogue of `expandAnnotationTypeNode` that works from a
1729
+ * resolved `Type` rather than a syntactic annotation node.
1730
+ *
1731
+ * `inferResponseBody`/`inferFunctionReturn` resolve a payload to a named
1732
+ * object type (e.g. `Payment`), then render it with `typeText`, which keeps
1733
+ * the bare name. In the source-less cross-repo `.d.ts` bundle that name is a
1734
+ * dangling `export type <alias> = Payment;` → resolves to `any` →
1735
+ * `unverifiable` → `compat = None`. Expanding the resolved object structurally
1736
+ * lands the real members (`{ id: string; … }`) in the bundle so the producer
1737
+ * can be compared. Mirror of #257's consumer-side fix; keeps the bare text for
1738
+ * primitives, library types, and anything the expander leaves by name.
1739
+ *
1740
+ * `fallback` is the already-computed type text (post Promise/wrapper unwrap),
1741
+ * preserved verbatim when expansion does not inline an object.
1742
+ */
1743
+ expandResolvedTypeStructural(type, fallback) {
1744
+ try {
1745
+ const expanded = expandTypeStructural(type);
1746
+ // Prefer the expanded form whenever an object got inlined, not only when
1747
+ // it leads with `{`. `expandTypeStructural` wraps arrays and unions, so a
1748
+ // resolved `Payment[]` or `(Payment | null)[]` renders as `{…}[]` or
1749
+ // `({…} | null)[]`; a `startsWith('{')` test would miss those and fall
1750
+ // back to the bare, dangling name. Any `{` means real members reached the
1751
+ // bundle.
1752
+ return expanded.includes('{') ? expanded : fallback;
1753
+ }
1754
+ catch {
1755
+ return fallback;
1756
+ }
1757
+ }
1758
+ // ===========================================================================
1759
+ // Response-helper argument recovery (carrick#631)
1760
+ // ===========================================================================
1761
+ /**
1762
+ * Recover a response contract from a handler's own `return` statements when
1763
+ * the resolved return type carries none.
1764
+ *
1765
+ * A file-based route `loader`/`action` almost never annotates its return: the
1766
+ * payload goes through a helper — `json(payload)`, `reply(payload, opts)`,
1767
+ * `wrap(request, json(payload))` — and the helper's result is transport, not
1768
+ * contract. Two ways that collapses today:
1769
+ *
1770
+ * * the helper is typed and returns machinery (`Promise<Response>`), so the
1771
+ * carrick#371 guard abstains and the manifest reads `unknown`;
1772
+ * * the helper comes from a package with no installed declaration (the
1773
+ * scanner reads a bare checkout), so it resolves to `any` and the whole
1774
+ * response reads `any`.
1775
+ *
1776
+ * In both, the contract is the helper's ARGUMENT — every framework that ships
1777
+ * such a helper is generic over it. This walks the handler's returned
1778
+ * expressions, descends through wrapper calls to the first argument that
1779
+ * carries a payload, and unions the surviving branches. It is structural
1780
+ * throughout: no helper, framework or package name is matched anywhere, and
1781
+ * the recovery only runs where the existing path already had nothing.
1782
+ *
1783
+ * `statedOnly` is the guard for the unresolvable-callee case. A return type
1784
+ * of `any` does not say the callee was a response helper, only that it could
1785
+ * not be resolved, and on a bare checkout that is true of most imported
1786
+ * callees: `return db.findMany({ where })` would otherwise report the query
1787
+ * object as the endpoint's contract, which is a false contract and worse
1788
+ * than `unknown`. With `statedOnly` set, only an argument the source
1789
+ * annotates (`satisfies` / `as` / `<T>`) counts, because there the developer
1790
+ * stated the contract. The machinery case leaves it clear: the callee is
1791
+ * known transport, so its argument is the payload whatever its type.
1792
+ *
1793
+ * Returns `null` (a logged limitation, never a guess) when no returned
1794
+ * expression yields an argument type this may read.
1795
+ */
1796
+ recoverPayloadFromReturnStatements(func, statedOnly) {
1797
+ const candidates = [];
1798
+ const returned = this.responseReturnedExpressions(func);
1799
+ const serialisers = this.calleesProvenSerialiser(returned);
1800
+ for (const expression of returned) {
1801
+ const payloadNode = this.responseHelperPayloadNode(expression, 0, statedOnly, serialisers);
1802
+ if (!payloadNode)
1803
+ continue;
1804
+ // A `satisfies X` / `as X` on the argument states the contract in source:
1805
+ // read the ANNOTATION, not the literal the compiler widened it from.
1806
+ // `expandAnnotationTypeNode` keeps the bare name when the declaration is
1807
+ // not resolvable, which is the honest answer on a bare checkout.
1808
+ const stated = this.statedTypeNodeOf(payloadNode);
1809
+ if (stated) {
1810
+ candidates.push({
1811
+ typeString: this.expandAnnotationTypeNode(stated),
1812
+ isExplicit: true,
1813
+ node: payloadNode,
1814
+ anchorType: this.unwrapPromiseType(stated.getType()),
1815
+ statedTypeNode: stated,
1816
+ });
1817
+ continue;
1818
+ }
1819
+ const payloadType = this.unwrapPromiseType(payloadNode.getType());
1820
+ candidates.push({
1821
+ typeString: this.expandResolvedTypeStructural(payloadType, typeText(payloadType, payloadNode)),
1822
+ isExplicit: false,
1823
+ node: payloadNode,
1824
+ anchorType: payloadType,
1825
+ });
1826
+ }
1827
+ if (candidates.length === 0) {
1828
+ return null;
1829
+ }
1830
+ // An empty payload (`json({})` on a preflight branch) declares nothing, and
1831
+ // `{}` in a union would swallow the branch that does declare something. Drop
1832
+ // it only when a branch with members survives — a handler whose ONLY payload
1833
+ // is `{}` is honestly reporting an empty body.
1834
+ const isEmptyObject = (text) => text.replace(/\s+/g, '') === '{}';
1835
+ const withMembers = candidates.filter((c) => !isEmptyObject(c.typeString));
1836
+ const kept = withMembers.length > 0 ? withMembers : candidates;
1837
+ // Dedupe on the whitespace-collapsed text, preserving source order.
1838
+ const seen = new Set();
1839
+ const distinct = kept.filter((c) => {
1840
+ const key = c.typeString.replace(/\s+/g, ' ').trim();
1841
+ if (seen.has(key))
1842
+ return false;
1843
+ seen.add(key);
1844
+ return true;
1845
+ });
1846
+ const typeString = distinct.map((c) => c.typeString).join(' | ');
1847
+ return {
1848
+ typeString,
1849
+ // Only a contract every surviving branch STATES in source is explicit.
1850
+ isExplicit: distinct.every((c) => c.isExplicit),
1851
+ node: distinct[0].node,
1852
+ // A union joined several payloads, so there is no single anchor — the
1853
+ // same rule the wrapper-unwrap path applies.
1854
+ anchorType: distinct.length === 1 ? distinct[0].anchorType : undefined,
1855
+ statedTypeNode: distinct.length === 1 ? distinct[0].statedTypeNode : undefined,
1856
+ };
1857
+ }
1858
+ /**
1859
+ * The expressions a function returns, excluding returns belonging to nested
1860
+ * functions (a `.map(x => …)` callback returns a row, not the response).
1861
+ * A concise arrow body is itself the returned expression.
1862
+ */
1863
+ returnedExpressions(func) {
1864
+ const body = func.getBody();
1865
+ if (!body)
1866
+ return [];
1867
+ if (!Node.isBlock(body))
1868
+ return [body];
1869
+ const returned = [];
1870
+ for (const statement of body.getDescendantsOfKind(SyntaxKind.ReturnStatement)) {
1871
+ if (this.findContainingFunctionForNode(statement) !== func)
1872
+ continue;
1873
+ const expression = statement.getExpression();
1874
+ if (expression)
1875
+ returned.push(expression);
1876
+ }
1877
+ return returned;
1878
+ }
1879
+ /**
1880
+ * The expressions that can carry this handler's response: its own returned
1881
+ * expressions, plus the returned expressions of any function the handler
1882
+ * hands DIRECTLY to a returned call (carrick#707).
1883
+ *
1884
+ * `return settle(...).then(ok => serialise(body), err => serialise(problem))`
1885
+ * returns a call whose result is whatever the callbacks produce, so the
1886
+ * callbacks' returns are this handler's returns as surely as its own. That
1887
+ * shape is how a result-type API, a promise combinator or a matcher is
1888
+ * written, and on a bare checkout the callee resolves to nothing, so the
1889
+ * outer call's type says nothing at all.
1890
+ *
1891
+ * Only ONE level, and only from the returned call's own arguments. A
1892
+ * callback nested deeper is inside an expression the handler computes, not
1893
+ * an expression it returns — `return rows.map(r => …)` returns a row list,
1894
+ * and reading the callback there would report a row as the response. Each
1895
+ * flattened expression still has to pass the payload rule below, which no
1896
+ * bare object literal does.
1897
+ */
1898
+ responseReturnedExpressions(func) {
1899
+ const own = this.returnedExpressions(func);
1900
+ const flattened = [...own];
1901
+ for (const expression of own) {
1902
+ const call = this.peelTransparentExpression(expression);
1903
+ if (!Node.isCallExpression(call))
1904
+ continue;
1905
+ for (const arg of call.getArguments()) {
1906
+ const callback = this.peelTransparentExpression(arg);
1907
+ if (!Node.isArrowFunction(callback) &&
1908
+ !Node.isFunctionExpression(callback)) {
1909
+ continue;
1910
+ }
1911
+ flattened.push(...this.returnedExpressions(callback));
1912
+ }
1913
+ }
1914
+ return flattened;
1915
+ }
1916
+ /**
1917
+ * The callees these returned expressions PROVE are response serialisers.
1918
+ *
1919
+ * A return type of `any` says the callee could not be resolved, not that it
1920
+ * was a serialiser — the reason the unresolvable-callee case is otherwise
1921
+ * restricted to arguments the source annotates. But a handler that writes
1922
+ *
1923
+ * return f(problem, { status: 401 });
1924
+ * return f(payload);
1925
+ *
1926
+ * has stated what `f` is in its own source: a call that takes a body and,
1927
+ * beside it, an HTTP status is a response serialiser, and nothing else is
1928
+ * written that way. That is evidence the compiler does not need to resolve
1929
+ * anything to see, so it survives the bare checkout CI scans, and it makes
1930
+ * the second call's unannotated argument a payload rather than a guess.
1931
+ *
1932
+ * Three deliberate limits, each a negative test:
1933
+ * - only RETURNED expressions count. A handler may well call a logger with
1934
+ * `{ status: 500 }`; what it returns is what it serialises.
1935
+ * - the status-stating argument must sit at index >= 1. A status in FIRST
1936
+ * position is a field of the body (`{ status: 503, note }`), and reading
1937
+ * it as evidence would promote every callee that takes a status field.
1938
+ * - the evidence is per HANDLER, never cached across files. It says what
1939
+ * this handler does, and that is all it is used for.
1940
+ *
1941
+ * Callees are keyed by resolved symbol where one exists — an import alias
1942
+ * resolves locally even when its module does not — and by callee text
1943
+ * otherwise.
1944
+ */
1945
+ calleesProvenSerialiser(returned) {
1946
+ const proven = new Set();
1947
+ for (const expression of returned) {
1948
+ const call = this.peelTransparentExpression(expression);
1949
+ if (!Node.isCallExpression(call))
1950
+ continue;
1951
+ const args = call.getArguments().map((a) => this.peelTransparentExpression(a));
1952
+ if (args.length < 2)
1953
+ continue;
1954
+ const statesStatus = args
1955
+ .slice(1)
1956
+ .some((arg) => this.statedStatusCode(arg) !== undefined);
1957
+ if (!statesStatus)
1958
+ continue;
1959
+ const key = this.calleeIdentity(call);
1960
+ if (key)
1961
+ proven.add(key);
1962
+ }
1963
+ return proven;
1964
+ }
1965
+ /**
1966
+ * Stable identity for a call's callee within one file: its resolved symbol
1967
+ * when the compiler has one, else the callee's source text.
1968
+ */
1969
+ calleeIdentity(call) {
1970
+ if (!Node.isCallExpression(call))
1971
+ return undefined;
1972
+ const callee = call.getExpression();
1973
+ const symbol = callee.getSymbol();
1974
+ if (symbol)
1975
+ return `symbol:${symbol.getFullyQualifiedName()}`;
1976
+ const text = callee.getText().trim();
1977
+ return text.length > 0 ? `text:${text}` : undefined;
1978
+ }
1979
+ /**
1980
+ * The payload argument of a returned response-helper call, or `undefined`.
1981
+ *
1982
+ * Walks the call's arguments in source order: the first whose type reads as a
1983
+ * contract wins; an argument that is itself a call is descended into, which is
1984
+ * what recovers `wrap(request, json(payload))` — the transport wrapper's own
1985
+ * first argument is the request, which never reads as a contract.
1986
+ *
1987
+ * A call whose sibling options object states a >= 400 status is an error
1988
+ * branch and contributes nothing: the response contract of an endpoint is the
1989
+ * shape it returns when it succeeds.
1990
+ *
1991
+ * `statedOnly` narrows what counts as a payload to an argument the source
1992
+ * annotates; see `recoverPayloadFromReturnStatements`. It is lifted for a
1993
+ * callee the handler's own returned calls PROVE is a serialiser
1994
+ * (`calleesProvenSerialiser`): there the source has stated what the callee
1995
+ * is, so its argument is the payload even unannotated.
1996
+ */
1997
+ responseHelperPayloadNode(expression, depth, statedOnly, serialisers) {
1998
+ if (depth > RESPONSE_HELPER_MAX_DEPTH)
1999
+ return undefined;
2000
+ const call = this.peelTransparentExpression(expression);
2001
+ if (!Node.isCallExpression(call))
2002
+ return undefined;
2003
+ const args = call.getArguments().map((a) => this.peelTransparentExpression(a));
2004
+ if (args.length === 0)
2005
+ return undefined;
2006
+ if (args.slice(1).some((a) => this.statesErrorStatus(a)))
2007
+ return undefined;
2008
+ const identity = this.calleeIdentity(call);
2009
+ const proven = identity !== undefined && serialisers.has(identity);
2010
+ const effectiveStatedOnly = statedOnly && !proven;
2011
+ for (const arg of args) {
2012
+ const candidate = this.unwrapJsonStringifyArg(arg);
2013
+ if (this.nodeCarriesPayloadContract(candidate, effectiveStatedOnly)) {
2014
+ return candidate;
2015
+ }
2016
+ const nested = this.responseHelperPayloadNode(candidate, depth + 1, statedOnly, serialisers);
2017
+ if (nested)
2018
+ return nested;
2019
+ }
2020
+ return undefined;
2021
+ }
2022
+ /**
2023
+ * True when a node's type reads as a response payload.
2024
+ *
2025
+ * Object-shaped only (an object literal, a named interface, an array of
2026
+ * either, or a union containing one). A bare primitive argument is a
2027
+ * redirect location, a status code or a body string — `redirectTo("/next")`
2028
+ * must never report `"/next"` as the endpoint's contract. A stated
2029
+ * `satisfies`/`as` annotation counts even when its declaration is missing:
2030
+ * the source says what the contract is.
2031
+ *
2032
+ * Under `statedOnly` the annotation is the ONLY thing that counts, so an
2033
+ * unresolvable callee's arguments never become a contract by accident.
2034
+ */
2035
+ nodeCarriesPayloadContract(node, statedOnly) {
2036
+ if (this.statedTypeNodeOf(node))
2037
+ return true;
2038
+ if (statedOnly)
2039
+ return false;
2040
+ const type = this.unwrapPromiseType(node.getType());
2041
+ if (type.isAny() ||
2042
+ type.isUnknown() ||
2043
+ type.isNever() ||
2044
+ type.isVoid() ||
2045
+ type.isUndefined() ||
2046
+ type.isNull()) {
2047
+ return false;
2048
+ }
2049
+ if (type.getCallSignatures().length > 0)
2050
+ return false;
2051
+ if (this.typeIsOrContainsResponseMachinery(type))
2052
+ return false;
2053
+ return this.typeIsObjectShaped(type, 0);
2054
+ }
2055
+ /** Object, array-of-object, or a union/intersection containing one. */
2056
+ typeIsObjectShaped(type, depth) {
2057
+ if (depth > 2)
2058
+ return false;
2059
+ if (type.isUnion()) {
2060
+ return type.getUnionTypes().some((m) => this.typeIsObjectShaped(m, depth + 1));
2061
+ }
2062
+ if (type.isIntersection()) {
2063
+ return type
2064
+ .getIntersectionTypes()
2065
+ .some((m) => this.typeIsObjectShaped(m, depth + 1));
2066
+ }
2067
+ if (type.isArray()) {
2068
+ const element = type.getArrayElementType();
2069
+ return element ? this.typeIsObjectShaped(element, depth + 1) : false;
2070
+ }
2071
+ return type.isObject() && !type.isTuple();
2072
+ }
2073
+ /**
2074
+ * The `satisfies X` / `as X` / `<X>` annotation node on an expression, when
2075
+ * the source states its type. `satisfies` is the shape a route uses to claim
2076
+ * a shared response contract without widening the literal.
2077
+ */
2078
+ statedTypeNodeOf(node) {
2079
+ if (Node.isSatisfiesExpression(node) || Node.isAsExpression(node)) {
2080
+ return node.getTypeNode();
2081
+ }
2082
+ if (Node.isTypeAssertion(node)) {
2083
+ return node.getTypeNode();
2084
+ }
2085
+ return undefined;
2086
+ }
2087
+ /**
2088
+ * True when an argument states a >= 400 status: an options object carrying
2089
+ * `status`/`statusCode`, or a bare status code (`send(body, 404)`).
2090
+ *
2091
+ * Read from the AST first: `{ status: 400 }` in an argument position widens
2092
+ * to `{ status: number }`, so the literal only survives syntactically. The
2093
+ * type check behind it catches `as const` and hoisted option objects.
2094
+ */
2095
+ statesErrorStatus(node) {
2096
+ const code = this.statedStatusCode(node);
2097
+ return code !== undefined && code >= 400;
2098
+ }
2099
+ /**
2100
+ * The HTTP status an argument states, or `undefined`.
2101
+ *
2102
+ * Read from the AST first: `{ status: 400 }` in an argument position widens
2103
+ * to `{ status: number }`, so the literal only survives syntactically. The
2104
+ * type check behind it catches `as const` and hoisted option objects. Only
2105
+ * values in the HTTP range count — an arbitrary number named `status` on a
2106
+ * domain object (`{ status: 2 }`) states nothing about transport.
2107
+ */
2108
+ statedStatusCode(node) {
2109
+ const asStatus = (value) => typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599
2110
+ ? value
2111
+ : undefined;
2112
+ if (Node.isNumericLiteral(node)) {
2113
+ return asStatus(node.getLiteralValue());
2114
+ }
2115
+ if (Node.isObjectLiteralExpression(node)) {
2116
+ for (const name of STATUS_MEMBER_NAMES) {
2117
+ const property = node.getProperty(name);
2118
+ if (property && Node.isPropertyAssignment(property)) {
2119
+ const initializer = property.getInitializer();
2120
+ if (initializer && Node.isNumericLiteral(initializer)) {
2121
+ const code = asStatus(initializer.getLiteralValue());
2122
+ if (code !== undefined)
2123
+ return code;
2124
+ }
2125
+ }
2126
+ }
2127
+ }
2128
+ const type = node.getType();
2129
+ for (const name of STATUS_MEMBER_NAMES) {
2130
+ const property = type.getProperty(name);
2131
+ const declaration = property?.getDeclarations()[0];
2132
+ if (!property || !declaration)
2133
+ continue;
2134
+ const propertyType = property.getTypeAtLocation(declaration);
2135
+ if (propertyType.isNumberLiteral()) {
2136
+ const code = asStatus(propertyType.getLiteralValue());
2137
+ if (code !== undefined)
2138
+ return code;
2139
+ }
2140
+ }
2141
+ return undefined;
2142
+ }
2143
+ /**
2144
+ * Peel the wrappers that do not change an expression's payload: parentheses
2145
+ * and `await`. Unlike `unwrapExpressionNode` this KEEPS `as`/`satisfies`,
2146
+ * because the annotation is exactly what the recovery wants to read.
2147
+ */
2148
+ peelTransparentExpression(node) {
2149
+ let current = node;
2150
+ while (Node.isParenthesizedExpression(current) ||
2151
+ Node.isAwaitExpression(current)) {
2152
+ current = current.getExpression();
2153
+ }
2154
+ return current;
2155
+ }
2156
+ /**
2157
+ * The deterministic source symbol of a resolved type (`Payment` for a payload
2158
+ * typed `Payment`), or `undefined` when there is no single user-defined
2159
+ * symbol to anchor on. This is the same `getSymbol() || getAliasSymbol()` name
2160
+ * the socket anchor already derives (`socket_io.rs`), filtered through
2161
+ * `BUILTIN_ANCHOR_SYMBOLS` so TS/lib globals (`Promise`, `Array`, `Date`,
2162
+ * primitives, …) never become an anchor. Used to populate
2163
+ * `primary_type_symbol` so the manifest anchor no longer depends on the LLM.
2164
+ */
2165
+ primaryTypeSymbol(type) {
2166
+ const name = (type.getSymbol() || type.getAliasSymbol())?.getName();
2167
+ // Reject the compiler's synthetic names for anonymous shapes (`__type`,
2168
+ // `__object`, `__function`, …). They are not user-facing type names, so
2169
+ // they would be a meaningless — and non-resolvable — anchor.
2170
+ if (!name || name.startsWith('__') || BUILTIN_ANCHOR_SYMBOLS.has(name)) {
2171
+ return undefined;
2172
+ }
2173
+ return name;
2174
+ }
2175
+ /**
2176
+ * Declaration file (absolute path) of the anchor symbol
2177
+ * `primaryTypeSymbol` reports for this type, or `undefined` when the type
2178
+ * has no user-facing anchor or no source declaration. The scanner's
2179
+ * pub/sub two-anchor arbitration (carrick#413) uses this to re-aim a
2180
+ * demoted explicit bundle request: the bundler resolves a `SymbolRequest`
2181
+ * only against declarations IN its `source_file`, so the request must
2182
+ * point at the file that actually declares the tsc-witnessed payload type.
2183
+ *
2184
+ * Only declaration kinds the bundler's `validateSymbols` can resolve
2185
+ * (interface, type alias, class, enum, function, variable) count. A
2186
+ * symbol's declaration list can also contain re-export machinery — a
2187
+ * barrel's `ExportSpecifier` (`export { Foo } from './foo'`) points at a
2188
+ * file that does not DECLARE the type, and a request re-aimed there would
2189
+ * fail validation, turning a resolvable explicit type into `unknown`.
2190
+ * With no declaring node, no source is reported and the arbitration
2191
+ * fails closed to the explicit anchor.
2192
+ */
2193
+ primaryTypeSymbolSource(type) {
2194
+ if (this.primaryTypeSymbol(type) === undefined) {
2195
+ return undefined;
2196
+ }
2197
+ const decls = (type.getSymbol() || type.getAliasSymbol())?.getDeclarations() ?? [];
2198
+ const declaring = decls.find((d) => Node.isInterfaceDeclaration(d) ||
2199
+ Node.isTypeAliasDeclaration(d) ||
2200
+ Node.isClassDeclaration(d) ||
2201
+ Node.isEnumDeclaration(d) ||
2202
+ Node.isFunctionDeclaration(d) ||
2203
+ Node.isVariableDeclaration(d));
2204
+ return declaring?.getSourceFile().getFilePath();
2205
+ }
2206
+ /**
2207
+ * The anchor a `satisfies X` / `as X` / `<X>` annotation states IN SOURCE:
2208
+ * the name written at the annotation, where its type is declared, and the
2209
+ * array levels wrapped around it (`satisfies Order[]` → `Order`, depth 1).
2210
+ *
2211
+ * carrick#768: reading the anchor off the RESOLVED type works for an
2212
+ * interface, whose type carries its own symbol, and fails for
2213
+ *
2214
+ * export type OrderBody = Inferred<typeof OrderSchema>;
2215
+ *
2216
+ * because the alias resolves to an instantiated type TypeScript keeps no
2217
+ * alias symbol on, so `getSymbol()` answers the synthetic `__type`. The
2218
+ * route then published a correct shape with no name, and
2219
+ * `primary_type_symbol` is precisely how a reader gets from a route to the
2220
+ * type its consumer imports. The annotation names it either way, so read
2221
+ * the name from the AST and confirm it against the declaration it resolves
2222
+ * to. This is the fallback for a resolved type that anchored nothing, never
2223
+ * an override of one that did.
2224
+ *
2225
+ * Four things are rejected, so a name only anchors when it is genuinely the
2226
+ * contract a consumer would import:
2227
+ * - a generic instantiation (`satisfies Envelope<Order>`) names the
2228
+ * WRAPPER, and a bare `Envelope` is not the payload;
2229
+ * - a name resolving to no type declaration — an import of a value, a
2230
+ * re-export the checker cannot follow — is not importable as a type;
2231
+ * - a declaration in the TypeScript default library, and every
2232
+ * `BUILTIN_ANCHOR_SYMBOLS` name, which describe machinery not contracts;
2233
+ * - an inline annotation (`satisfies { id: string }`), which names nothing.
2234
+ */
2235
+ writtenAnchorOf(typeNode) {
2236
+ const MAX_ANCHOR_ARRAY_DEPTH = 10;
2237
+ let current = typeNode;
2238
+ let depth = 0;
2239
+ while (Node.isArrayTypeNode(current) && depth < MAX_ANCHOR_ARRAY_DEPTH) {
2240
+ current = current.getElementTypeNode();
2241
+ depth++;
2242
+ }
2243
+ if (!Node.isTypeReference(current))
2244
+ return undefined;
2245
+ // A type reference carrying arguments names the wrapper, not the payload.
2246
+ if (current.getTypeArguments().length > 0)
2247
+ return undefined;
2248
+ const entityName = current.getTypeName();
2249
+ // `ns.Order` states `Order`; the qualifier is the module, not the type.
2250
+ const identifier = Node.isQualifiedName(entityName)
2251
+ ? entityName.getRight()
2252
+ : entityName;
2253
+ const name = identifier.getText();
2254
+ if (!name || name.startsWith('__') || BUILTIN_ANCHOR_SYMBOLS.has(name)) {
2255
+ return undefined;
2256
+ }
2257
+ const symbol = identifier.getSymbol();
2258
+ if (!symbol)
2259
+ return undefined;
2260
+ const resolved = symbol.getAliasedSymbol() ?? symbol;
2261
+ const declaration = resolved
2262
+ .getDeclarations()
2263
+ .find((d) => Node.isTypeAliasDeclaration(d) ||
2264
+ Node.isInterfaceDeclaration(d) ||
2265
+ Node.isClassDeclaration(d) ||
2266
+ Node.isEnumDeclaration(d));
2267
+ if (!declaration)
2268
+ return undefined;
2269
+ const sourceFile = declaration.getSourceFile();
2270
+ if (sourceFile.compilerNode.hasNoDefaultLib)
2271
+ return undefined;
2272
+ return { symbol: name, source: sourceFile.getFilePath(), depth };
2273
+ }
2274
+ /**
2275
+ * Peel array levels off a resolved type: `TimelineEvent[]` → element
2276
+ * `TimelineEvent`, depth 1. An array type's own symbol is `Array` (builtin,
2277
+ * filtered), so without this a `T[]` payload has NO anchor and — worse — an
2278
+ * explicit anchor bundled for the same alias silently drops the array-ness
2279
+ * (#306: array-vs-scalar scored compatible). The element drives the anchor
2280
+ * symbol; the depth is reported on the `InferredType` so the bundler's
2281
+ * existing `array_depth` wrap (#248) can restore the `[]` levels on the
2282
+ * explicit bundle. Depth is capped at the bundler's sane ceiling; a deeper
2283
+ * type is treated as depth 0 rather than a runaway loop.
2284
+ */
2285
+ unwrapArrayLevels(type) {
2286
+ const MAX_ANCHOR_ARRAY_DEPTH = 10;
2287
+ let element = type;
2288
+ let depth = 0;
2289
+ while (depth < MAX_ANCHOR_ARRAY_DEPTH) {
2290
+ const el = element.getArrayElementType();
2291
+ if (!el) {
2292
+ return { element, depth };
2293
+ }
2294
+ element = el;
2295
+ depth++;
2296
+ }
2297
+ return { element: type, depth: 0 };
2298
+ }
2299
+ // ===========================================================================
2300
+ // Node Finding
2301
+ // ===========================================================================
2302
+ // ===========================================================================
2303
+ // Text-Based Node Resolution (Gemini expression text + line)
2304
+ // ===========================================================================
2305
+ /**
2306
+ * Resolve the target node using the best available locator:
2307
+ * 1. If span_start + span_end present → findNodeAtSpan (SWC byte offsets)
2308
+ * 2. If expression_text present → findNodeByText (Gemini text + line)
2309
+ * 3. Otherwise → undefined
2310
+ */
2311
+ /**
2312
+ * carrick#695: the type of the RECEIVER of a member call, plus the two facts
2313
+ * a role decision needs about it — which package declares that type, and what
2314
+ * the invoked member returns.
2315
+ *
2316
+ * Nothing here classifies. A member call with a route-shaped literal
2317
+ * (`x.verb("/lit", arg)`) is a route registration or a request depending on
2318
+ * what `x` IS, and that is the only question this answers. The Rust driver
2319
+ * maps the declaring package onto the detected framework / data-fetcher
2320
+ * lists; the compiler layer names no library and applies no shape rule.
2321
+ *
2322
+ * A receiver that does not resolve reports its top type (`any`) with no
2323
+ * package, so the caller can tell "unresolved" from "declared here": on a
2324
+ * checkout with no installed dependencies EVERY dependency-typed receiver is
2325
+ * `any`, and treating that as workspace-owned would invent a role.
2326
+ */
2327
+ inferReceiverType(sourceFile, request) {
2328
+ const call = this.resolveTargetCallExpression(sourceFile, request);
2329
+ if (!call) {
2330
+ return null;
2331
+ }
2332
+ const callee = call.getExpression();
2333
+ // The receiver exists only for a MEMBER call. A bare `fn("/lit", arg)`
2334
+ // has no receiver to classify and is not this kind's question.
2335
+ if (!Node.isPropertyAccessExpression(callee) && !Node.isElementAccessExpression(callee)) {
2336
+ return null;
2337
+ }
2338
+ const receiver = callee.getExpression();
2339
+ const receiverType = receiver.getType();
2340
+ const typeString = this.namedTypeLabel(receiverType, receiver);
2341
+ return {
2342
+ ...this.createInferredType(request, typeString, false, this.getNodeLocation(receiver)),
2343
+ declaring_package: this.declaringPackageOf(receiverType),
2344
+ member_return_type: this.memberReturnTypeOf(call, receiver),
2345
+ };
2346
+ }
2347
+ /**
2348
+ * The npm package name that declares a type, read off its declaration's file
2349
+ * path. `undefined` when the type has no declaration to read (a top type, a
2350
+ * primitive, an anonymous object literal) or when its declaration is not
2351
+ * under a `node_modules` tree — a type the workspace itself declares.
2352
+ *
2353
+ * The LAST `node_modules` segment wins, which is what a nested or
2354
+ * content-addressed store (`node_modules/.store/pkg@1.0.0/node_modules/pkg`)
2355
+ * requires. Scoped names keep both segments.
2356
+ */
2357
+ declaringPackageOf(type) {
2358
+ const symbol = type.getSymbol() ?? type.getAliasSymbol();
2359
+ const declaration = symbol?.getDeclarations()?.[0];
2360
+ if (!declaration) {
2361
+ return undefined;
2362
+ }
2363
+ const filePath = declaration.getSourceFile().getFilePath().replace(/\\/g, '/');
2364
+ const marker = '/node_modules/';
2365
+ const index = filePath.lastIndexOf(marker);
2366
+ if (index < 0) {
2367
+ return undefined;
2368
+ }
2369
+ const rest = filePath.slice(index + marker.length).split('/');
2370
+ if (rest.length === 0 || rest[0].length === 0) {
2371
+ return undefined;
2372
+ }
2373
+ if (rest[0].startsWith('@')) {
2374
+ return rest.length > 1 ? `${rest[0]}/${rest[1]}` : undefined;
2375
+ }
2376
+ return rest[0];
2377
+ }
2378
+ /**
2379
+ * The awaited return type of the member invoked on the receiver. Reported as
2380
+ * a fact next to the receiver; a caller that classified on it alone would be
2381
+ * back to a shape rule (any async helper taking a path would match).
2382
+ */
2383
+ memberReturnTypeOf(call, receiver) {
2384
+ const signature = call.getReturnType();
2385
+ if (!signature) {
2386
+ return undefined;
2387
+ }
2388
+ const awaited = this.unwrapPromiseType(signature);
2389
+ return this.namedTypeLabel(awaited, receiver);
2390
+ }
2391
+ /**
2392
+ * A type's own name when it has one, else its printed form.
2393
+ *
2394
+ * The printed form of a type whose declaring module the use site does not
2395
+ * import is `import("/abs/path/to/module").Name` — an absolute path that
2396
+ * would be persisted and rendered. The name alone is both stable and the
2397
+ * part a reader (or a package-list lookup) uses; anonymous types, top types
2398
+ * and primitives have no name and keep their printed text.
2399
+ */
2400
+ namedTypeLabel(type, enclosingNode) {
2401
+ const symbol = type.getSymbol() ?? type.getAliasSymbol();
2402
+ const name = symbol?.getName();
2403
+ if (name && !name.startsWith('__') && name !== 'unknown') {
2404
+ return name;
2405
+ }
2406
+ return typeText(type, enclosingNode);
2407
+ }
2408
+ /**
2409
+ * A type's own name, or `an unnamed branch` when it has none.
2410
+ *
2411
+ * Unlike `namedTypeLabel` this never falls back to the printed form, because
2412
+ * its output reaches a reader as a provenance `detail`, and the printed form
2413
+ * of a type whose declaring module the use site does not import is
2414
+ * `import("/abs/path").Name`. The bar for a detail is "one scrubbed sentence,
2415
+ * never an absolute path", so an anonymous branch is described rather than
2416
+ * printed.
2417
+ */
2418
+ pathFreeTypeLabel(type) {
2419
+ const name = (type.getSymbol() ?? type.getAliasSymbol())?.getName();
2420
+ if (name && !name.startsWith('__') && name !== 'unknown') {
2421
+ return name;
2422
+ }
2423
+ return 'an unnamed branch';
2424
+ }
2425
+ resolveTargetNode(sourceFile, request) {
2426
+ if (request.span_start !== undefined && request.span_end !== undefined) {
2427
+ return this.findNodeAtSpan(sourceFile, request.span_start, request.span_end);
2428
+ }
2429
+ if (request.expression_text) {
2430
+ return this.findNodeByText(sourceFile, request.expression_text, request.expression_line);
2431
+ }
2432
+ return undefined;
2433
+ }
2434
+ /**
2435
+ * Resolve to a CallExpression using the best available locator.
2436
+ */
2437
+ resolveTargetCallExpression(sourceFile, request) {
2438
+ if (request.span_start !== undefined && request.span_end !== undefined) {
2439
+ return this.findCallExpressionAtSpan(sourceFile, request.span_start, request.span_end);
2440
+ }
2441
+ if (request.expression_text) {
2442
+ return this.findCallExpressionByText(sourceFile, request.expression_text, request.expression_line);
2443
+ }
2444
+ return undefined;
2445
+ }
2446
+ /**
2447
+ * Resolve to a containing function using the best available locator.
2448
+ */
2449
+ resolveContainingFunction(sourceFile, request) {
2450
+ if (request.span_start !== undefined && request.span_end !== undefined) {
2451
+ return this.findContainingFunctionBySpan(sourceFile, request.span_start, request.span_end);
2452
+ }
2453
+ if (request.expression_text) {
2454
+ const node = this.findNodeByText(sourceFile, request.expression_text, request.expression_line);
2455
+ if (!node)
2456
+ return undefined;
2457
+ return this.findContainingFunctionForNode(node);
2458
+ }
2459
+ // Signature requests carry only the function's start line (FunctionDefinition
2460
+ // has no byte span). Fall back to locating the function by that line.
2461
+ return this.findFunctionByLine(sourceFile, request.line_number);
2462
+ }
2463
+ /**
2464
+ * Find the function whose declaration starts at (or within a couple of lines
2465
+ * of) the given line. Used for signature inference, where the only locator is
2466
+ * the function's start line as recorded by the scanner. Ties break toward the
2467
+ * innermost (smallest) function. Returns undefined if nothing is close enough,
2468
+ * to avoid binding to an unrelated function.
2469
+ *
2470
+ * The tolerance is one-directional in effect (carrick#766). Looking BACK is
2471
+ * free: a function starting before the anchor is one the anchor sits inside
2472
+ * or just after, which is what an anchor recorded on a signature or body line
2473
+ * means. Looking FORWARD is how the anchor reaches a handler that starts a
2474
+ * line or two into the registration or binding it names — and it is also how,
2475
+ * unguarded, an anchor landing on a statement that declares no function
2476
+ * (`export { action, loader };`) was answered by the NEXT declaration in the
2477
+ * file. That answer is silent: nothing about it says it came from a
2478
+ * neighbouring helper.
2479
+ *
2480
+ * So a forward candidate must be one the anchor line's own statement leads
2481
+ * to: rejected when a statement begins at or after the anchor line and does
2482
+ * not contain it. Nothing but trivia may sit between the anchor and the
2483
+ * function it is taken to name. With no candidate left the caller abstains,
2484
+ * which is the honest answer and the one a consumer check can act on.
2485
+ */
2486
+ findFunctionByLine(sourceFile, line) {
2487
+ const LINE_TOLERANCE = 2;
2488
+ const functions = [];
2489
+ /** Statements opening inside the forward window, in source order. */
2490
+ const windowStatements = [];
2491
+ for (const node of sourceFile.getDescendants()) {
2492
+ if (Node.isFunctionDeclaration(node) ||
2493
+ Node.isArrowFunction(node) ||
2494
+ Node.isFunctionExpression(node) ||
2495
+ Node.isMethodDeclaration(node)) {
2496
+ functions.push(node);
2497
+ }
2498
+ if (Node.isStatement(node)) {
2499
+ const start = node.getStartLineNumber();
2500
+ if (start >= line && start <= line + LINE_TOLERANCE) {
2501
+ windowStatements.push(node);
2502
+ }
2503
+ }
2504
+ }
2505
+ const separatedFromAnchor = (fn) => windowStatements.some((statement) => statement.getStartLineNumber() < fn.getStartLineNumber() &&
2506
+ !(statement.getStart() <= fn.getStart() && statement.getEnd() >= fn.getEnd()));
2507
+ let best;
2508
+ let bestDelta = Infinity;
2509
+ for (const fn of functions) {
2510
+ const delta = Math.abs(fn.getStartLineNumber() - line);
2511
+ if (delta > LINE_TOLERANCE)
2512
+ continue;
2513
+ if (fn.getStartLineNumber() > line && separatedFromAnchor(fn))
2514
+ continue;
2515
+ const isCloser = delta < bestDelta;
2516
+ const isInnermostTie = delta === bestDelta &&
2517
+ best !== undefined &&
2518
+ fn.getEnd() - fn.getStart() < best.getEnd() - best.getStart();
2519
+ if (isCloser || isInnermostTie) {
2520
+ best = fn;
2521
+ bestDelta = delta;
2522
+ }
2523
+ }
2524
+ return best;
2525
+ }
2526
+ /**
2527
+ * Walk up from a node to find its innermost containing function.
2528
+ */
2529
+ findContainingFunctionForNode(node) {
2530
+ return node.getFirstAncestor((n) => Node.isFunctionDeclaration(n) ||
2531
+ Node.isArrowFunction(n) ||
2532
+ Node.isFunctionExpression(n) ||
2533
+ Node.isMethodDeclaration(n));
2534
+ }
2535
+ // ===========================================================================
2536
+ // Route-Registration Handler Resolution
2537
+ // ===========================================================================
2538
+ /**
2539
+ * Follow a LINE-ONLY route-registration anchor to its handler function. The
2540
+ * scanner sends a bare line number (no span/text) for a named-handler
2541
+ * registration whose handler is declared away from the registration line
2542
+ * (`InferLocator::Line`). Find a registration node — a call or object literal
2543
+ * — that STARTS on that line and whose handler resolves, and return it.
2544
+ * Structural (call with a function-typed arg, or handler-shaped object-literal
2545
+ * property), no framework name-lists. Used only when the primary locator did
2546
+ * not resolve a node, so no existing path changes.
2547
+ */
2548
+ handlerAtLine(sourceFile, line) {
2549
+ return this.registrationAtLine(sourceFile, line)?.handler;
2550
+ }
2551
+ /**
2552
+ * The route registration on `line` together with the handler it references.
2553
+ *
2554
+ * `handlerAtLine` returns only the handler, but the route's DECLARED contract
2555
+ * (a validation-schema object passed alongside the handler) lives on the
2556
+ * registration node itself, so the schema anchors need both halves.
2557
+ */
2558
+ registrationAtLine(sourceFile, line) {
2559
+ for (const node of sourceFile.getDescendants()) {
2560
+ if ((Node.isCallExpression(node) ||
2561
+ Node.isObjectLiteralExpression(node)) &&
2562
+ node.getStartLineNumber() === line) {
2563
+ const handler = this.resolveRegisteredHandler(node);
2564
+ if (handler) {
2565
+ return { registration: node, handler };
2566
+ }
2567
+ }
2568
+ }
2569
+ return undefined;
2570
+ }
2571
+ /**
2572
+ * Follow a route-registration locator ONE indirection to the handler function.
2573
+ *
2574
+ * The scanner points the infer request at the registration site, but the
2575
+ * type-bearing expression (the handler's return, or its first request read)
2576
+ * lives inside the handler function, which the registration only *references*.
2577
+ * This resolves that reference to the handler's `FunctionLike` node, purely
2578
+ * structurally, in three shapes:
2579
+ *
2580
+ * 1. INLINE — the node is (or is inside) a call whose arguments include an
2581
+ * arrow/function expression: `app.post('/x', async (c) => { … })`. The
2582
+ * inline function IS the handler.
2583
+ * 2. OBJECT-LITERAL HANDLER PROPERTY — the node is (or is inside) an object
2584
+ * literal with a `handler`-like property whose value is either an inline
2585
+ * function or an identifier bound to a function:
2586
+ * `{ method, path, handler: healthCheckHandler }`. Resolve that value.
2587
+ * 3. IDENTIFIER ARGUMENT — the node is (or is inside) a call whose 2nd+
2588
+ * argument is an identifier bound to a function:
2589
+ * `app.get('/x', healthCheckHandler)`. Resolve that identifier.
2590
+ *
2591
+ * No framework name, method name, or property name beyond the generic
2592
+ * `handler`-shaped key is hardcoded — the match is on node SHAPE (call with a
2593
+ * function-typed arg / object literal with a function-valued property), which
2594
+ * is what "route registration" structurally is across Express, Fastify, Hono,
2595
+ * Koa, a hand-rolled registry array, etc. Returns undefined when the locator
2596
+ * is not a registration shape, so every existing non-registration path is
2597
+ * untouched.
2598
+ */
2599
+ resolveRegisteredHandler(node) {
2600
+ // 1 & 3: the node is, or is inside, a route-registration CALL.
2601
+ const call = Node.isCallExpression(node)
2602
+ ? node
2603
+ : node.getFirstAncestorByKind(SyntaxKind.CallExpression);
2604
+ if (call) {
2605
+ const fromCall = this.handlerFromRegistrationCall(call);
2606
+ if (fromCall) {
2607
+ return fromCall;
2608
+ }
2609
+ }
2610
+ // 2: the node is, or is inside, an object literal carrying the handler.
2611
+ const objectLiteral = Node.isObjectLiteralExpression(node)
2612
+ ? node
2613
+ : node.getFirstAncestorByKind(SyntaxKind.ObjectLiteralExpression);
2614
+ if (objectLiteral) {
2615
+ const fromObject = this.handlerFromObjectLiteral(objectLiteral);
2616
+ if (fromObject) {
2617
+ return fromObject;
2618
+ }
2619
+ }
2620
+ return undefined;
2621
+ }
2622
+ /**
2623
+ * Request-path gate for handler-following: return the handler function ONLY
2624
+ * when the located node is ITSELF (after expression-unwrap) the registration
2625
+ * — a call whose arguments include a handler function, or a route-registry
2626
+ * object literal with a handler property. Unlike `resolveRegisteredHandler`,
2627
+ * this does NOT walk up to an ancestor call, so a consumer's inner
2628
+ * `JSON.stringify(payload)` (nested inside a `fetch(...)` that may carry other
2629
+ * callbacks) is never mistaken for a registration and the existing consumer
2630
+ * request paths stay exactly as they were.
2631
+ */
2632
+ registrationHandlerAt(located) {
2633
+ const node = this.unwrapExpressionNode(located);
2634
+ if (Node.isCallExpression(node)) {
2635
+ return this.handlerFromRegistrationCall(node);
2636
+ }
2637
+ if (Node.isObjectLiteralExpression(node)) {
2638
+ return this.handlerFromObjectLiteral(node);
2639
+ }
2640
+ return undefined;
2641
+ }
2642
+ /**
2643
+ * The handler function referenced by a route-registration call: the first
2644
+ * argument that is an inline function, or an identifier bound to a function
2645
+ * (typically the 2nd+ arg — the path literal is not function-valued, so it is
2646
+ * skipped naturally). No callee-name check: a call whose argument resolves to
2647
+ * a function is structurally a registration regardless of the framework.
2648
+ */
2649
+ handlerFromRegistrationCall(call) {
2650
+ for (const arg of call.getArguments()) {
2651
+ const handler = this.asHandlerFunction(arg);
2652
+ if (handler) {
2653
+ return handler;
2654
+ }
2655
+ }
2656
+ return undefined;
2657
+ }
2658
+ /**
2659
+ * The handler function carried by a route-registry object literal. Matches a
2660
+ * property whose name is `handler`-shaped (case-insensitive `handler`) and
2661
+ * whose value is an inline function or an identifier bound to a function.
2662
+ */
2663
+ handlerFromObjectLiteral(objectLiteral) {
2664
+ if (!Node.isObjectLiteralExpression(objectLiteral)) {
2665
+ return undefined;
2666
+ }
2667
+ for (const prop of objectLiteral.getProperties()) {
2668
+ if (!Node.isPropertyAssignment(prop)) {
2669
+ continue;
2670
+ }
2671
+ const name = prop.getName().replace(/['"]/g, '').toLowerCase();
2672
+ if (name !== 'handler') {
2673
+ continue;
2674
+ }
2675
+ const initializer = prop.getInitializer();
2676
+ const handler = initializer ? this.asHandlerFunction(initializer) : undefined;
2677
+ if (handler) {
2678
+ return handler;
2679
+ }
2680
+ }
2681
+ return undefined;
2682
+ }
2683
+ /**
2684
+ * Resolve a node to a handler `FunctionLike`: an inline arrow/function
2685
+ * expression is returned directly; an identifier is followed to its binding
2686
+ * declaration (via the compiler's definition nodes) and returned when that
2687
+ * declaration is — or initializes to — a function. Anything else (a path
2688
+ * literal, an object, a non-function binding) yields undefined.
2689
+ */
2690
+ asHandlerFunction(node) {
2691
+ const expr = this.unwrapExpressionNode(node);
2692
+ if (Node.isArrowFunction(expr) || Node.isFunctionExpression(expr)) {
2693
+ return expr;
2694
+ }
2695
+ if (Node.isIdentifier(expr)) {
2696
+ for (const def of expr.getDefinitionNodes()) {
2697
+ const func = this.functionFromDeclaration(def);
2698
+ if (func) {
2699
+ return func;
2700
+ }
2701
+ }
2702
+ }
2703
+ return undefined;
2704
+ }
2705
+ /**
2706
+ * Extract a `FunctionLike` from a declaration node the compiler resolved an
2707
+ * identifier to: a function declaration is itself the function; a variable
2708
+ * declaration / binding whose initializer is an inline function yields that
2709
+ * function (`const h = async () => { … }`). Import/re-export shims are walked
2710
+ * by ts-morph's `getDefinitionNodes`, so no manual import chasing is needed.
2711
+ */
2712
+ functionFromDeclaration(decl) {
2713
+ if (Node.isFunctionDeclaration(decl)) {
2714
+ return decl;
2715
+ }
2716
+ if (Node.isArrowFunction(decl) || Node.isFunctionExpression(decl)) {
2717
+ return decl;
2718
+ }
2719
+ // `getDefinitionNodes()` may return the name identifier of a `const h = …`
2720
+ // binding rather than the declaration; walk to the variable declaration.
2721
+ const varDecl = Node.isVariableDeclaration(decl)
2722
+ ? decl
2723
+ : decl.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
2724
+ if (varDecl) {
2725
+ const initializer = varDecl.getInitializer();
2726
+ if (initializer) {
2727
+ const inner = this.unwrapExpressionNode(initializer);
2728
+ if (Node.isArrowFunction(inner) || Node.isFunctionExpression(inner)) {
2729
+ return inner;
2730
+ }
2731
+ }
2732
+ }
2733
+ return undefined;
2734
+ }
2735
+ /**
2736
+ * Scan a handler function body for the FIRST request-body read that carries a
2737
+ * type, and return that type's structural text — or null when the body holds
2738
+ * no typed request read.
2739
+ *
2740
+ * "Typed request read" is matched on SHAPE, never on a callee/method name:
2741
+ *
2742
+ * A. a CALL that carries an explicit type argument — `c.req.json<T>()`,
2743
+ * `parseBody<T>(req)`; the first type argument is `T`; or
2744
+ * B. an expression with an explicit type annotation or cast in its immediate
2745
+ * binding context — `const b: T = …`, `req.body as T`.
2746
+ *
2747
+ * The body is walked in source order (`forEachDescendant` is a pre-order
2748
+ * traversal), so the FIRST such read wins, mirroring "the request type lives
2749
+ * at the first body read" without assuming which framework produced it.
2750
+ * Returns null (not a spurious type) for a genuinely payload-less handler.
2751
+ */
2752
+ inferRequestReadFromHandler(func) {
2753
+ const body = func.getBody();
2754
+ if (!body) {
2755
+ return null;
2756
+ }
2757
+ let found = null;
2758
+ body.forEachDescendant((descendant, traversal) => {
2759
+ if (found !== null) {
2760
+ traversal.stop();
2761
+ return;
2762
+ }
2763
+ // A. call with an explicit type argument: `c.req.json<TrackRequest>()`.
2764
+ if (Node.isCallExpression(descendant)) {
2765
+ const typeArgs = descendant.getTypeArguments();
2766
+ if (typeArgs.length > 0) {
2767
+ const resolved = this.structuralTextFromTypeNode(typeArgs[0]);
2768
+ if (resolved) {
2769
+ found = resolved;
2770
+ traversal.stop();
2771
+ return;
2772
+ }
2773
+ }
2774
+ }
2775
+ // B. an `as T` cast (`req.body as TrackRequest`) or a typed binding
2776
+ // (`const b: TrackRequest = …`). Read the annotation/cast target.
2777
+ if (Node.isAsExpression(descendant)) {
2778
+ const typeNode = descendant.getTypeNode();
2779
+ const resolved = typeNode
2780
+ ? this.structuralTextFromTypeNode(typeNode)
2781
+ : null;
2782
+ if (resolved) {
2783
+ found = resolved;
2784
+ traversal.stop();
2785
+ return;
2786
+ }
2787
+ }
2788
+ });
2789
+ return found;
2790
+ }
2791
+ /**
2792
+ * Resolve a type-annotation/type-argument node to fully-structural text,
2793
+ * dropping any `Promise<…>` wrapper (`c.req.json<T>()` returns `Promise<T>`,
2794
+ * but the annotation node is `T` directly; the guard is harmless either way).
2795
+ * Returns null when the node resolves to a useless/library type that carries
2796
+ * no member shape, so the caller keeps scanning rather than locking onto
2797
+ * `any`/`unknown`.
2798
+ */
2799
+ structuralTextFromTypeNode(typeNode) {
2800
+ try {
2801
+ const resolved = this.unwrapPromiseType(typeNode.getType());
2802
+ const bare = typeText(resolved, typeNode);
2803
+ if (this.isUselessType(bare)) {
2804
+ return null;
2805
+ }
2806
+ return this.expandResolvedTypeStructural(resolved, bare);
2807
+ }
2808
+ catch {
2809
+ return null;
2810
+ }
2811
+ }
2812
+ /**
2813
+ * Same reduction as `structuralTextFromTypeNode`, but starting from a resolved
2814
+ * `Type` rather than a syntax node. `at` only supplies the scope the type is
2815
+ * rendered in.
2816
+ */
2817
+ structuralTextFromType(type, at) {
2818
+ try {
2819
+ const resolved = this.unwrapPromiseType(type);
2820
+ const bare = typeText(resolved, at);
2821
+ if (this.isUselessType(bare)) {
2822
+ return null;
2823
+ }
2824
+ const expanded = this.expandResolvedTypeStructural(resolved, bare);
2825
+ return this.isUselessType(expanded) ? null : expanded;
2826
+ }
2827
+ catch {
2828
+ return null;
2829
+ }
2830
+ }
2831
+ // ===========================================================================
2832
+ // Route-contract anchors (carrick#528)
2833
+ // ===========================================================================
2834
+ //
2835
+ // A route registration can DECLARE its request/response contract in two
2836
+ // places the payload locators never reach, because neither is an expression
2837
+ // the handler evaluates:
2838
+ //
2839
+ // (a) the handler's own parameter annotation — a request parameter typed
2840
+ // `T<{ Body: CreateWidget }>` exposes the contract as the `body` member
2841
+ // of that parameter's type;
2842
+ // (b) a validation-schema object passed alongside the handler — `{ schema:
2843
+ // { body: ref('CreateWidget'), response: { 200: ref('Widget') } } }` —
2844
+ // whose entries reference schema VALUES that carry their parsed output
2845
+ // type.
2846
+ //
2847
+ // Both are read structurally: (a) is "the `body` member of a parameter's
2848
+ // type", (b) is "a `schema` property on a registration argument, whose
2849
+ // entries resolve to a value whose `parse` returns the contract". No
2850
+ // framework, library, or method-name list is involved — a framework whose
2851
+ // request object exposes `body` and whose route options carry `schema` is
2852
+ // read the same way regardless of which one it is.
2853
+ /**
2854
+ * The route registration a request's locator points AT, or undefined when it
2855
+ * points at anything else.
2856
+ *
2857
+ * Deliberately strict: the located node must itself be the registration (a
2858
+ * call registering a handler, or a route-registry object literal), never an
2859
+ * ancestor of it, so an expression inside a handler is not mistaken for the
2860
+ * route it belongs to. A locator that resolves nothing falls back to the
2861
+ * registration on the request's line.
2862
+ */
2863
+ routeRegistrationForRequest(sourceFile, request) {
2864
+ const located = this.resolveTargetNode(sourceFile, request);
2865
+ if (located) {
2866
+ const node = this.unwrapExpressionNode(located);
2867
+ return this.registrationHandlerAt(node) ? node : undefined;
2868
+ }
2869
+ return this.registrationAtLine(sourceFile, request.line_number)?.registration;
2870
+ }
2871
+ /**
2872
+ * Anchor (b) on the response side: the success-status contract declared in the
2873
+ * registration's schema, as an `InferredType` ready to return. Null when the
2874
+ * registration declares no resolvable response schema, so the caller falls
2875
+ * back to following the handler's return.
2876
+ */
2877
+ declaredResponseInferredType(request, registration) {
2878
+ const declared = this.routeSchemaContractText(registration, 'response');
2879
+ if (!declared) {
2880
+ return null;
2881
+ }
2882
+ this.log(`Route registration at ${request.file_path}:${request.line_number} declares its ` +
2883
+ 'response schema; using the declared contract');
2884
+ return this.createInferredType(request, declared, true, this.getNodeLocation(registration), undefined, undefined);
2885
+ }
2886
+ /**
2887
+ * The request contract of a route registration, in anchor order: the
2888
+ * handler's parameter annotation (a), the registration's declared schema (b),
2889
+ * then the first typed request read in the handler body (the pre-existing
2890
+ * behaviour). The first anchor yielding a non-useless type wins; null means
2891
+ * the route declares its request nowhere we can read.
2892
+ */
2893
+ requestContractFromRegistration(registration, handler) {
2894
+ return (this.requestBodyFromHandlerParams(handler) ??
2895
+ this.routeSchemaContractText(registration, 'body') ??
2896
+ this.inferRequestReadFromHandler(handler));
2897
+ }
2898
+ /**
2899
+ * Anchor (a): the request contract declared on the handler's own signature.
2900
+ *
2901
+ * A typed request parameter (`request: CreateWidgetRequest` where that alias
2902
+ * resolves to a request type parameterised with the body shape) exposes the
2903
+ * contract as the `body` member of the parameter's type. Parameters are
2904
+ * scanned in order and the first one carrying a non-useless `body` member
2905
+ * wins; a reply/response parameter has no `body` member, and an unparameterised
2906
+ * request type resolves `body` to `unknown`/`any`, which the useless-type
2907
+ * guard rejects. So a handler that declares nothing yields null and the next
2908
+ * anchor runs.
2909
+ */
2910
+ requestBodyFromHandlerParams(func) {
2911
+ for (const param of func.getParameters()) {
2912
+ let paramType;
2913
+ try {
2914
+ paramType = param.getType();
2915
+ }
2916
+ catch {
2917
+ continue;
2918
+ }
2919
+ const bodySymbol = paramType.getProperty('body');
2920
+ if (!bodySymbol) {
2921
+ continue;
2922
+ }
2923
+ let bodyType;
2924
+ try {
2925
+ bodyType = bodySymbol.getTypeAtLocation(param);
2926
+ }
2927
+ catch {
2928
+ continue;
2929
+ }
2930
+ const text = this.structuralTextFromType(bodyType, param);
2931
+ if (text) {
2932
+ return text;
2933
+ }
2934
+ }
2935
+ return null;
2936
+ }
2937
+ /**
2938
+ * Anchor (b): the contract declared in the route's validation schema.
2939
+ *
2940
+ * `part` is `'body'` for the request contract and `'response'` for the
2941
+ * response contract; a `response` entry keyed by status code resolves to its
2942
+ * success entry. Returns null when the registration carries no schema, when
2943
+ * the entry references something whose parsed output cannot be resolved, or
2944
+ * when the schema is a plain JSON-schema literal (whose own object type is
2945
+ * the JSON-Schema document, not the payload — emitting that would be worse
2946
+ * than abstaining).
2947
+ */
2948
+ routeSchemaContractText(registration, part) {
2949
+ const schemaObject = this.routeSchemaObject(registration);
2950
+ if (!schemaObject) {
2951
+ return null;
2952
+ }
2953
+ const entry = schemaObject.getProperty(part);
2954
+ if (!entry || !Node.isPropertyAssignment(entry)) {
2955
+ return null;
2956
+ }
2957
+ const initializer = entry.getInitializer();
2958
+ if (!initializer) {
2959
+ return null;
2960
+ }
2961
+ const value = part === 'response'
2962
+ ? (this.successStatusEntry(initializer) ?? initializer)
2963
+ : initializer;
2964
+ return this.schemaOutputTypeText(value);
2965
+ }
2966
+ /**
2967
+ * The `schema` object literal carried by a route registration: scan the
2968
+ * registration call's arguments (or the registry object literal itself) for a
2969
+ * `schema` property whose value is an object literal.
2970
+ */
2971
+ routeSchemaObject(registration) {
2972
+ const candidates = [];
2973
+ if (Node.isCallExpression(registration)) {
2974
+ candidates.push(...registration.getArguments());
2975
+ }
2976
+ else if (Node.isObjectLiteralExpression(registration)) {
2977
+ candidates.push(registration);
2978
+ }
2979
+ for (const candidate of candidates) {
2980
+ if (!Node.isObjectLiteralExpression(candidate)) {
2981
+ continue;
2982
+ }
2983
+ const schemaProp = candidate.getProperty('schema');
2984
+ if (!schemaProp || !Node.isPropertyAssignment(schemaProp)) {
2985
+ continue;
2986
+ }
2987
+ const schemaValue = schemaProp.getInitializer();
2988
+ if (schemaValue && Node.isObjectLiteralExpression(schemaValue)) {
2989
+ return schemaValue;
2990
+ }
2991
+ }
2992
+ return undefined;
2993
+ }
2994
+ /**
2995
+ * The success entry of a status-keyed response map (`{ 200: …, 4xx: … }`):
2996
+ * exact `200` when present, else the lowest 2xx key. Returns undefined when
2997
+ * the value is not a status-keyed map, so the caller reads it as the schema
2998
+ * itself.
2999
+ */
3000
+ successStatusEntry(value) {
3001
+ if (!Node.isObjectLiteralExpression(value)) {
3002
+ return undefined;
3003
+ }
3004
+ let best;
3005
+ for (const prop of value.getProperties()) {
3006
+ if (!Node.isPropertyAssignment(prop)) {
3007
+ continue;
3008
+ }
3009
+ const name = prop.getName().replace(/['"`]/g, '').toLowerCase();
3010
+ // `2xx` is a status RANGE key; treat it as the bottom of its range.
3011
+ const code = /^\d{3}$/.test(name)
3012
+ ? Number(name)
3013
+ : /^\dxx$/.test(name)
3014
+ ? Number(name[0]) * 100
3015
+ : NaN;
3016
+ if (!Number.isFinite(code) || code < 200 || code > 299) {
3017
+ continue;
3018
+ }
3019
+ const initializer = prop.getInitializer();
3020
+ if (!initializer) {
3021
+ continue;
3022
+ }
3023
+ if (!best || code < best.code) {
3024
+ best = { code, node: initializer };
3025
+ }
3026
+ }
3027
+ return best?.node;
3028
+ }
3029
+ /**
3030
+ * The payload type a schema entry declares.
3031
+ *
3032
+ * The entry is either a REFERENCE call — `ref('CreateWidget')`, a
3033
+ * name-to-schema indirection whose registry is the argument the ref function
3034
+ * was built from — or the schema value itself. Either way the payload is the
3035
+ * schema's parsed output: the return type of its `parse` method (the shape
3036
+ * every schema value exposes as its public validate-and-return API), falling
3037
+ * back to a declared `_output` member. A value with neither is not a schema
3038
+ * and yields null.
3039
+ */
3040
+ schemaOutputTypeText(value) {
3041
+ const node = this.unwrapExpressionNode(value);
3042
+ let schemaType;
3043
+ if (Node.isCallExpression(node)) {
3044
+ schemaType = this.registrySchemaType(node);
3045
+ }
3046
+ if (!schemaType) {
3047
+ try {
3048
+ schemaType = node.getType();
3049
+ }
3050
+ catch {
3051
+ return null;
3052
+ }
3053
+ }
3054
+ const output = this.schemaOutputType(schemaType, node);
3055
+ if (!output) {
3056
+ this.log(`Route schema entry at ${this.getNodeLocation(node).file_path}:${this.getNodeLocation(node).start_line} declares no resolvable parsed output (schema library types unavailable, ` +
3057
+ 'or a plain JSON-Schema literal); leaving unresolved');
3058
+ return null;
3059
+ }
3060
+ return this.structuralTextFromType(output, node);
3061
+ }
3062
+ /**
3063
+ * Follow a schema REFERENCE call (`ref('CreateWidget')`) to the schema value
3064
+ * it names.
3065
+ *
3066
+ * The ref function is produced by a registry-building call — `const { $ref } =
3067
+ * build({ CreateWidget, Widget })` — so the registry is that call's first
3068
+ * argument, and the referenced key is a property of it. Resolve the callee to
3069
+ * its declaration, walk to the variable declaration it is bound in, and read
3070
+ * the key off the builder argument's type. Returns undefined for anything
3071
+ * that is not this shape.
3072
+ */
3073
+ registrySchemaType(call) {
3074
+ const args = call.getArguments();
3075
+ if (args.length === 0) {
3076
+ return undefined;
3077
+ }
3078
+ const keyArg = this.unwrapExpressionNode(args[0]);
3079
+ if (!Node.isStringLiteral(keyArg)) {
3080
+ return undefined;
3081
+ }
3082
+ const key = keyArg.getLiteralValue();
3083
+ const callee = call.getExpression();
3084
+ const identifier = Node.isIdentifier(callee)
3085
+ ? callee
3086
+ : Node.isPropertyAccessExpression(callee)
3087
+ ? callee.getNameNode()
3088
+ : undefined;
3089
+ if (!identifier || !Node.isIdentifier(identifier)) {
3090
+ return undefined;
3091
+ }
3092
+ for (const def of identifier.getDefinitionNodes()) {
3093
+ const varDecl = Node.isVariableDeclaration(def)
3094
+ ? def
3095
+ : def.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
3096
+ const initializer = varDecl?.getInitializer();
3097
+ if (!initializer) {
3098
+ continue;
3099
+ }
3100
+ const builderCall = this.unwrapExpressionNode(initializer);
3101
+ if (!Node.isCallExpression(builderCall)) {
3102
+ continue;
3103
+ }
3104
+ const registryArg = builderCall.getArguments()[0];
3105
+ if (!registryArg) {
3106
+ continue;
3107
+ }
3108
+ let registryType;
3109
+ try {
3110
+ registryType = registryArg.getType();
3111
+ }
3112
+ catch {
3113
+ continue;
3114
+ }
3115
+ const schemaSymbol = registryType.getProperty(key);
3116
+ if (!schemaSymbol) {
3117
+ continue;
3118
+ }
3119
+ try {
3120
+ return schemaSymbol.getTypeAtLocation(registryArg);
3121
+ }
3122
+ catch {
3123
+ continue;
3124
+ }
3125
+ }
3126
+ return undefined;
3127
+ }
3128
+ /**
3129
+ * The parsed output type of a schema value: the return type of its `parse`
3130
+ * method, else a declared `_output` member. Returns undefined when neither
3131
+ * carries a usable type — including when the schema library's own types are
3132
+ * unavailable (an uninstalled dependency resolves the schema to `any`), which
3133
+ * must abstain rather than publish `any` as a contract.
3134
+ */
3135
+ schemaOutputType(schemaType, at) {
3136
+ const usable = (type) => {
3137
+ if (!type) {
3138
+ return undefined;
3139
+ }
3140
+ return this.isUselessType(typeText(type, at)) ? undefined : type;
3141
+ };
3142
+ const parseSymbol = schemaType.getProperty('parse');
3143
+ if (parseSymbol) {
3144
+ try {
3145
+ const signatures = parseSymbol.getTypeAtLocation(at).getCallSignatures();
3146
+ const returned = usable(signatures[0]?.getReturnType());
3147
+ if (returned) {
3148
+ return returned;
3149
+ }
3150
+ }
3151
+ catch {
3152
+ // fall through to the declared-output member
3153
+ }
3154
+ }
3155
+ const outputSymbol = schemaType.getProperty('_output');
3156
+ if (outputSymbol) {
3157
+ try {
3158
+ return usable(outputSymbol.getTypeAtLocation(at));
3159
+ }
3160
+ catch {
3161
+ return undefined;
3162
+ }
3163
+ }
3164
+ return undefined;
3165
+ }
3166
+ /**
3167
+ * Find a node by matching expression text near a target line.
3168
+ *
3169
+ * Strategy:
3170
+ * 1. Get all descendant nodes within [lineNumber - searchRadius, lineNumber + searchRadius]
3171
+ * 2. Normalize whitespace for comparison
3172
+ * 3. Try exact match first (after normalization), prefer closest to target line
3173
+ * 4. Fall back to substring match (LLM text in node text, or vice versa)
3174
+ * 5. Return smallest matching node closest to target line
3175
+ */
3176
+ findNodeByText(sourceFile, expressionText, lineNumber, searchRadius = 5) {
3177
+ const allNodes = sourceFile.getDescendants().filter((node) => {
3178
+ if (Node.isSourceFile(node))
3179
+ return false;
3180
+ if (node.getKind() === SyntaxKind.SyntaxList)
3181
+ return false;
3182
+ return true;
3183
+ });
3184
+ return this.matchByText(allNodes, expressionText, lineNumber, searchRadius);
3185
+ }
3186
+ /**
3187
+ * Find a CallExpression by matching expression text near a target line.
3188
+ */
3189
+ findCallExpressionByText(sourceFile, expressionText, lineNumber, searchRadius = 5) {
3190
+ const callExpressions = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
3191
+ return this.matchByText(callExpressions, expressionText, lineNumber, searchRadius);
3192
+ }
3193
+ /**
3194
+ * Shared text-matching logic for node resolution.
3195
+ * Normalizes whitespace once per candidate, then tries exact match,
3196
+ * then substring match (preferring containing matches).
3197
+ */
3198
+ matchByText(nodes, expressionText, lineNumber, searchRadius = 5) {
3199
+ const normalizedTarget = this.normalizeWhitespace(expressionText);
3200
+ if (!normalizedTarget)
3201
+ return undefined;
3202
+ // Filter to nodes within the search window and pre-compute normalized text
3203
+ const candidates = (lineNumber
3204
+ ? nodes.filter((node) => {
3205
+ const nodeLine = node.getStartLineNumber();
3206
+ return nodeLine >= lineNumber - searchRadius && nodeLine <= lineNumber + searchRadius;
3207
+ })
3208
+ : nodes).map((node) => ({ node, text: this.normalizeWhitespace(node.getText()) }));
3209
+ if (candidates.length === 0)
3210
+ return undefined;
3211
+ // Try exact match (normalized whitespace)
3212
+ const exactMatches = candidates.filter((c) => c.text === normalizedTarget);
3213
+ if (exactMatches.length > 0) {
3214
+ return this.pickBestMatch(exactMatches.map((c) => c.node), lineNumber);
3215
+ }
3216
+ // A bare identifier target must match a node exactly: substring matching
3217
+ // would bind `users` to `usersCsv` (or to any enclosing node that merely
3218
+ // contains the identifier somewhere) and report a confidently wrong type.
3219
+ // Failing here is correct — the caller records an error and the alias
3220
+ // pads to `unknown` downstream.
3221
+ if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(normalizedTarget)) {
3222
+ return undefined;
3223
+ }
3224
+ // Fall back to substring match
3225
+ // For the reverse direction (target contains node text), require a minimum node text
3226
+ // length to avoid matching tiny identifiers like "res" or "body" too broadly.
3227
+ // Also require the node to cover at least half of the target: the reverse
3228
+ // branch exists for a locator with minor syntactic drift around the true
3229
+ // node, not for binding to a fragment. Without the floor, a 100+ char
3230
+ // locator whose exact match failed would bind to the smallest embedded
3231
+ // sub-expression (pickBestMatch prefers small nodes), e.g. the 8-char
3232
+ // literal "active" inside a res.json object payload (#335).
3233
+ const MIN_REVERSE_MATCH_LEN = 8;
3234
+ const MIN_REVERSE_MATCH_COVERAGE = 0.5;
3235
+ const substringMatches = candidates.filter((c) => c.text.includes(normalizedTarget) ||
3236
+ (c.text.length >= MIN_REVERSE_MATCH_LEN &&
3237
+ c.text.length >= normalizedTarget.length * MIN_REVERSE_MATCH_COVERAGE &&
3238
+ normalizedTarget.includes(c.text)));
3239
+ if (substringMatches.length > 0) {
3240
+ // Prefer nodes where the LLM text is contained in the node text
3241
+ const containingMatches = substringMatches.filter((c) => c.text.includes(normalizedTarget));
3242
+ if (containingMatches.length > 0) {
3243
+ return this.pickBestMatch(containingMatches.map((c) => c.node), lineNumber);
3244
+ }
3245
+ return this.pickBestMatch(substringMatches.map((c) => c.node), lineNumber);
3246
+ }
3247
+ return undefined;
3248
+ }
3249
+ /**
3250
+ * Pick the best match from a set of candidate nodes:
3251
+ * smallest range, then closest to target line.
3252
+ */
3253
+ pickBestMatch(nodes, targetLine) {
3254
+ return nodes.reduce((best, current) => {
3255
+ const bestRange = best.getEnd() - best.getStart();
3256
+ const currentRange = current.getEnd() - current.getStart();
3257
+ // Prefer smaller nodes
3258
+ if (currentRange !== bestRange) {
3259
+ return currentRange < bestRange ? current : best;
3260
+ }
3261
+ // Tie-break by proximity to target line
3262
+ if (targetLine !== undefined) {
3263
+ const bestDist = Math.abs(best.getStartLineNumber() - targetLine);
3264
+ const currentDist = Math.abs(current.getStartLineNumber() - targetLine);
3265
+ return currentDist < bestDist ? current : best;
3266
+ }
3267
+ return best;
3268
+ });
3269
+ }
3270
+ /**
3271
+ * Normalize whitespace for text comparison:
3272
+ * collapse runs of whitespace into single spaces, trim.
3273
+ *
3274
+ * Also strips trailing commas before `}` `)` `]`: multi-line source
3275
+ * literals carry them but the LLM's single-line locator print does not,
3276
+ * and that one comma used to defeat exact AND containment matching for
3277
+ * the payload and every enclosing node (#335). Applied symmetrically to
3278
+ * node text and target, so both sides compare equal.
3279
+ *
3280
+ * Also strips the space left AFTER `(` `[` `{` by the collapse: a
3281
+ * multi-line call whose arguments start on the next line normalizes to
3282
+ * `f( x)` while the LLM's compact print is `f(x)`, and that one space
3283
+ * defeated exact matching for the call and every enclosing node — the
3284
+ * opening-delimiter mirror of the #335 trailing comma (#336). Symmetric
3285
+ * for the same reason.
3286
+ */
3287
+ normalizeWhitespace(text) {
3288
+ return text
3289
+ .replace(/\s+/g, ' ')
3290
+ .replace(/\s*,?\s*([}\)\]])/g, '$1')
3291
+ .replace(/([\(\[{])\s+/g, '$1')
3292
+ .trim();
3293
+ }
3294
+ /**
3295
+ * Format a human-readable location string for error messages.
3296
+ */
3297
+ formatRequestLocation(request) {
3298
+ return request.expression_text
3299
+ ? `text="${request.expression_text}" line=${request.expression_line ?? '?'}`
3300
+ : `${request.span_start}-${request.span_end}`;
3301
+ }
3302
+ // ===========================================================================
3303
+ // Span-Based Node Lookup (SWC byte offsets)
3304
+ // ===========================================================================
3305
+ findContainingFunctionBySpan(sourceFile, spanStart, spanEnd) {
3306
+ const functions = sourceFile.getDescendants().filter((node) => Node.isFunctionDeclaration(node) ||
3307
+ Node.isArrowFunction(node) ||
3308
+ Node.isFunctionExpression(node) ||
3309
+ Node.isMethodDeclaration(node));
3310
+ const containing = functions.filter((func) => {
3311
+ const start = func.getStart();
3312
+ const end = func.getEnd();
3313
+ return start <= spanStart && spanEnd <= end;
3314
+ });
3315
+ if (containing.length === 0) {
3316
+ return undefined;
3317
+ }
3318
+ // Return innermost function
3319
+ return containing.reduce((innermost, current) => {
3320
+ const innermostRange = innermost.getEnd() - innermost.getStart();
3321
+ const currentRange = current.getEnd() - current.getStart();
3322
+ return currentRange < innermostRange ? current : innermost;
3323
+ });
3324
+ }
3325
+ findNodeAtSpan(sourceFile, spanStart, spanEnd) {
3326
+ const allNodes = sourceFile.getDescendants();
3327
+ const containing = allNodes.filter((node) => {
3328
+ if (Node.isSourceFile(node))
3329
+ return false;
3330
+ // Skip SyntaxList nodes as they have unreliable types
3331
+ if (node.getKind() === SyntaxKind.SyntaxList)
3332
+ return false;
3333
+ const start = node.getStart();
3334
+ const end = node.getEnd();
3335
+ return start <= spanStart && spanEnd <= end;
3336
+ });
3337
+ if (containing.length === 0) {
3338
+ return undefined;
3339
+ }
3340
+ return containing.reduce((best, current) => {
3341
+ const bestRange = best.getEnd() - best.getStart();
3342
+ const currentRange = current.getEnd() - current.getStart();
3343
+ // Prefer exact matches, then smallest containing
3344
+ const bestDelta = Math.abs(bestRange - (spanEnd - spanStart));
3345
+ const currentDelta = Math.abs(currentRange - (spanEnd - spanStart));
3346
+ return currentDelta < bestDelta ? current : best;
3347
+ });
3348
+ }
3349
+ findCallExpressionAtSpan(sourceFile, spanStart, spanEnd) {
3350
+ // Tolerate the SWC span convention (#336): the scanner sends raw SWC
3351
+ // BytePos offsets, which are 1-based (BytePos(1) is the file's first
3352
+ // byte), while ts-morph offsets are 0-based — so an SWC-sourced span
3353
+ // sits one byte past the call on BOTH ends. Under strict containment the
3354
+ // shifted end overshoots the call's own end, the true call is excluded,
3355
+ // and the smallest ENCLOSING call wins: for a data call inside a route
3356
+ // registration that is the whole `router.get("/status", handler)`
3357
+ // expression, anchoring the router type instead of the payload. The
3358
+ // slack admits both conventions; the size-delta pick below still prefers
3359
+ // the call whose extent matches the span, so the exactly-covered inner
3360
+ // call always beats its enclosing registration.
3361
+ //
3362
+ // Both producers of a span request now convert at the request boundary
3363
+ // (carrick#805, carrick#806), so nothing sends the other convention any
3364
+ // more and the slack only widens the candidate set. Removing it is
3365
+ // carrick#823, which wants its own before/after.
3366
+ const SPAN_SLACK = 2;
3367
+ const callExpressions = sourceFile
3368
+ .getDescendantsOfKind(SyntaxKind.CallExpression);
3369
+ const candidates = callExpressions.filter((expr) => {
3370
+ const start = expr.getStart();
3371
+ const end = expr.getEnd();
3372
+ return start - SPAN_SLACK <= spanStart && spanEnd <= end + SPAN_SLACK;
3373
+ });
3374
+ if (candidates.length === 0) {
3375
+ return undefined;
3376
+ }
3377
+ return candidates.reduce((best, current) => {
3378
+ const bestRange = best.getEnd() - best.getStart();
3379
+ const currentRange = current.getEnd() - current.getStart();
3380
+ const bestDelta = Math.abs(bestRange - (spanEnd - spanStart));
3381
+ const currentDelta = Math.abs(currentRange - (spanEnd - spanStart));
3382
+ return currentDelta < bestDelta ? current : best;
3383
+ });
3384
+ }
3385
+ // ===========================================================================
3386
+ // Type Utilities
3387
+ // ===========================================================================
3388
+ unwrapPromise(typeString, type) {
3389
+ // Operate per top-level union member: a naive `^Promise<(.+)>$` regex
3390
+ // matches the WHOLE of `Promise<A> | Promise<B>` and produces the
3391
+ // mangled capture `A> | Promise<B`.
3392
+ const parts = this.splitTopLevelUnion(typeString);
3393
+ const unwrapped = parts.map((part) => this.unwrapPromiseText(part));
3394
+ if (unwrapped.some((u, i) => u !== parts[i])) {
3395
+ return [...new Set(unwrapped)].join(' | ');
3396
+ }
3397
+ // Handle nested Promise via type arguments
3398
+ const typeArguments = type.getTypeArguments();
3399
+ if (typeArguments.length > 0 &&
3400
+ (typeString.startsWith('Promise<') || typeString.startsWith('PromiseLike<'))) {
3401
+ return typeText(typeArguments[0]);
3402
+ }
3403
+ return typeString;
3404
+ }
3405
+ /**
3406
+ * Unwrap a single `Promise<...>` / `PromiseLike<...>` type string, only when
3407
+ * the inner text is bracket-balanced (so `Promise<A> | B` is left alone for
3408
+ * the caller's union handling rather than mangled).
3409
+ */
3410
+ unwrapPromiseText(part) {
3411
+ const prefix = part.startsWith('Promise<')
3412
+ ? 'Promise<'
3413
+ : part.startsWith('PromiseLike<')
3414
+ ? 'PromiseLike<'
3415
+ : null;
3416
+ if (prefix === null || !part.endsWith('>')) {
3417
+ return part;
3418
+ }
3419
+ const inner = part.slice(prefix.length, -1);
3420
+ return this.isBracketBalanced(inner) ? inner : part;
3421
+ }
3422
+ /**
3423
+ * Split a type string on `|` at bracket depth 0. `=>` is not treated as a
3424
+ * closing bracket.
3425
+ */
3426
+ splitTopLevelUnion(typeString) {
3427
+ const parts = [];
3428
+ let depth = 0;
3429
+ let start = 0;
3430
+ for (let i = 0; i < typeString.length; i++) {
3431
+ const ch = typeString[i];
3432
+ if (ch === '>' && typeString[i - 1] === '=')
3433
+ continue;
3434
+ if (ch === '<' || ch === '(' || ch === '[' || ch === '{')
3435
+ depth++;
3436
+ else if (ch === '>' || ch === ')' || ch === ']' || ch === '}')
3437
+ depth--;
3438
+ else if (ch === '|' && depth === 0) {
3439
+ parts.push(typeString.slice(start, i).trim());
3440
+ start = i + 1;
3441
+ }
3442
+ }
3443
+ parts.push(typeString.slice(start).trim());
3444
+ return parts;
3445
+ }
3446
+ isBracketBalanced(text) {
3447
+ let depth = 0;
3448
+ for (let i = 0; i < text.length; i++) {
3449
+ const ch = text[i];
3450
+ if (ch === '>' && text[i - 1] === '=')
3451
+ continue;
3452
+ if (ch === '<' || ch === '(' || ch === '[' || ch === '{')
3453
+ depth++;
3454
+ else if (ch === '>' || ch === ')' || ch === ']' || ch === '}')
3455
+ depth--;
3456
+ if (depth < 0)
3457
+ return false;
3458
+ }
3459
+ return depth === 0;
3460
+ }
3461
+ // ===========================================================================
3462
+ // Result Building
3463
+ // ===========================================================================
3464
+ getNodeLocation(node) {
3465
+ const startLinePos = node.getStartLineNumber();
3466
+ const endLinePos = node.getEndLineNumber();
3467
+ return {
3468
+ file_path: node.getSourceFile().getFilePath(),
3469
+ start_line: startLinePos,
3470
+ end_line: endLinePos,
3471
+ start_column: node.getStart() - node.getStartLinePos(),
3472
+ end_column: node.getEnd() - node.getStartLinePos(),
3473
+ };
3474
+ }
3475
+ createInferredType(request, typeString, isExplicit, sourceLocation, payloadTypeString, primaryTypeSymbol, arrayDepth, primaryTypeSymbolSource) {
3476
+ const alias = request.alias ||
3477
+ this.generateAlias(request.file_path, request.line_number, request.infer_kind);
3478
+ return {
3479
+ alias,
3480
+ type_string: typeString,
3481
+ is_explicit: isExplicit,
3482
+ source_location: sourceLocation,
3483
+ infer_kind: request.infer_kind,
3484
+ payload_type_string: payloadTypeString,
3485
+ primary_type_symbol: primaryTypeSymbol,
3486
+ // Only meaningful relative to the anchor symbol: without an element
3487
+ // symbol there is nothing for the explicit-bundle correction to match.
3488
+ array_depth: primaryTypeSymbol !== undefined && arrayDepth !== undefined && arrayDepth > 0
3489
+ ? arrayDepth
3490
+ : undefined,
3491
+ // Same gate: a declaration source without an anchor symbol is useless.
3492
+ primary_type_symbol_source: primaryTypeSymbol !== undefined ? primaryTypeSymbolSource : undefined,
3493
+ };
3494
+ }
3495
+ generateAlias(filePath, lineNumber, inferKind) {
3496
+ const fileName = filePath
3497
+ .split('/')
3498
+ .pop()
3499
+ ?.replace(/\.(ts|tsx|js|jsx)$/, '') || 'unknown';
3500
+ const pascalName = fileName
3501
+ .split(/[-_.]/)
3502
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
3503
+ .join('');
3504
+ const suffix = this.inferKindSuffix(inferKind);
3505
+ return `${pascalName}${suffix}L${lineNumber}`;
3506
+ }
3507
+ inferKindSuffix(inferKind) {
3508
+ switch (inferKind) {
3509
+ case 'function_return':
3510
+ return 'Return';
3511
+ case 'receiver_type':
3512
+ return 'Receiver';
3513
+ case 'response_body':
3514
+ return 'Response';
3515
+ case 'call_result':
3516
+ return 'Result';
3517
+ case 'variable':
3518
+ return 'Var';
3519
+ case 'expression':
3520
+ return 'Expr';
3521
+ case 'request_body':
3522
+ return 'Request';
3523
+ case 'signature_return':
3524
+ return 'SigReturn';
3525
+ case 'function_param':
3526
+ return 'Param';
3527
+ default:
3528
+ return 'Type';
3529
+ }
3530
+ }
3531
+ // ===========================================================================
3532
+ // Logging
3533
+ // ===========================================================================
3534
+ log(message) {
3535
+ console.error(`[sidecar:type-inferrer] ${message}`);
3536
+ }
3537
+ logError(message) {
3538
+ console.error(`[sidecar:type-inferrer:error] ${message}`);
3539
+ }
3540
+ }