@gate-forge/pack-http 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -134,6 +134,491 @@ export function activeClientSymbolNamesIn(config, file) {
134
134
  }
135
135
  return [...names].sort();
136
136
  }
137
+ /**
138
+ * The one payload property this model follows: the axios/kit response
139
+ * envelope whose `data` member carries the decoded body.
140
+ */
141
+ const PAYLOAD_PROPERTY = 'data';
142
+ /**
143
+ * Members of `Response`, of the collection prototypes and of `Object`
144
+ * itself. A read of one of these is a JavaScript member, never a
145
+ * response-model field, so it is never collected — without this a
146
+ * `res.data.map(...)` on a list endpoint would read as a missing field.
147
+ */
148
+ const NEVER_MODEL_FIELDS = {
149
+ at: true, catch: true, concat: true, constructor: true, entries: true, every: true,
150
+ filter: true, find: true, findIndex: true, flat: true, flatMap: true, finally: true,
151
+ forEach: true, get: true, has: true, headers: true, includes: true, indexOf: true,
152
+ join: true, keys: true, length: true, map: true, message: true, name: true, ok: true,
153
+ prototype: true, push: true, reduce: true, shift: true, slice: true, some: true,
154
+ sort: true, status: true, statusText: true, then: true, toJSON: true, toString: true,
155
+ unshift: true, values: true, valueOf: true,
156
+ };
157
+ /**
158
+ * Members of the response envelope that decide whether the call
159
+ * SUCCEEDED. A branch that opens on one of them of the call's own result
160
+ * is a guard: the error body a failure branch reads is the framework's,
161
+ * never the success model.
162
+ */
163
+ const ENVELOPE_MEMBERS = {
164
+ ok: true,
165
+ status: true,
166
+ statusText: true,
167
+ };
168
+ /** The `ok` envelope member; its polarity is the whole verdict. */
169
+ const OK_MEMBER = 'ok';
170
+ /** The last status a successful call answers with (2xx/3xx). */
171
+ const SUCCESS_STATUS = 299;
172
+ /** The first status a failing call answers with. */
173
+ const FAILURE_STATUS = 400;
174
+ /** `||` and `??` are the two ways code writes a fallback chain. */
175
+ function chainOperator(kind) {
176
+ return kind === ts.SyntaxKind.BarBarToken || kind === ts.SyntaxKind.QuestionQuestionToken;
177
+ }
178
+ /** The numeric value of a boolean or numeric literal, else null. */
179
+ function literalValue(node) {
180
+ const expression = unwrapExpression(node);
181
+ if (expression.kind === ts.SyntaxKind.TrueKeyword)
182
+ return 1;
183
+ if (expression.kind === ts.SyntaxKind.FalseKeyword)
184
+ return 0;
185
+ if (ts.isNumericLiteral(expression)) {
186
+ const value = Number(expression.text);
187
+ return Number.isFinite(value) ? value : null;
188
+ }
189
+ return null;
190
+ }
191
+ /** The names a binding pattern binds, in source order. */
192
+ function bindingNames(name) {
193
+ if (ts.isIdentifier(name))
194
+ return [name.text];
195
+ const names = [];
196
+ for (const element of name.elements) {
197
+ if (ts.isOmittedExpression(element) || element.dotDotDotToken !== undefined)
198
+ continue;
199
+ names.push(...bindingNames(element.name));
200
+ }
201
+ return names;
202
+ }
203
+ /** The literal key a binding element or property names, or `''`. */
204
+ function propertyNameText(name) {
205
+ if (name === undefined)
206
+ return '';
207
+ if (ts.isIdentifier(name) || ts.isStringLiteralLike(name))
208
+ return name.text;
209
+ return '';
210
+ }
211
+ /** Strips `await`, parentheses and non-null assertions from an expression. */
212
+ function unwrapExpression(node) {
213
+ let current = node;
214
+ for (;;) {
215
+ const parent = current.parent;
216
+ if (parent === undefined)
217
+ return current;
218
+ if (ts.isAwaitExpression(parent) ||
219
+ ts.isParenthesizedExpression(parent) ||
220
+ parent.kind === ts.SyntaxKind.AsExpression ||
221
+ parent.kind === ts.SyntaxKind.NonNullExpression ||
222
+ parent.kind === ts.SyntaxKind.SatisfiesExpression) {
223
+ current = parent;
224
+ continue;
225
+ }
226
+ return current;
227
+ }
228
+ }
229
+ /**
230
+ * The node ITSELF with its own wrapping peeled: parentheses, `as T`,
231
+ * `!`. {@link unwrapExpression} walks the other way (from an inner node
232
+ * out to the wrapper around it), which is what a value-table lookup
233
+ * needs; the concise arrow body `=> ({ ... })` IS that wrapper, so the
234
+ * request-options model has to look through it.
235
+ */
236
+ function unwrapOwnExpression(node) {
237
+ let current = node;
238
+ for (;;) {
239
+ if (ts.isParenthesizedExpression(current)) {
240
+ current = current.expression;
241
+ continue;
242
+ }
243
+ if (ts.isAsExpression(current) || ts.isNonNullExpression(current)) {
244
+ current = current.expression;
245
+ continue;
246
+ }
247
+ return current;
248
+ }
249
+ }
250
+ /**
251
+ * The bounded static read of the response fields one detected call is
252
+ * consumed through (plan 2026-09-25 Phase 4b item 5).
253
+ *
254
+ * The model is deliberately small and file-local, and it never guesses:
255
+ *
256
+ * - the call's own awaited result is followed through `await` and
257
+ * parentheses; `<result>.data` is the payload (axios/kit envelope) and
258
+ * `<result>` alone is the envelope;
259
+ * - a name bound from either of those (`const r = await call`,
260
+ * `const { data: d } = await call`, `const d = (await call).data`) is
261
+ * followed by NAME inside the enclosing function-like (nested
262
+ * function-likes included, so a `useEffect` callback still counts);
263
+ * - every `holder.<field>`, `holder.data.<field>`, `holder.data['<field>']`
264
+ * and `holder['<field>']` is a read, and so is each key of an object
265
+ * destructuring of a holder or of `<holder>.data`;
266
+ * - computed access (`d[key]`), a reassigned (`let`) holder, and a read
267
+ * of a JavaScript member (`data.map`, `data.length`, …) yield nothing;
268
+ * - a branch that opens on the call's OWN envelope (`if (res.ok)`,
269
+ * `if (!res.ok)`, `if (res.status >= 400)`, `res.ok ? … : …`) is read
270
+ * for its POLARITY, and only its FAILURE arm is not collected: the
271
+ * body a failure branch reads is the error envelope, not the success
272
+ * model. An undecidable condition (a compound test, a comparison this
273
+ * pass cannot read) drops BOTH arms. A read AFTER the guard is
274
+ * collected as usual;
275
+ * - a read that is one operand of a `||` / `??` chain carries the chain's
276
+ * index, so the response-model check can judge the whole chain at once
277
+ * instead of flagging a defensive fallback.
278
+ *
279
+ * A read is recorded once per field and location, in source order.
280
+ */
281
+ function responseReadsOf(file, call, source) {
282
+ const reads = new Map();
283
+ const chainRoots = [];
284
+ // Reads the BINDING pass already saw (an inline `(await call).data.x`).
285
+ // They are recorded first and replayed once `add` knows the holders,
286
+ // which the binding pass is what discovers.
287
+ const bound = [];
288
+ let add = (field, node) => {
289
+ bound.push({ field, node });
290
+ };
291
+ const awaited = unwrapExpression(call);
292
+ const envelopeAccess = awaited.parent !== undefined &&
293
+ ts.isPropertyAccessExpression(awaited.parent) &&
294
+ awaited.parent.expression === awaited &&
295
+ awaited.parent.name.text === PAYLOAD_PROPERTY
296
+ ? awaited.parent
297
+ : undefined;
298
+ const payload = envelopeAccess;
299
+ const holders = [];
300
+ bindHolder(awaited, false, holders, add);
301
+ if (payload !== undefined)
302
+ bindHolder(payload, true, holders, add);
303
+ const responseNames = new Set(holders.filter((h) => !h.payload).map((h) => h.name));
304
+ const payloadNames = new Set(holders.filter((h) => h.payload).map((h) => h.name));
305
+ /** Whether an expression IS the decoded payload of this call. */
306
+ const isPayload = (node) => {
307
+ const expression = unwrapExpression(node);
308
+ if (ts.isIdentifier(expression))
309
+ return payloadNames.has(expression.text);
310
+ return (ts.isPropertyAccessExpression(expression) &&
311
+ expression.name.text === PAYLOAD_PROPERTY &&
312
+ ts.isIdentifier(expression.expression) &&
313
+ responseNames.has(expression.expression.text));
314
+ };
315
+ let scope = source;
316
+ for (let node = call; node !== undefined; node = node.parent) {
317
+ if (ts.isFunctionLike(node)) {
318
+ scope = node;
319
+ break;
320
+ }
321
+ }
322
+ // A branch that opens on the call's OWN envelope (`if (!res.ok)`,
323
+ // `if (res.status >= 400)`, `res.ok ? … : …`) is a guard: which side
324
+ // runs is not statically known, and the error body a failure branch
325
+ // reads is the framework's, not the success model. Nothing inside such a
326
+ // branch is a proven read of the success model — see the failure-path
327
+ // note in the module doc.
328
+ const guarded = new Set();
329
+ const isHolderName = (node) => ts.isIdentifier(node) && responseNames.has(node.text);
330
+ /** The envelope member of this call's own result an expression reads. */
331
+ const envelopeMember = (node) => ts.isPropertyAccessExpression(node) &&
332
+ isHolderName(node.expression) &&
333
+ ENVELOPE_MEMBERS[node.name.text] === true
334
+ ? node.name.text
335
+ : null;
336
+ const mentionsEnvelope = (node) => {
337
+ if (envelopeMember(node) !== null)
338
+ return true;
339
+ let found = false;
340
+ const inspect = (at) => {
341
+ if (found)
342
+ return;
343
+ if (envelopeMember(at) !== null) {
344
+ found = true;
345
+ return;
346
+ }
347
+ ts.forEachChild(at, inspect);
348
+ };
349
+ ts.forEachChild(node, inspect);
350
+ return found;
351
+ };
352
+ /**
353
+ * The polarity of an envelope guard, or null when the condition does not
354
+ * test this call's envelope at all.
355
+ *
356
+ * `if (res.ok)` / `if (res.ok === true)` / `if (res.status >= 400)` /
357
+ * `if (res.status !== 201)` and their negations are decided here, so the
358
+ * SUCCESS arm keeps its reads — the error envelope belongs to the
359
+ * failure arm only. A compound condition, a comparison this cannot
360
+ * read, or a `statusText`/`headers` test is `unknown`: neither arm is a
361
+ * proven read of the success model.
362
+ */
363
+ const guardPolarity = (condition) => {
364
+ const expression = unwrapExpression(condition);
365
+ if (ts.isPrefixUnaryExpression(expression) && expression.operator === ts.SyntaxKind.ExclamationToken) {
366
+ const inner = guardPolarity(expression.operand);
367
+ if (inner === null || inner === 'unknown')
368
+ return inner;
369
+ return inner === 'success' ? 'failure' : 'success';
370
+ }
371
+ const bare = envelopeMember(expression);
372
+ if (bare !== null)
373
+ return bare === OK_MEMBER ? 'success' : 'unknown';
374
+ if (!ts.isBinaryExpression(expression)) {
375
+ return mentionsEnvelope(expression) ? 'unknown' : null;
376
+ }
377
+ const member = envelopeMember(expression.left);
378
+ if (member === null)
379
+ return mentionsEnvelope(expression) ? 'unknown' : null;
380
+ const expected = literalValue(expression.right);
381
+ if (expected === null)
382
+ return 'unknown';
383
+ const isOk = member === OK_MEMBER;
384
+ const kind = expression.operatorToken.kind;
385
+ const equality = kind === ts.SyntaxKind.EqualsEqualsToken || kind === ts.SyntaxKind.EqualsEqualsEqualsToken;
386
+ const inequality = kind === ts.SyntaxKind.ExclamationEqualsToken || kind === ts.SyntaxKind.ExclamationEqualsEqualsToken;
387
+ if (isOk) {
388
+ // A boolean compared with a boolean literal: `!==` is the negated
389
+ // equality, so `ok !== true` is the failure arm.
390
+ if (!equality && !inequality)
391
+ return 'unknown';
392
+ const satisfied = (succeeded) => {
393
+ const same = (succeeded ? 1 : 0) === expected;
394
+ return inequality ? !same : same;
395
+ };
396
+ if (satisfied(true) && !satisfied(false))
397
+ return 'success';
398
+ if (!satisfied(true) && satisfied(false))
399
+ return 'failure';
400
+ return 'unknown';
401
+ }
402
+ if (inequality) {
403
+ // `status !== 200` reads as "not the success I expected", which is
404
+ // the failure arm even though a 404 satisfies it too.
405
+ if (expected >= 200 && expected <= SUCCESS_STATUS)
406
+ return 'failure';
407
+ if (expected >= FAILURE_STATUS)
408
+ return 'success';
409
+ return 'unknown';
410
+ }
411
+ const succeeded = expected >= 200 && expected <= SUCCESS_STATUS;
412
+ const failed = expected >= FAILURE_STATUS;
413
+ switch (kind) {
414
+ case ts.SyntaxKind.EqualsEqualsToken:
415
+ case ts.SyntaxKind.EqualsEqualsEqualsToken:
416
+ if (succeeded)
417
+ return 'success';
418
+ if (failed)
419
+ return 'failure';
420
+ return 'unknown';
421
+ case ts.SyntaxKind.LessThanToken:
422
+ return SUCCESS_STATUS < expected ? 'success' : FAILURE_STATUS < expected ? 'failure' : 'unknown';
423
+ case ts.SyntaxKind.LessThanEqualsToken:
424
+ return SUCCESS_STATUS <= expected ? 'success' : FAILURE_STATUS <= expected ? 'failure' : 'unknown';
425
+ case ts.SyntaxKind.GreaterThanToken:
426
+ return SUCCESS_STATUS > expected ? 'success' : FAILURE_STATUS > expected ? 'failure' : 'unknown';
427
+ case ts.SyntaxKind.GreaterThanEqualsToken:
428
+ return SUCCESS_STATUS >= expected ? 'success' : FAILURE_STATUS >= expected ? 'failure' : 'unknown';
429
+ default:
430
+ return 'unknown';
431
+ }
432
+ };
433
+ const markGuards = (node) => {
434
+ if (ts.isIfStatement(node)) {
435
+ const polarity = guardPolarity(node.expression);
436
+ if (polarity === 'failure')
437
+ guarded.add(node.thenStatement);
438
+ else if (polarity === 'success' && node.elseStatement !== undefined) {
439
+ guarded.add(node.elseStatement);
440
+ }
441
+ else if (polarity === 'unknown') {
442
+ guarded.add(node.thenStatement);
443
+ if (node.elseStatement !== undefined)
444
+ guarded.add(node.elseStatement);
445
+ }
446
+ }
447
+ if (ts.isConditionalExpression(node)) {
448
+ // A ternary on the envelope is the same guard as an `if`: only its
449
+ // failure arm is the error envelope.
450
+ const polarity = guardPolarity(node.condition);
451
+ if (polarity === 'failure')
452
+ guarded.add(node.whenTrue);
453
+ else if (polarity === 'success')
454
+ guarded.add(node.whenFalse);
455
+ else if (polarity === 'unknown') {
456
+ guarded.add(node.whenTrue);
457
+ guarded.add(node.whenFalse);
458
+ }
459
+ }
460
+ ts.forEachChild(node, markGuards);
461
+ };
462
+ markGuards(scope);
463
+ /** Whether a node sits inside one of the guarded branch bodies. */
464
+ const insideGuard = (node) => {
465
+ for (let at = node; at !== undefined && at !== scope; at = at.parent) {
466
+ if (guarded.has(at))
467
+ return true;
468
+ }
469
+ return false;
470
+ };
471
+ /** The outermost `||` / `??` chain an operand belongs to, if any. */
472
+ const chainOf = (node) => {
473
+ for (let at = node; at !== undefined && at !== scope; at = at.parent) {
474
+ if (ts.isBinaryExpression(at) && chainOperator(at.operatorToken.kind))
475
+ return at;
476
+ }
477
+ return undefined;
478
+ };
479
+ add = (field, node) => {
480
+ if (field.length === 0 || NEVER_MODEL_FIELDS[field] === true)
481
+ return;
482
+ if (insideGuard(node))
483
+ return;
484
+ const location = locationOf(file, source, node);
485
+ const key = `${field}@${String(location.line)}:${String(location.col)}`;
486
+ if (reads.has(key))
487
+ return;
488
+ const root = chainOf(node);
489
+ if (root === undefined) {
490
+ reads.set(key, { field, location });
491
+ return;
492
+ }
493
+ let index = chainRoots.indexOf(root);
494
+ if (index < 0) {
495
+ chainRoots.push(root);
496
+ index = chainRoots.length - 1;
497
+ }
498
+ reads.set(key, { field, location, chain: index });
499
+ };
500
+ for (const read of bound)
501
+ add(read.field, read.node);
502
+ const visit = (node) => {
503
+ if (ts.isPropertyAccessExpression(node) && isPayload(node.expression)) {
504
+ add(node.name.text, node);
505
+ }
506
+ else if (ts.isElementAccessExpression(node) &&
507
+ ts.isStringLiteralLike(node.argumentExpression) &&
508
+ isPayload(node.expression)) {
509
+ add(node.argumentExpression.text, node);
510
+ }
511
+ else if (ts.isVariableDeclaration(node) &&
512
+ node.initializer !== undefined &&
513
+ ts.isObjectBindingPattern(node.name) &&
514
+ isPayload(node.initializer)) {
515
+ for (const element of node.name.elements) {
516
+ const field = bindingKey(element);
517
+ if (field.length > 0)
518
+ add(field, element);
519
+ }
520
+ }
521
+ ts.forEachChild(node, visit);
522
+ };
523
+ visit(scope);
524
+ const ordered = [...reads.values()].sort((left, right) => {
525
+ const where = (left.location.file < right.location.file ? -1 : left.location.file > right.location.file ? 1 : 0) ||
526
+ left.location.line - right.location.line ||
527
+ left.location.col - right.location.col ||
528
+ (left.field < right.field ? -1 : left.field > right.field ? 1 : 0);
529
+ return where;
530
+ });
531
+ // Chain numbers are handed out in discovery order; renumber them by
532
+ // where each chain FIRST appears in source, so the fact is independent
533
+ // of the traversal.
534
+ const firstOf = (index) => ordered.find((read) => read.chain === index);
535
+ const chains = [...new Set(ordered.map((read) => read.chain))]
536
+ .filter((index) => index !== undefined)
537
+ .sort((left, right) => firstOf(left).location.line - firstOf(right).location.line ||
538
+ firstOf(left).location.col - firstOf(right).location.col);
539
+ const renumbered = new Map(chains.map((index, position) => [index, position]));
540
+ return ordered.map((read) => read.chain === undefined ? read : { ...read, chain: renumbered.get(read.chain) ?? read.chain });
541
+ /** The key a binding element pulls off the object it destructures. */
542
+ function bindingKey(element) {
543
+ const declared = propertyNameText(element.propertyName);
544
+ if (declared.length > 0)
545
+ return declared;
546
+ return ts.isIdentifier(element.name) ? element.name.text : '';
547
+ }
548
+ /**
549
+ * Records what the awaited result (or its `.data` payload) binds to:
550
+ * a name to follow, or — for a destructured payload — the field keys
551
+ * that destructuring itself reads. `status`/`headers` and every other
552
+ * envelope member are not response-model fields, so they are ignored.
553
+ */
554
+ function bindHolder(node, isPayloadNode, into, addRead) {
555
+ const parent = node.parent;
556
+ if (parent === undefined)
557
+ return;
558
+ if (ts.isVariableDeclaration(parent) && parent.initializer === node) {
559
+ // A `let` holder can be reassigned before the read, so the name no
560
+ // longer provably holds this call's response; a `const` one does.
561
+ const declarationList = parent.parent;
562
+ const isConst = ts.isVariableDeclarationList(declarationList) &&
563
+ (ts.getCombinedNodeFlags(declarationList) & ts.NodeFlags.Const) !== 0;
564
+ if (!isConst)
565
+ return;
566
+ if (ts.isIdentifier(parent.name)) {
567
+ into.push({ name: parent.name.text, payload: isPayloadNode });
568
+ return;
569
+ }
570
+ // An array destructuring pulls ELEMENTS, not named fields, out of a
571
+ // response: there is no field name to compare against a model.
572
+ if (!ts.isObjectBindingPattern(parent.name))
573
+ return;
574
+ // `const { data: d } = await call` — the payload is one level in;
575
+ // every other envelope key is not a response-model field.
576
+ if (!isPayloadNode) {
577
+ for (const element of parent.name.elements) {
578
+ if (bindingKey(element) !== PAYLOAD_PROPERTY)
579
+ continue;
580
+ for (const name of bindingNames(element.name))
581
+ into.push({ name, payload: true });
582
+ }
583
+ return;
584
+ }
585
+ for (const element of parent.name.elements) {
586
+ const field = bindingKey(element);
587
+ if (field.length > 0)
588
+ addRead(field, element);
589
+ }
590
+ return;
591
+ }
592
+ if (ts.isBindingElement(parent) && parent.initializer === node) {
593
+ if (!isPayloadNode) {
594
+ if (bindingKey(parent) === PAYLOAD_PROPERTY && ts.isObjectBindingPattern(parent.name)) {
595
+ for (const name of bindingNames(parent.name))
596
+ into.push({ name, payload: true });
597
+ }
598
+ return;
599
+ }
600
+ if (ts.isObjectBindingPattern(parent.name)) {
601
+ // `const { data: { dueDate } } = await call` — the nested keys
602
+ // are the fields the body is read through.
603
+ for (const element of parent.name.elements) {
604
+ const field = bindingKey(element);
605
+ if (field.length > 0)
606
+ addRead(field, element);
607
+ }
608
+ return;
609
+ }
610
+ const key = bindingKey(parent);
611
+ if (key.length > 0)
612
+ addRead(key, parent);
613
+ return;
614
+ }
615
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === node) {
616
+ // `(await call).data.<field>` reads one field of the body inline.
617
+ if (isPayloadNode)
618
+ addRead(parent.name.text, parent);
619
+ }
620
+ }
621
+ }
137
622
  const VERB_METHODS = new Map([
138
623
  ['get', 'GET'],
139
624
  ['post', 'POST'],
@@ -143,6 +628,14 @@ const VERB_METHODS = new Map([
143
628
  ['head', 'HEAD'],
144
629
  ['options', 'OPTIONS'],
145
630
  ]);
631
+ const ABSENT_OPTIONS = { kind: 'absent' };
632
+ const UNPROVEN_OPTIONS = { kind: 'unproven' };
633
+ /**
634
+ * Bound hops of the request-options model (an options object, a constant
635
+ * naming one, or a single-return function that forwards its argument), so
636
+ * a chain of wrappers is reported, never followed forever.
637
+ */
638
+ const MAX_OPTIONS_HOPS = 4;
146
639
  const UNRESOLVED_VALUE = { kind: 'unresolved', text: '' };
147
640
  function languageKindFor(file) {
148
641
  if (file.endsWith('.tsx'))
@@ -162,6 +655,7 @@ function modelFile(source, config, file) {
162
655
  const constants = new Map();
163
656
  const clientFunctions = new Set();
164
657
  const wrappers = new Map();
658
+ const functions = new Map();
165
659
  const isModuleScope = (node) => {
166
660
  let current = node.parent;
167
661
  while (current !== undefined) {
@@ -186,23 +680,26 @@ function modelFile(source, config, file) {
186
680
  // One-declaration wrapper: `const apiGet = (path) => fetch(...)`.
187
681
  const init = declaration.initializer;
188
682
  if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) {
683
+ functions.set(declaration.name.text, init);
189
684
  modelWrapper(declaration.name.text, init, wrappers);
190
685
  if (containsClientCall(init, config, file))
191
686
  clientFunctions.add(declaration.name.text);
192
687
  }
193
688
  }
194
689
  }
195
- if ((ts.isFunctionDeclaration(node) && node.name !== undefined && node.body !== undefined) ||
196
- (ts.isVariableStatement(node))) {
197
- // Named function declarations can be client wrappers too.
198
- if (ts.isFunctionDeclaration(node) && node.name !== undefined && containsClientCall(node, config, file)) {
690
+ if (ts.isFunctionDeclaration(node) && node.name !== undefined) {
691
+ // Named function declarations can be client wrappers too, and the
692
+ // request-options model reads their single returned expression.
693
+ if (isModuleScope(node))
694
+ functions.set(node.name.text, node);
695
+ if (node.body !== undefined && containsClientCall(node, config, file)) {
199
696
  clientFunctions.add(node.name.text);
200
697
  }
201
698
  }
202
699
  ts.forEachChild(node, visit);
203
700
  };
204
701
  visit(source);
205
- return { source, constants, clientFunctions, wrappers };
702
+ return { source, constants, clientFunctions, functions, wrappers };
206
703
  }
207
704
  /** True when the subtree contains a direct fetch/axios/client call. */
208
705
  function containsClientCall(node, config, file) {
@@ -323,10 +820,14 @@ function extractCall(call, file, config, table, model, calls, unresolved) {
323
820
  if (source === undefined)
324
821
  return;
325
822
  const location = locationOf(file, source, call);
823
+ // The response fields THIS call site reads (plan 2026-09-25 Phase 4b
824
+ // item 5) — the same bounded, file-local pass, attached to whichever
825
+ // call shape below is recognized.
826
+ const reads = responseReadsOf(file, call, source);
326
827
  const expression = call.expression;
327
828
  // fetch(url[, {method}])
328
829
  if (ts.isIdentifier(expression) && expression.text === 'fetch') {
329
- extractClientCall(call, 'fetch', 'GET', file, config, table, calls, unresolved, location);
830
+ extractClientCall(call, 'fetch', 'GET', file, config, table, calls, unresolved, location, undefined, reads);
330
831
  return;
331
832
  }
332
833
  // axios.get(url), apiClient.post(url), window.fetch(url)
@@ -335,7 +836,7 @@ function extractCall(call, file, config, table, model, calls, unresolved) {
335
836
  const verb = expression.name.text.toLowerCase();
336
837
  const isFetchObject = objectName === 'window' && expression.name.text === 'fetch';
337
838
  if (isFetchObject) {
338
- extractClientCall(call, 'fetch', 'GET', file, config, table, calls, unresolved, location);
839
+ extractClientCall(call, 'fetch', 'GET', file, config, table, calls, unresolved, location, undefined, reads);
339
840
  return;
340
841
  }
341
842
  // Per-symbol scoping (phase 3): the symbol counts only where its
@@ -347,7 +848,7 @@ function extractCall(call, file, config, table, model, calls, unresolved) {
347
848
  // proven literal baseURL joins into the emitted path (fetch has no
348
849
  // instance and never joins).
349
850
  const baseURL = table.instanceBaseURL(objectName, file);
350
- extractClientCall(call, objectName, method, file, config, table, calls, unresolved, location, baseURL);
851
+ extractClientCall(call, objectName, method, file, config, table, calls, unresolved, location, baseURL, reads);
351
852
  return;
352
853
  }
353
854
  }
@@ -368,7 +869,7 @@ function extractCall(call, file, config, table, model, calls, unresolved) {
368
869
  }
369
870
  if (configCall !== null && framework !== null) {
370
871
  const baseURL = table.instanceBaseURL(framework, file);
371
- extractConfiguredCall(configCall, framework, file, config, table, calls, unresolved, location, baseURL);
872
+ extractConfiguredCall(configCall, framework, file, config, table, calls, unresolved, location, baseURL, reads);
372
873
  return;
373
874
  }
374
875
  // Configured wrapper: the declaration must exist in the scanned set as
@@ -408,7 +909,7 @@ function extractCall(call, file, config, table, model, calls, unresolved) {
408
909
  });
409
910
  return;
410
911
  }
411
- resolveAndRecord(urlNode, wrapperConfig.name, wrapperConfig.method, file, config, bound, calls, unresolved, location);
912
+ resolveAndRecord(urlNode, wrapperConfig.name, wrapperConfig.method, file, config, bound, calls, unresolved, location, reads);
412
913
  return;
413
914
  }
414
915
  // A module-scope function whose body issues client calls IS a client
@@ -467,8 +968,16 @@ class BoundTable {
467
968
  }
468
969
  return this.inner.evaluate(node, file);
469
970
  }
971
+ optionsMethod(node, file, bindings, depth = 0) {
972
+ if (ts.isIdentifier(node) &&
973
+ node.text === this.parameter &&
974
+ this.argument !== undefined) {
975
+ return this.inner.optionsMethod(this.argument, this.argumentFile, bindings, depth + 1);
976
+ }
977
+ return this.inner.optionsMethod(node, file, bindings, depth);
978
+ }
470
979
  }
471
- function extractClientCall(call, framework, defaultMethod, file, config, table, calls, unresolved, location, baseURL) {
980
+ function extractClientCall(call, framework, defaultMethod, file, config, table, calls, unresolved, location, baseURL, reads = []) {
472
981
  const urlNode = call.arguments[0];
473
982
  if (urlNode === undefined) {
474
983
  unresolved.push({
@@ -513,11 +1022,31 @@ function extractClientCall(call, framework, defaultMethod, file, config, table,
513
1022
  }
514
1023
  }
515
1024
  }
1025
+ if (framework === 'fetch' &&
1026
+ optionsNode !== undefined &&
1027
+ !ts.isObjectLiteralExpression(optionsNode)) {
1028
+ // `fetch(url, options)` with options built elsewhere: the verb is
1029
+ // whatever that object carries, and until the bounded options model
1030
+ // proves it, assuming GET invents a route the server never serves.
1031
+ const resolved = table.optionsMethod(optionsNode, file);
1032
+ if (resolved.kind === 'unproven') {
1033
+ unresolved.push({
1034
+ code: HTTP_METHOD_DYNAMIC,
1035
+ detail: 'fetch call passes request options that are not a provable object literal ' +
1036
+ '(they are built by another function or a computed expression); the method ' +
1037
+ 'cannot be proven statically and is never assumed to be GET',
1038
+ location,
1039
+ });
1040
+ return;
1041
+ }
1042
+ if (resolved.kind === 'proven')
1043
+ method = resolved.method;
1044
+ }
516
1045
  if (method === null)
517
1046
  return;
518
- resolveAndRecord(urlNode, framework, method, file, config, table, calls, unresolved, location, baseURL);
1047
+ resolveAndRecord(urlNode, framework, method, file, config, table, calls, unresolved, location, reads, baseURL);
519
1048
  }
520
- function extractConfiguredCall(call, framework, file, config, table, calls, unresolved, location, baseURL) {
1049
+ function extractConfiguredCall(call, framework, file, config, table, calls, unresolved, location, baseURL, reads = []) {
521
1050
  const configNode = call.arguments[0];
522
1051
  if (configNode === undefined || !ts.isObjectLiteralExpression(configNode)) {
523
1052
  unresolved.push({
@@ -564,7 +1093,7 @@ function extractConfiguredCall(call, framework, file, config, table, calls, unre
564
1093
  });
565
1094
  return;
566
1095
  }
567
- resolveAndRecord(urlNode, framework, method, file, config, table, calls, unresolved, location, baseURL);
1096
+ resolveAndRecord(urlNode, framework, method, file, config, table, calls, unresolved, location, reads, baseURL);
568
1097
  }
569
1098
  /**
570
1099
  * axios `isAbsoluteURL`: a call URL beginning `<scheme>://` or a
@@ -596,7 +1125,7 @@ function joinInstanceBaseURL(baseURL, callPath) {
596
1125
  joined: baseURL,
597
1126
  };
598
1127
  }
599
- function resolveAndRecord(urlNode, framework, method, file, config, table, calls, unresolved, location, baseURL) {
1128
+ function resolveAndRecord(urlNode, framework, method, file, config, table, calls, unresolved, location, reads = [], baseURL) {
600
1129
  const resolved = table.evaluate(urlNode, file);
601
1130
  if (resolved.kind === 'unresolved') {
602
1131
  unresolved.push({
@@ -623,8 +1152,28 @@ function resolveAndRecord(urlNode, framework, method, file, config, table, calls
623
1152
  ...(joined.joined !== undefined ? { joinedBaseURL: joined.joined } : {}),
624
1153
  framework,
625
1154
  location,
1155
+ ...(reads.length > 0 ? { responseReads: reads } : {}),
626
1156
  });
627
1157
  }
1158
+ /**
1159
+ * The ONE expression a function-like returns, or undefined when it
1160
+ * returns none or branches: two return paths can set the verb
1161
+ * differently, and an unprovable options object must not be guessed.
1162
+ */
1163
+ function returnedExpression(fn) {
1164
+ // A concise arrow body IS its returned expression: `(o) => ({ ...o })`.
1165
+ if (ts.isArrowFunction(fn) && !ts.isBlock(fn.body))
1166
+ return fn.body;
1167
+ const body = ts.isArrowFunction(fn)
1168
+ ? ts.isBlock(fn.body)
1169
+ ? fn.body
1170
+ : undefined
1171
+ : fn.body;
1172
+ if (body === undefined)
1173
+ return undefined;
1174
+ const returns = body.statements.filter((statement) => ts.isReturnStatement(statement));
1175
+ return returns.length === 1 ? returns[0]?.expression : undefined;
1176
+ }
628
1177
  function normalizeHttpMethodValue(raw) {
629
1178
  const upper = raw.trim().toUpperCase();
630
1179
  if (upper === 'GET' || upper === 'HEAD' || upper === 'POST' || upper === 'PUT' || upper === 'PATCH' || upper === 'DELETE' || upper === 'OPTIONS') {
@@ -940,6 +1489,106 @@ class ValueTable {
940
1489
  return UNRESOLVED_VALUE;
941
1490
  return this.evaluate(initializer, targetFile);
942
1491
  }
1492
+ /**
1493
+ * The verb one request-options expression provably carries.
1494
+ *
1495
+ * Bounded and deterministic, three shapes only:
1496
+ * - an inline object literal: `method` is read with JavaScript's
1497
+ * last-writer-wins order, and a spread of another resolvable options
1498
+ * expression participates equally (`{...base, method:'POST'}`);
1499
+ * - a name: a module-scope constant in the scanned set (same file or
1500
+ * relative import), followed at most {@link MAX_OPTIONS_HOPS} hops;
1501
+ * - a call to a plain-named function whose declaration is in the
1502
+ * scanned set and whose body is a SINGLE `return <expression>`:
1503
+ * the first parameter binds to this call's first argument, so a
1504
+ * forwarder (`(options) => ({ ...options })`) resolves to whatever
1505
+ * its argument proves.
1506
+ * Everything else — a computed member, an attribute, a member call, a
1507
+ * function this scan cannot see — is `unproven`, and the caller turns
1508
+ * that into a typed `HTTP_METHOD_DYNAMIC` entry instead of a GET.
1509
+ */
1510
+ optionsMethod(node, file, bindings = new Map(), depth = 0) {
1511
+ if (depth > MAX_OPTIONS_HOPS)
1512
+ return UNPROVEN_OPTIONS;
1513
+ const expression = unwrapOwnExpression(node);
1514
+ if (ts.isObjectLiteralExpression(expression)) {
1515
+ return this.optionsMethodOfObject(expression, file, bindings, depth);
1516
+ }
1517
+ if (ts.isIdentifier(expression)) {
1518
+ const bound = bindings.get(expression.text);
1519
+ if (bound !== undefined)
1520
+ return this.optionsMethod(bound[0], bound[1], bindings, depth + 1);
1521
+ const constant = this.resolveConstantInitializer(expression.text, file, new Set());
1522
+ if (constant === undefined)
1523
+ return UNPROVEN_OPTIONS;
1524
+ return this.optionsMethod(constant[0], constant[1], bindings, depth + 1);
1525
+ }
1526
+ if (ts.isCallExpression(expression) && ts.isIdentifier(expression.expression)) {
1527
+ return this.optionsMethodThroughCall(expression, file, bindings, depth);
1528
+ }
1529
+ return UNPROVEN_OPTIONS;
1530
+ }
1531
+ /** The verb one options object literal carries, spreads included. */
1532
+ optionsMethodOfObject(object, file, bindings, depth) {
1533
+ let result = ABSENT_OPTIONS;
1534
+ for (const property of object.properties) {
1535
+ if (ts.isSpreadAssignment(property)) {
1536
+ result = this.optionsMethod(property.expression, file, bindings, depth + 1);
1537
+ }
1538
+ else if ((ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) &&
1539
+ ts.isIdentifier(property.name) &&
1540
+ property.name.text === 'method') {
1541
+ const initializer = ts.isPropertyAssignment(property) ? property.initializer : property.name;
1542
+ result = this.verbOfLiteral(initializer, file, bindings);
1543
+ }
1544
+ else {
1545
+ continue;
1546
+ }
1547
+ if (result.kind === 'unproven')
1548
+ return UNPROVEN_OPTIONS;
1549
+ }
1550
+ return result;
1551
+ }
1552
+ /** A `method:` member whose value provably is one concrete verb. */
1553
+ verbOfLiteral(node, file, bindings) {
1554
+ const bound = ts.isIdentifier(node) ? bindings.get(node.text) : undefined;
1555
+ const resolved = bound === undefined ? this.evaluate(node, file) : this.evaluate(bound[0], bound[1]);
1556
+ if (resolved.kind !== 'literal')
1557
+ return UNPROVEN_OPTIONS;
1558
+ const verb = normalizeHttpMethodValue(resolved.text);
1559
+ return verb === null ? UNPROVEN_OPTIONS : { kind: 'proven', method: verb };
1560
+ }
1561
+ /** Options returned by a single-return function declared in the scan. */
1562
+ optionsMethodThroughCall(call, file, bindings, depth) {
1563
+ const name = ts.isIdentifier(call.expression) ? call.expression.text : '';
1564
+ if (name.length === 0)
1565
+ return UNPROVEN_OPTIONS;
1566
+ const declared = this.functionOf(name, file);
1567
+ if (declared === undefined)
1568
+ return UNPROVEN_OPTIONS;
1569
+ const returned = returnedExpression(declared);
1570
+ if (returned === undefined)
1571
+ return UNPROVEN_OPTIONS;
1572
+ const next = new Map(bindings);
1573
+ const parameter = declared.parameters[0];
1574
+ const argument = call.arguments[0];
1575
+ if (parameter !== undefined &&
1576
+ argument !== undefined &&
1577
+ ts.isIdentifier(parameter.name)) {
1578
+ next.set(parameter.name.text, [argument, file]);
1579
+ }
1580
+ return this.optionsMethod(returned, file, next, depth + 1);
1581
+ }
1582
+ /** A module-scope function declaration, same file or relative import. */
1583
+ functionOf(name, file) {
1584
+ const own = this.modelOf(file)?.functions.get(name);
1585
+ if (own !== undefined)
1586
+ return own;
1587
+ const imported = this.importedFrom(file, name);
1588
+ if (imported === null)
1589
+ return undefined;
1590
+ return this.modelOf(imported)?.functions.get(name);
1591
+ }
943
1592
  resolveSpecifier(fromFile, specifier) {
944
1593
  const base = posix.dirname(fromFile.split('\\').join('/'));
945
1594
  const joined = posix.normalize(posix.join(base, specifier));