carrick 0.3.90 → 0.3.92

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 (45) hide show
  1. package/README.md +5 -2
  2. package/bin/carrick.mjs +2 -2
  3. package/dist/auth/credentials.d.ts +1 -1
  4. package/dist/auth/credentials.js +1 -1
  5. package/dist/auth/credentials.js.map +1 -1
  6. package/dist/auth/oauth.d.ts +8 -0
  7. package/dist/auth/oauth.js +65 -11
  8. package/dist/auth/oauth.js.map +1 -1
  9. package/dist/auth/run.d.ts +19 -1
  10. package/dist/auth/run.js +52 -9
  11. package/dist/auth/run.js.map +1 -1
  12. package/dist/init/connect.d.ts +42 -3
  13. package/dist/init/connect.js +172 -113
  14. package/dist/init/connect.js.map +1 -1
  15. package/dist/init/mcp.d.ts +11 -0
  16. package/dist/init/mcp.js +14 -8
  17. package/dist/init/mcp.js.map +1 -1
  18. package/dist/init/output.d.ts +2 -0
  19. package/dist/init/output.js +33 -2
  20. package/dist/init/output.js.map +1 -1
  21. package/dist/init/projects.d.ts +56 -5
  22. package/dist/init/projects.js +124 -17
  23. package/dist/init/projects.js.map +1 -1
  24. package/dist/init/repos.d.ts +33 -0
  25. package/dist/init/repos.js +8 -1
  26. package/dist/init/repos.js.map +1 -1
  27. package/dist/init/run.d.ts +64 -36
  28. package/dist/init/run.js +263 -163
  29. package/dist/init/run.js.map +1 -1
  30. package/package.json +6 -6
  31. package/sidecar/dist/src/capture/api.d.ts +13 -1
  32. package/sidecar/dist/src/capture/check-classify.js +12 -7
  33. package/sidecar/dist/src/capture/check-probe.d.ts +10 -0
  34. package/sidecar/dist/src/capture/check-probe.js +21 -3
  35. package/sidecar/dist/src/capture/check.js +1 -0
  36. package/sidecar/dist/src/capture/index.d.ts +1 -0
  37. package/sidecar/dist/src/capture/index.js +1 -0
  38. package/sidecar/dist/src/index.js +37 -9
  39. package/sidecar/dist/src/retype.d.ts +67 -0
  40. package/sidecar/dist/src/retype.js +813 -0
  41. package/sidecar/dist/src/type-inferrer.d.ts +85 -1
  42. package/sidecar/dist/src/type-inferrer.js +384 -1
  43. package/sidecar/dist/src/types.d.ts +58 -2
  44. package/sidecar/dist/src/validators.d.ts +153 -20
  45. package/sidecar/dist/src/validators.js +17 -0
@@ -0,0 +1,813 @@
1
+ /**
2
+ * The retype check (carrick#1491): judge an untyped consumer call by what its
3
+ * own source does with the producer's response.
4
+ *
5
+ * A consumer that writes `await api.post('/orders')` with no type argument
6
+ * gets `any` back, so the pair has nothing to compare and every field it reads
7
+ * goes unchecked. The source still says how the response is used: the members
8
+ * it reads, what it destructures, the typed places it flows into. So the call
9
+ * is rewritten, in memory, to state the producer's response type
10
+ * (`api.post<ProducerResponse>('/orders')`), the consumer file is type-checked
11
+ * before and after, and every diagnostic the rewrite adds is a place where the
12
+ * consumer uses something the producer does not return. No rule of ours
13
+ * decides anything: the compiler reads the consumer's own code.
14
+ *
15
+ * A call that takes no type argument but returns a typed transport response
16
+ * (`const res = await fetch(u)`) is judged at its body read instead: the
17
+ * `res.json()` on that call's result is the payload, so that read is cast to
18
+ * the producer's type (carrick#1493).
19
+ *
20
+ * Runs in the consumer's REAL program (the init'd project), because that is
21
+ * the only place its file can type-check: the check workspace holds
22
+ * declaration stubs, not consumer sources, and none of the consumer's imports.
23
+ *
24
+ * Every edit is undone before the next item and before returning, so the
25
+ * project other requests read is the project the scan loaded.
26
+ */
27
+ import { Node, SyntaxKind, ts } from 'ts-morph';
28
+ /** Names appended to a file that is not ours carry this prefix. */
29
+ const PREFIX = '__carrick_';
30
+ const WIRE = `${PREFIX}Wire`;
31
+ export class Retyper {
32
+ project;
33
+ inferrer;
34
+ jsonWire;
35
+ /**
36
+ * `jsonWire` is the check phase's own JSON wire transform
37
+ * (`jsonWireDeclarations` in the capture bundle), handed in by the entry
38
+ * point so both judges read the wire the same way without this file
39
+ * crossing the bundle seam.
40
+ */
41
+ constructor(project, inferrer, jsonWire) {
42
+ this.project = project;
43
+ this.inferrer = inferrer;
44
+ this.jsonWire = jsonWire;
45
+ }
46
+ /**
47
+ * Judge every item, spending at most `budgetMs`. Each item rebuilds the
48
+ * program at least twice, so a consumer with many calls could otherwise
49
+ * outrun the caller's read deadline and lose every answer; the items the
50
+ * budget does not reach abstain and say so.
51
+ */
52
+ run(items, budgetMs) {
53
+ const deadline = performance.now() + budgetMs;
54
+ // What each file said before any rewrite. Every rewrite is undone, so it
55
+ // is the same for every item in the file.
56
+ const before = new Map();
57
+ return items.map((item) => {
58
+ if (performance.now() >= deadline) {
59
+ return abstain(item, `the retype check ran out of its ${budgetMs}ms budget`);
60
+ }
61
+ try {
62
+ return this.runOne(item, before);
63
+ }
64
+ catch (err) {
65
+ return abstain(item, `the retype check failed: ${err instanceof Error ? err.message : String(err)}`);
66
+ }
67
+ });
68
+ }
69
+ runOne(item, before) {
70
+ const producer = oneLine(item.producer_type);
71
+ if (!producer)
72
+ return abstain(item, "the producer's response type is empty");
73
+ if (producer.includes('//') || producer.includes('/*')) {
74
+ // Collapsing the text onto one line would turn a line comment into a
75
+ // comment over the rest of the call.
76
+ return abstain(item, "the producer's response type carries a comment");
77
+ }
78
+ const located = this.inferrer.locateCall(asLocator(item));
79
+ if (!located)
80
+ return abstain(item, 'the consumer call could not be located in its file');
81
+ const { sourceFile, call } = located;
82
+ if (resultIsDiscarded(call)) {
83
+ // Nothing reads the response, so nothing can disagree with it: an
84
+ // "agrees" here would be a verdict about no comparison at all.
85
+ return abstain(item, 'the consumer never reads the response');
86
+ }
87
+ const escape = resultEscapes(call);
88
+ if (escape) {
89
+ // Only this file's diagnostics are compared, so reads made where the
90
+ // value escapes to are invisible and an "agrees" would claim them.
91
+ return abstain(item, escape);
92
+ }
93
+ const plan = this.plan(call, producer, item.wire);
94
+ if (typeof plan === 'string')
95
+ return abstain(item, plan);
96
+ const original = sourceFile.getFullText();
97
+ let pre = before.get(sourceFile);
98
+ if (!pre) {
99
+ pre = fileDiagnostics(sourceFile);
100
+ before.set(sourceFile, pre);
101
+ }
102
+ const decisive = this.check(sourceFile, original, plan(true), pre);
103
+ if (decisive.kind === 'abstain')
104
+ return abstain(item, decisive.reason);
105
+ if (decisive.added.length === 0) {
106
+ return {
107
+ item_id: item.item_id,
108
+ outcome: 'agrees',
109
+ diagnostics: [],
110
+ };
111
+ }
112
+ // The wire form decided it. The DECLARED form prints the producer's own
113
+ // type in each message instead of the transform's name, so its text is
114
+ // used wherever it reports the same place; the decision is not re-made.
115
+ let messages = new Map();
116
+ if (item.wire) {
117
+ const plain = this.check(sourceFile, original, plan(false), pre);
118
+ if (plain.kind === 'checked') {
119
+ messages = new Map(plain.added.map((d) => [key(d), d.message]));
120
+ }
121
+ }
122
+ const lineOf = lineIndex(original);
123
+ return {
124
+ item_id: item.item_id,
125
+ outcome: 'mismatch',
126
+ diagnostics: decisive.added.map((d) => ({
127
+ line: lineOf(d.start),
128
+ code: d.code,
129
+ message: messages.get(key(d)) ?? d.message,
130
+ })),
131
+ };
132
+ }
133
+ /**
134
+ * How to state the producer's type at this call, or why it cannot be done.
135
+ * Returns a builder so the wire and the declared form share one decision.
136
+ */
137
+ plan(call, producer, wire) {
138
+ const stated = (useWire) => useWire && wire ? `${WIRE}<(${producer})>` : `(${producer})`;
139
+ const callStart = call.getStart();
140
+ const callEnd = call.getEnd();
141
+ const typeArgs = call.getTypeArguments();
142
+ if (typeArgs.length > 0) {
143
+ // The source states a type argument already. The producer's type
144
+ // replaces it: what is judged is how the source USES the response, not
145
+ // what it claimed the response was.
146
+ // Positions are read now: the rewrite forgets every node of the file.
147
+ const argStart = typeArgs[0].getStart();
148
+ const argEnd = typeArgs[0].getEnd();
149
+ return (useWire) => {
150
+ const text = stated(useWire);
151
+ const delta = text.length - (argEnd - argStart);
152
+ return {
153
+ edits: [{ start: argStart, end: argEnd, text }],
154
+ wire: useWire && wire,
155
+ typeArgumentCall: { start: callStart, end: callEnd + delta },
156
+ };
157
+ };
158
+ }
159
+ if (declaresTypeParameters(call)) {
160
+ const open = call.getFirstChildByKind(SyntaxKind.OpenParenToken);
161
+ if (!open)
162
+ return 'the consumer call has no argument list to retype';
163
+ const at = open.getStart();
164
+ return (useWire) => {
165
+ const text = `<${stated(useWire)}>`;
166
+ return {
167
+ edits: [{ start: at, end: at, text }],
168
+ wire: useWire && wire,
169
+ typeArgumentCall: { start: callStart, end: callEnd + text.length },
170
+ };
171
+ };
172
+ }
173
+ // No type parameter to state. A cast of the call's result is not
174
+ // offered: an untyped helper declared `Promise<any>` may return a whole
175
+ // client envelope, and casting that to the payload would report every
176
+ // `.data` read as a break (ruling on PR #1492).
177
+ if (!resolvedDeclaration(call)) {
178
+ return 'the consumer call does not resolve in its program (is the client installed?)';
179
+ }
180
+ // A typed transport response (`fetch`) whose body the source reads with
181
+ // `.json()`: the payload is that read, so the cast goes there
182
+ // (carrick#1493). The call's own result is not the payload and is not
183
+ // touched.
184
+ const reads = bodyReadsOf(call);
185
+ if (typeof reads === 'string')
186
+ return reads;
187
+ if (reads.length > 0) {
188
+ const edits = reads.map(bodyReadEdit);
189
+ return (useWire) => {
190
+ const text = stated(useWire);
191
+ return {
192
+ edits: edits.flatMap((edit) => edit(text)),
193
+ wire: useWire && wire,
194
+ };
195
+ };
196
+ }
197
+ return (`the consumer call takes no type argument and returns ` +
198
+ `'${call.getType().getText(call)}', so it cannot be retyped`);
199
+ }
200
+ /**
201
+ * Apply one rewrite, type-check, restore. Returns the diagnostics the
202
+ * rewrite ADDED, in original-file positions.
203
+ */
204
+ check(sourceFile, original, rewrite, pre) {
205
+ // Appended only when the rewrite names them: an alias nothing references
206
+ // is a TS6196 under `noUnusedLocals`, which would read as the producer's
207
+ // type failing to resolve.
208
+ const appendix = rewrite.wire
209
+ ? '\n' +
210
+ [
211
+ ...this.jsonWire(PREFIX),
212
+ `type ${WIRE}<P> = ${PREFIX}JsonWireSame<${PREFIX}JsonWire<P>, P> extends true ? P : ${PREFIX}JsonWire<P>;`,
213
+ ].join('\n') +
214
+ '\n'
215
+ : '';
216
+ const rewritten = applyEdits(original, rewrite.edits) + appendix;
217
+ const bodyEnd = rewritten.length - appendix.length;
218
+ const originOf = (pos) => pos >= bodyEnd ? { kind: 'inserted' } : mapBack(pos, rewrite.edits);
219
+ sourceFile.replaceWithText(rewritten);
220
+ try {
221
+ if (rewrite.typeArgumentCall) {
222
+ const reached = this.typeArgumentReachesResult(sourceFile, rewrite.typeArgumentCall);
223
+ if (reached !== true) {
224
+ return { kind: 'abstain', reason: reached };
225
+ }
226
+ }
227
+ const post = fileDiagnostics(sourceFile);
228
+ const inserted = post.filter((d) => originOf(d.start).kind === 'inserted');
229
+ if (inserted.length > 0) {
230
+ return {
231
+ kind: 'abstain',
232
+ reason: "the producer's response type does not resolve in the consumer's program: " +
233
+ `TS${inserted[0].code}: ${inserted[0].message}`,
234
+ };
235
+ }
236
+ const remaining = new Map();
237
+ for (const d of pre)
238
+ remaining.set(key(d), (remaining.get(key(d)) ?? 0) + 1);
239
+ const added = [];
240
+ for (const d of post) {
241
+ const origin = originOf(d.start);
242
+ const mapped = { ...d, start: origin.pos };
243
+ const k = key(mapped);
244
+ const left = remaining.get(k) ?? 0;
245
+ if (left > 0) {
246
+ remaining.set(k, left - 1);
247
+ continue;
248
+ }
249
+ added.push(mapped);
250
+ }
251
+ added.sort((a, b) => a.start - b.start || a.code - b.code);
252
+ return { kind: 'checked', added };
253
+ }
254
+ finally {
255
+ sourceFile.replaceWithText(original);
256
+ }
257
+ }
258
+ /**
259
+ * The stated type argument must be what the call RETURNS (or a type
260
+ * argument or member of it): the rewrite is only a statement about the
261
+ * response when the parameter it fills carries the response. A parameter
262
+ * that types something else — a request body, a config — would judge the
263
+ * consumer against the wrong thing, so the item abstains.
264
+ */
265
+ typeArgumentReachesResult(sourceFile, at) {
266
+ const call = sourceFile
267
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
268
+ .find((c) => c.getStart() === at.start && c.getEnd() === at.end);
269
+ if (!call)
270
+ return 'the retyped call could not be found again after the rewrite';
271
+ const stated = call.getTypeArguments()[0]?.getType();
272
+ if (!stated)
273
+ return 'the retyped call carries no type argument';
274
+ const checker = this.project.getTypeChecker().compilerObject;
275
+ const returned = call.getType().compilerType;
276
+ const result = checker.getAwaitedType(returned) ?? returned;
277
+ const target = stated.compilerType;
278
+ if (result === target)
279
+ return true;
280
+ const reference = result;
281
+ const args = [
282
+ ...(result.aliasTypeArguments ?? []),
283
+ ...(result.flags & ts.TypeFlags.Object &&
284
+ result.objectFlags & ts.ObjectFlags.Reference
285
+ ? checker.getTypeArguments(reference)
286
+ : []),
287
+ ];
288
+ if (args.includes(target))
289
+ return true;
290
+ for (const property of checker.getPropertiesOfType(result)) {
291
+ if (checker.getTypeOfSymbolAtLocation(property, call.compilerNode) === target)
292
+ return true;
293
+ }
294
+ return ("the call's type argument does not reach its result " +
295
+ `('${checker.typeToString(result)}'), so it does not state the response`);
296
+ }
297
+ }
298
+ function abstain(item, reason) {
299
+ return { item_id: item.item_id, outcome: 'abstain', diagnostics: [], reason };
300
+ }
301
+ function asLocator(item) {
302
+ return {
303
+ file_path: item.file_path,
304
+ line_number: item.line_number,
305
+ span_start: item.span_start,
306
+ span_end: item.span_end,
307
+ expression_text: item.expression_text,
308
+ expression_line: item.expression_line,
309
+ infer_kind: 'call_result',
310
+ };
311
+ }
312
+ /** Whitespace collapsed, so the rewrite never moves a line. */
313
+ function oneLine(text) {
314
+ return text.replace(/\s+/g, ' ').trim().replace(/;$/, '').trim();
315
+ }
316
+ /** `await call;` / `call;` / `void call;` — the value goes nowhere. */
317
+ function resultIsDiscarded(call) {
318
+ let node = call;
319
+ let parent = node.getParent();
320
+ while (parent &&
321
+ (Node.isAwaitExpression(parent) ||
322
+ Node.isParenthesizedExpression(parent) ||
323
+ Node.isVoidExpression(parent))) {
324
+ if (Node.isVoidExpression(parent))
325
+ return true;
326
+ node = parent;
327
+ parent = node.getParent();
328
+ }
329
+ return !!parent && Node.isExpressionStatement(parent);
330
+ }
331
+ function resolvedDeclaration(call) {
332
+ const signature = call
333
+ .getProject()
334
+ .getTypeChecker()
335
+ .compilerObject.getResolvedSignature(call.compilerNode);
336
+ return signature?.getDeclaration();
337
+ }
338
+ /**
339
+ * Why the call's result leaves what this file's type-check can see, or
340
+ * `undefined` when every use of it stays in view.
341
+ *
342
+ * The retype diffs one file's diagnostics. A value returned from a function
343
+ * whose return type is inferred carries the producer's type to the callers,
344
+ * wherever they are, and one bound to an exported name carries it out of the
345
+ * file. A function that DECLARES its return type is the opposite: the return
346
+ * is checked against the declaration right here, which is how a typed wrapper
347
+ * is judged. A callback handed to a call returns into that call, which is
348
+ * also in view.
349
+ */
350
+ function resultEscapes(call, throughCasts = false) {
351
+ let top = call;
352
+ while (Node.isAwaitExpression(top.getParentOrThrow()) ||
353
+ Node.isParenthesizedExpression(top.getParentOrThrow()) ||
354
+ (throughCasts && Node.isAsExpression(top.getParentOrThrow()))) {
355
+ top = top.getParentOrThrow();
356
+ }
357
+ if (returnsUndeclared(top)) {
358
+ return 'the response is returned from a function with no declared return type, so its readers are elsewhere';
359
+ }
360
+ const parent = top.getParent();
361
+ if (!parent || !Node.isVariableDeclaration(parent) || parent.getInitializer() !== top) {
362
+ return undefined;
363
+ }
364
+ if (parent.getVariableStatement()?.isExported()) {
365
+ return 'the response is bound to an exported name, so its readers are elsewhere';
366
+ }
367
+ const names = Node.isIdentifier(parent.getNameNode())
368
+ ? [parent.getNameNode()]
369
+ : parent.getNameNode().getDescendantsOfKind(SyntaxKind.Identifier);
370
+ for (const name of names) {
371
+ if (!Node.isIdentifier(name))
372
+ continue;
373
+ for (const ref of name.findReferencesAsNodes()) {
374
+ if (returnsUndeclared(ref)) {
375
+ return 'the response is returned from a function with no declared return type, so its readers are elsewhere';
376
+ }
377
+ }
378
+ }
379
+ return undefined;
380
+ }
381
+ /** `node` is (part of) what a function with an inferred return type returns. */
382
+ function returnsUndeclared(node) {
383
+ for (let at = node; at; at = at.getParent()) {
384
+ const parent = at.getParent();
385
+ if (!parent)
386
+ return false;
387
+ const returned = Node.isReturnStatement(parent) ||
388
+ (Node.isArrowFunction(parent) && parent.getBody() === at);
389
+ if (returned) {
390
+ const fn = Node.isReturnStatement(parent)
391
+ ? parent.getFirstAncestor((n) => Node.isFunctionDeclaration(n) ||
392
+ Node.isFunctionExpression(n) ||
393
+ Node.isArrowFunction(n) ||
394
+ Node.isMethodDeclaration(n))
395
+ : parent;
396
+ if (!fn || !('getReturnTypeNode' in fn))
397
+ return false;
398
+ const declared = fn.getReturnTypeNode();
399
+ if (declared)
400
+ return false;
401
+ // A callback returns into the call it is handed to, which is in view.
402
+ return !Node.isCallExpression(fn.getParent());
403
+ }
404
+ // A statement inside a block is not what the block's function returns.
405
+ if (Node.isBlock(parent))
406
+ return false;
407
+ }
408
+ return false;
409
+ }
410
+ /**
411
+ * The `.json()` body reads that carry this call's payload, or why the ones
412
+ * the source makes cannot be judged (carrick#1493). An empty list means the
413
+ * source reads no JSON body off the result.
414
+ *
415
+ * The located call may be the body read itself (`res.json()`), or the call
416
+ * whose result the source reads it from: `(await fetch(u)).json()`, or
417
+ * `const res = await fetch(u)` followed by `res.json()` on that binding.
418
+ *
419
+ * A read counts only when it is on THIS call's result: the binding is a
420
+ * plain name that is never reassigned, and every other use of it is a member
421
+ * read (`res.ok`, `res.status`). A response handed whole to anything else
422
+ * may have its body read out of view, so an agreement here would claim reads
423
+ * the diff cannot see.
424
+ */
425
+ function bodyReadsOf(call) {
426
+ if (isBodyRead(call))
427
+ return checkedBodyReads([call]);
428
+ let top = call;
429
+ while (Node.isAwaitExpression(top.getParentOrThrow()) ||
430
+ Node.isParenthesizedExpression(top.getParentOrThrow())) {
431
+ top = top.getParentOrThrow();
432
+ }
433
+ const chained = bodyReadOn(top);
434
+ if (chained)
435
+ return checkedBodyReads([chained]);
436
+ const declaration = top.getParent();
437
+ if (!declaration ||
438
+ !Node.isVariableDeclaration(declaration) ||
439
+ declaration.getInitializer() !== top) {
440
+ return [];
441
+ }
442
+ const name = declaration.getNameNode();
443
+ if (!Node.isIdentifier(name))
444
+ return [];
445
+ const reads = [];
446
+ let handedOn = false;
447
+ for (const ref of name.findReferencesAsNodes()) {
448
+ const read = bodyReadOn(ref);
449
+ if (read) {
450
+ reads.push(read);
451
+ continue;
452
+ }
453
+ const parent = ref.getParent();
454
+ if (parent &&
455
+ Node.isBinaryExpression(parent) &&
456
+ parent.getLeft() === ref &&
457
+ isAssignmentOperator(parent.getOperatorToken().getKind())) {
458
+ return 'the response binding is reassigned, so its body may not be this call\'s payload';
459
+ }
460
+ if (!parent || !Node.isPropertyAccessExpression(parent) || parent.getExpression() !== ref) {
461
+ handedOn = true;
462
+ }
463
+ }
464
+ if (reads.length === 0)
465
+ return [];
466
+ if (handedOn) {
467
+ return 'the response object is handed on, so its body may be read elsewhere';
468
+ }
469
+ return checkedBodyReads(reads);
470
+ }
471
+ /**
472
+ * Why these body reads cannot be retyped, or the reads to retype.
473
+ *
474
+ * Only the SUCCESS path's read carries the producer's response (ruling on
475
+ * PR #1505: "cast the body read, never the Response, success path only"). A
476
+ * read under a failed-status test (`if (!res.ok) { const e = await
477
+ * res.json(); ... }`), or one whose result is used only there, parses an
478
+ * error body the producer's response type does not describe, so it is left
479
+ * alone. A read whose result is used on BOTH paths cannot be retyped without
480
+ * judging the error branch against the success type, so the item abstains.
481
+ */
482
+ function checkedBodyReads(all) {
483
+ const checker = all[0].getProject().getTypeChecker().compilerObject;
484
+ const isResponse = responseTest(all[0]);
485
+ const reads = [];
486
+ let errorReads = 0;
487
+ for (const read of all) {
488
+ const side = sideOf(read, isResponse);
489
+ if (side === 'failure') {
490
+ errorReads++;
491
+ continue;
492
+ }
493
+ if (side === 'unclear') {
494
+ return 'the body read sits under a status test whose failing side is unclear';
495
+ }
496
+ const uses = resultNames(read).flatMap((name) => name.findReferencesAsNodes());
497
+ const sides = uses.map((use) => sideOf(use, isResponse));
498
+ if (uses.length > 0 && sides.every((s) => s === 'failure')) {
499
+ errorReads++;
500
+ continue;
501
+ }
502
+ if (sides.some((s) => s === 'failure' || s === 'unclear')) {
503
+ return 'the body read serves both the success and the error path, so it is not retyped';
504
+ }
505
+ reads.push(read);
506
+ }
507
+ if (reads.length === 0) {
508
+ return errorReads > 0
509
+ ? 'the consumer reads the response body only on its error path'
510
+ : 'the consumer never reads the response body';
511
+ }
512
+ for (const read of reads) {
513
+ const returned = read.getType().compilerType;
514
+ const body = checker.getAwaitedType(returned) ?? returned;
515
+ if (!(body.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown))) {
516
+ // The source's own client states what the body is; that is the
517
+ // consumer's contract, and it is compared as one, not retyped.
518
+ return (`the consumer's body read already states a type ` +
519
+ `('${checker.typeToString(body)}'), so it is not retyped`);
520
+ }
521
+ const typed = typedAround(read);
522
+ if (typed)
523
+ return `the consumer's body read already states a type (${typed}), so it is not retyped`;
524
+ }
525
+ if (reads.every((read) => resultIsDiscarded(read))) {
526
+ return 'the consumer never reads the response body';
527
+ }
528
+ for (const read of reads) {
529
+ const escape = resultEscapes(read, true);
530
+ if (escape)
531
+ return escape.replace('the response', 'the response body');
532
+ }
533
+ return reads;
534
+ }
535
+ /**
536
+ * What the source wraps around the parsed body beyond the ONE cast the
537
+ * retype replaces: a second cast (`as unknown as Order`), an angle-bracket
538
+ * assertion, or a call it is handed to (`parse(await res.json())`). Each
539
+ * states or launders the body's type where the retype cannot reach, so a
540
+ * replaced inner type would agree with anything.
541
+ */
542
+ function typedAround(read) {
543
+ let node = read;
544
+ let cast = false;
545
+ for (let parent = node.getParent(); parent; node = parent, parent = parent.getParent()) {
546
+ if (Node.isAwaitExpression(parent) || Node.isParenthesizedExpression(parent))
547
+ continue;
548
+ if (Node.isAsExpression(parent) && !cast) {
549
+ cast = true;
550
+ continue;
551
+ }
552
+ if (Node.isAsExpression(parent))
553
+ return 'a second cast';
554
+ if (Node.isTypeAssertion(parent))
555
+ return 'a type assertion';
556
+ if ((Node.isCallExpression(parent) || Node.isNewExpression(parent)) &&
557
+ parent.getArguments().includes(node)) {
558
+ return 'a call it is handed to';
559
+ }
560
+ return undefined;
561
+ }
562
+ return undefined;
563
+ }
564
+ /** The names the read's parsed body is bound to, if it is bound. */
565
+ function resultNames(read) {
566
+ let top = read;
567
+ for (let parent = top.getParent(); parent; parent = top.getParent()) {
568
+ if (!Node.isAwaitExpression(parent) &&
569
+ !Node.isParenthesizedExpression(parent) &&
570
+ !Node.isAsExpression(parent)) {
571
+ break;
572
+ }
573
+ top = parent;
574
+ }
575
+ const declaration = top.getParent();
576
+ if (!declaration || !Node.isVariableDeclaration(declaration) || declaration.getInitializer() !== top) {
577
+ return [];
578
+ }
579
+ const name = declaration.getNameNode();
580
+ return Node.isIdentifier(name)
581
+ ? [name]
582
+ : name.getDescendantsOfKind(SyntaxKind.Identifier).filter((id) => {
583
+ const parent = id.getParent();
584
+ return Node.isBindingElement(parent) && parent.getNameNode() === id;
585
+ });
586
+ }
587
+ /** Whether a node names the response the read is on. */
588
+ function responseTest(read) {
589
+ const receiver = read.getExpression().getExpression();
590
+ const symbol = Node.isIdentifier(receiver) ? receiver.getSymbol() : undefined;
591
+ if (!symbol)
592
+ return () => false;
593
+ return (node) => Node.isIdentifier(node) && node.getSymbol() === symbol;
594
+ }
595
+ /**
596
+ * Which side of a test of the response's status `node` runs on: inside a
597
+ * branch of one, or after an `if (test) return/throw` that leaves the rest of
598
+ * the block to the other side. `undefined` when no test decides it.
599
+ */
600
+ function sideOf(node, isResponse) {
601
+ let found;
602
+ const note = (side) => {
603
+ if (side === 'failure' || found === 'failure')
604
+ found = 'failure';
605
+ else if (side === 'unclear' || found === 'unclear')
606
+ found = 'unclear';
607
+ else
608
+ found = side ?? found;
609
+ };
610
+ for (let child = node, parent = node.getParent(); parent; child = parent, parent = parent.getParent()) {
611
+ if (Node.isIfStatement(parent) && child !== parent.getExpression()) {
612
+ note(branchSide(parent.getExpression(), child === parent.getThenStatement(), isResponse));
613
+ }
614
+ else if (Node.isConditionalExpression(parent) && child !== parent.getCondition()) {
615
+ note(branchSide(parent.getCondition(), child === parent.getWhenTrue(), isResponse));
616
+ }
617
+ else if (Node.isCaseClause(parent) || Node.isDefaultClause(parent)) {
618
+ const swtch = parent.getParent()?.getParent();
619
+ if (swtch && Node.isSwitchStatement(swtch) && testsResponse(swtch.getExpression(), isResponse)) {
620
+ note('unclear');
621
+ }
622
+ }
623
+ if (Node.isBlock(parent) || Node.isSourceFile(parent) || Node.isCaseClause(parent)) {
624
+ for (const statement of parent.getStatements()) {
625
+ if (statement === child)
626
+ break;
627
+ if (Node.isIfStatement(statement) &&
628
+ !statement.getElseStatement() &&
629
+ exits(statement.getThenStatement())) {
630
+ note(branchSide(statement.getExpression(), false, isResponse));
631
+ }
632
+ }
633
+ }
634
+ }
635
+ return found;
636
+ }
637
+ function branchSide(condition, whenTrue, isResponse) {
638
+ const ok = okWhenTrue(condition, isResponse);
639
+ if (ok === undefined || ok === 'unclear')
640
+ return ok;
641
+ return ok === whenTrue ? 'success' : 'failure';
642
+ }
643
+ /**
644
+ * Whether `condition` being true means the response succeeded: `res.ok`,
645
+ * `res.status === 200`, `res.status !== 200`, `res.status >= 400` and their
646
+ * negations. Any other test of the response is `'unclear'`; a condition that does not
647
+ * test the response is `undefined`.
648
+ */
649
+ function okWhenTrue(condition, isResponse) {
650
+ let e = condition;
651
+ while (Node.isParenthesizedExpression(e))
652
+ e = e.getExpression();
653
+ if (Node.isPrefixUnaryExpression(e) && e.getOperatorToken() === SyntaxKind.ExclamationToken) {
654
+ const inner = okWhenTrue(e.getOperand(), isResponse);
655
+ return typeof inner === 'boolean' ? !inner : inner;
656
+ }
657
+ if (isMember(e, 'ok', isResponse))
658
+ return true;
659
+ if (Node.isBinaryExpression(e)) {
660
+ const op = e.getOperatorToken().getKind();
661
+ const [left, right] = [e.getLeft(), e.getRight()];
662
+ const value = isMember(left, 'status', isResponse) && Node.isNumericLiteral(right)
663
+ ? right.getLiteralValue()
664
+ : undefined;
665
+ if (value !== undefined) {
666
+ const success = value >= 200 && value < 300;
667
+ switch (op) {
668
+ case SyntaxKind.EqualsEqualsEqualsToken:
669
+ case SyntaxKind.EqualsEqualsToken:
670
+ return success;
671
+ case SyntaxKind.ExclamationEqualsEqualsToken:
672
+ case SyntaxKind.ExclamationEqualsToken:
673
+ return !success;
674
+ case SyntaxKind.GreaterThanEqualsToken:
675
+ if (value >= 300)
676
+ return false;
677
+ break;
678
+ }
679
+ return 'unclear';
680
+ }
681
+ }
682
+ return testsResponse(e, isResponse) ? 'unclear' : undefined;
683
+ }
684
+ function isMember(node, name, isResponse) {
685
+ return (Node.isPropertyAccessExpression(node) && node.getName() === name && isResponse(node.getExpression()));
686
+ }
687
+ /** The expression reads the response's `ok` or `status`. */
688
+ function testsResponse(node, isResponse) {
689
+ return [node, ...node.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)].some((n) => isMember(n, 'ok', isResponse) || isMember(n, 'status', isResponse));
690
+ }
691
+ /** A statement that always leaves the function. */
692
+ function exits(statement) {
693
+ if (Node.isReturnStatement(statement) || Node.isThrowStatement(statement))
694
+ return true;
695
+ if (Node.isBlock(statement)) {
696
+ const last = statement.getStatements().at(-1);
697
+ return !!last && exits(last);
698
+ }
699
+ return false;
700
+ }
701
+ /** `node.json()` with no arguments, where `node` is the receiver. */
702
+ function bodyReadOn(node) {
703
+ const access = node.getParent();
704
+ if (!access ||
705
+ !Node.isPropertyAccessExpression(access) ||
706
+ access.getExpression() !== node ||
707
+ access.getName() !== 'json') {
708
+ return undefined;
709
+ }
710
+ const read = access.getParent();
711
+ return read && Node.isCallExpression(read) && read.getExpression() === access && isBodyRead(read)
712
+ ? read
713
+ : undefined;
714
+ }
715
+ function isBodyRead(call) {
716
+ const callee = call.getExpression();
717
+ return (Node.isPropertyAccessExpression(callee) &&
718
+ callee.getName() === 'json' &&
719
+ call.getArguments().length === 0);
720
+ }
721
+ function isAssignmentOperator(kind) {
722
+ return kind >= SyntaxKind.FirstAssignment && kind <= SyntaxKind.LastAssignment;
723
+ }
724
+ /**
725
+ * The edits that state `P` at one body read. A cast the source wrote around
726
+ * the parsed body (`(await res.json()) as Order`) is REPLACED, as a type
727
+ * argument the source wrote is: what is judged is how the source uses the
728
+ * body, not what it claimed the body was. Otherwise the read itself is cast:
729
+ * `(res.json() as Promise<P>)`.
730
+ */
731
+ function bodyReadEdit(read) {
732
+ let node = read;
733
+ let awaited = false;
734
+ for (let parent = node.getParent(); parent; parent = node.getParent()) {
735
+ if (Node.isAwaitExpression(parent))
736
+ awaited = true;
737
+ else if (Node.isAsExpression(parent)) {
738
+ const type = parent.getTypeNodeOrThrow();
739
+ const start = type.getStart();
740
+ const end = type.getEnd();
741
+ return (stated) => [{ start, end, text: awaited ? stated : `Promise<${stated}>` }];
742
+ }
743
+ else if (!Node.isParenthesizedExpression(parent))
744
+ break;
745
+ node = parent;
746
+ }
747
+ const start = read.getStart();
748
+ const end = read.getEnd();
749
+ return (stated) => [
750
+ // The compiler reports past a parenthesis, so a finding on the cast read
751
+ // lands on `res`, an original position, never on this one.
752
+ { start, end: start, text: '(' },
753
+ { start: end, end, text: ` as Promise<${stated}>)` },
754
+ ];
755
+ }
756
+ function declaresTypeParameters(call) {
757
+ return (resolvedDeclaration(call)?.typeParameters?.length ?? 0) > 0;
758
+ }
759
+ function fileDiagnostics(sourceFile) {
760
+ const program = sourceFile.getProject().getProgram().compilerObject;
761
+ const node = sourceFile.compilerNode;
762
+ return [...program.getSyntacticDiagnostics(node), ...program.getSemanticDiagnostics(node)]
763
+ .filter((d) => d.file === node && d.start !== undefined)
764
+ .map((d) => ({ start: d.start, code: d.code, message: flatten(d.messageText) }));
765
+ }
766
+ function flatten(text) {
767
+ return ts.flattenDiagnosticMessageText(text, ' ');
768
+ }
769
+ function key(d) {
770
+ return `${d.start}:${d.code}`;
771
+ }
772
+ function applyEdits(text, edits) {
773
+ let out = '';
774
+ let cursor = 0;
775
+ for (const edit of [...edits].sort((a, b) => a.start - b.start)) {
776
+ out += text.slice(cursor, edit.start) + edit.text;
777
+ cursor = edit.end;
778
+ }
779
+ return out + text.slice(cursor);
780
+ }
781
+ /** Map a position in the rewritten text back to the original, or mark it ours. */
782
+ function mapBack(pos, edits) {
783
+ let shift = 0;
784
+ for (const edit of [...edits].sort((a, b) => a.start - b.start)) {
785
+ const newStart = edit.start + shift;
786
+ const newEnd = newStart + edit.text.length;
787
+ if (pos < newStart)
788
+ break;
789
+ if (pos < newEnd)
790
+ return { kind: 'inserted' };
791
+ shift += edit.text.length - (edit.end - edit.start);
792
+ }
793
+ return { kind: 'original', pos: pos - shift };
794
+ }
795
+ /** 1-based line of an original-file position. */
796
+ function lineIndex(text) {
797
+ const starts = [0];
798
+ for (let i = 0; i < text.length; i++)
799
+ if (text[i] === '\n')
800
+ starts.push(i + 1);
801
+ return (pos) => {
802
+ let lo = 0;
803
+ let hi = starts.length - 1;
804
+ while (lo < hi) {
805
+ const mid = (lo + hi + 1) >> 1;
806
+ if (starts[mid] <= pos)
807
+ lo = mid;
808
+ else
809
+ hi = mid - 1;
810
+ }
811
+ return lo + 1;
812
+ };
813
+ }