carrick 0.3.93 → 0.3.95
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +6 -6
- package/plugin/.claude-plugin/plugin.json +18 -3
- package/sidecar/dist/src/retype.d.ts +5 -0
- package/sidecar/dist/src/retype.js +26 -11
- package/sidecar/dist/src/type-inferrer.d.ts +33 -0
- package/sidecar/dist/src/type-inferrer.js +211 -1
- package/sidecar/dist/src/types.d.ts +20 -1
- package/sidecar/dist/src/unwidened.d.ts +101 -0
- package/sidecar/dist/src/unwidened.js +272 -0
- package/sidecar/dist/src/validators.d.ts +10 -0
- package/sidecar/dist/src/validators.js +1 -0
package/README.md
CHANGED
|
@@ -182,7 +182,8 @@ for the machine-readable shape, pinned in
|
|
|
182
182
|
## Where the answers land
|
|
183
183
|
|
|
184
184
|
- **Claude Code**: the hooks `carrick init` writes deliver on the edit itself.
|
|
185
|
-
`claude
|
|
185
|
+
`claude plugin marketplace add carrick-tools/carrick` then
|
|
186
|
+
`claude plugin install carrick@carrick` adds the language server too.
|
|
186
187
|
- **VS Code, Cursor, Windsurf**: the `carrick-tools.carrick` extension is a
|
|
187
188
|
client on `carrick lsp --stdio` and publishes diagnostics in the Problems
|
|
188
189
|
panel. Whether an editor-hosted agent reads those diagnostics depends on its
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "carrick",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.95",
|
|
4
4
|
"description": "The API contract index for a TypeScript workspace: what the other services do with the routes and calls in the file you are editing, in your editor and in your agent's context",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -58,11 +58,11 @@
|
|
|
58
58
|
"zod": "^3.23.0"
|
|
59
59
|
},
|
|
60
60
|
"optionalDependencies": {
|
|
61
|
-
"@carrick-tools/cli-darwin-arm64": "0.3.
|
|
62
|
-
"@carrick-tools/cli-darwin-x64": "0.3.
|
|
63
|
-
"@carrick-tools/cli-linux-arm64": "0.3.
|
|
64
|
-
"@carrick-tools/cli-linux-x64": "0.3.
|
|
65
|
-
"@carrick-tools/cli-win32-x64": "0.3.
|
|
61
|
+
"@carrick-tools/cli-darwin-arm64": "0.3.95",
|
|
62
|
+
"@carrick-tools/cli-darwin-x64": "0.3.95",
|
|
63
|
+
"@carrick-tools/cli-linux-arm64": "0.3.95",
|
|
64
|
+
"@carrick-tools/cli-linux-x64": "0.3.95",
|
|
65
|
+
"@carrick-tools/cli-win32-x64": "0.3.95"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@types/node": "^24.13.3",
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "carrick",
|
|
3
|
-
"description": "
|
|
4
|
-
"version": "0.
|
|
5
|
-
"
|
|
3
|
+
"description": "Claude Code plugin for Carrick, which indexes TypeScript codebases across service and repository boundaries. After each edit it adds the file's routes, calls, and cross-service type mismatches to the session, and it registers Carrick's language server. It pairs with the Carrick MCP server, which lets agents search functions by intent rather than name.",
|
|
4
|
+
"version": "0.3.95",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Carrick",
|
|
7
|
+
"email": "hello@carrick.tools"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://carrick.tools",
|
|
10
|
+
"repository": "https://github.com/carrick-tools/carrick",
|
|
11
|
+
"license": "Elastic-2.0",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"typescript",
|
|
14
|
+
"mcp",
|
|
15
|
+
"api-contracts",
|
|
16
|
+
"monorepo",
|
|
17
|
+
"microservices",
|
|
18
|
+
"coding-agents",
|
|
19
|
+
"language-server"
|
|
20
|
+
]
|
|
6
21
|
}
|
|
@@ -57,6 +57,11 @@ export declare class Retyper {
|
|
|
57
57
|
*/
|
|
58
58
|
run(items: RetypeItem[], budgetMs: number): RetypeOutcome[];
|
|
59
59
|
private runOne;
|
|
60
|
+
/**
|
|
61
|
+
* The rewrite that states the producer's UNWIDENED return (carrick#1516),
|
|
62
|
+
* when the item carries one that can be stated at this call.
|
|
63
|
+
*/
|
|
64
|
+
private unwidenedPlan;
|
|
60
65
|
/**
|
|
61
66
|
* How to state the producer's type at this call, or why it cannot be done.
|
|
62
67
|
* Returns a builder so the wire and the declared form share one decision.
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* project other requests read is the project the scan loaded.
|
|
26
26
|
*/
|
|
27
27
|
import { Node, SyntaxKind, ts } from 'ts-morph';
|
|
28
|
+
import { fileDiagnostics } from './unwidened.js';
|
|
28
29
|
/** Names appended to a file that is not ours carry this prefix. */
|
|
29
30
|
const PREFIX = '__carrick_';
|
|
30
31
|
const WIRE = `${PREFIX}Wire`;
|
|
@@ -96,6 +97,8 @@ export class Retyper {
|
|
|
96
97
|
const plan = this.plan(call, producer, item.wire);
|
|
97
98
|
if (typeof plan === 'string')
|
|
98
99
|
return abstain(item, plan);
|
|
100
|
+
// Planned now: a check forgets every node of the file, `call` included.
|
|
101
|
+
const unwidened = this.unwidenedPlan(call, item);
|
|
99
102
|
const original = sourceFile.getFullText();
|
|
100
103
|
let pre = before.get(sourceFile);
|
|
101
104
|
if (!pre) {
|
|
@@ -122,10 +125,21 @@ export class Retyper {
|
|
|
122
125
|
messages = new Map(plain.added.map((d) => [key(d), d.message]));
|
|
123
126
|
}
|
|
124
127
|
}
|
|
128
|
+
// carrick#1516: the published type is what the compiler inferred, with
|
|
129
|
+
// any literal the handler returns widened. When the handler's own return,
|
|
130
|
+
// read before that widening, raises nothing, the producer's type is wider
|
|
131
|
+
// than what it sends: its own verdict class, not a break. Anything short
|
|
132
|
+
// of a clean check (a diagnostic, an abstain) leaves the mismatch.
|
|
133
|
+
let outcome = 'mismatch';
|
|
134
|
+
if (unwidened) {
|
|
135
|
+
const narrowed = this.check(sourceFile, original, unwidened(true), pre);
|
|
136
|
+
if (narrowed.kind === 'checked' && narrowed.added.length === 0)
|
|
137
|
+
outcome = 'wider';
|
|
138
|
+
}
|
|
125
139
|
const lineOf = lineIndex(original);
|
|
126
140
|
return {
|
|
127
141
|
item_id: item.item_id,
|
|
128
|
-
outcome
|
|
142
|
+
outcome,
|
|
129
143
|
diagnostics: decisive.added.map((d) => ({
|
|
130
144
|
line: lineOf(d.start),
|
|
131
145
|
code: d.code,
|
|
@@ -133,6 +147,17 @@ export class Retyper {
|
|
|
133
147
|
})),
|
|
134
148
|
};
|
|
135
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* The rewrite that states the producer's UNWIDENED return (carrick#1516),
|
|
152
|
+
* when the item carries one that can be stated at this call.
|
|
153
|
+
*/
|
|
154
|
+
unwidenedPlan(call, item) {
|
|
155
|
+
const text = oneLine(item.producer_unwidened_type ?? '');
|
|
156
|
+
if (!text || text.includes('//') || text.includes('/*'))
|
|
157
|
+
return undefined;
|
|
158
|
+
const plan = this.plan(call, text, item.wire);
|
|
159
|
+
return typeof plan === 'string' ? undefined : plan;
|
|
160
|
+
}
|
|
136
161
|
/**
|
|
137
162
|
* How to state the producer's type at this call, or why it cannot be done.
|
|
138
163
|
* Returns a builder so the wire and the declared form share one decision.
|
|
@@ -830,16 +855,6 @@ function bodyReadEdit(read) {
|
|
|
830
855
|
function declaresTypeParameters(call) {
|
|
831
856
|
return (resolvedDeclaration(call)?.typeParameters?.length ?? 0) > 0;
|
|
832
857
|
}
|
|
833
|
-
function fileDiagnostics(sourceFile) {
|
|
834
|
-
const program = sourceFile.getProject().getProgram().compilerObject;
|
|
835
|
-
const node = sourceFile.compilerNode;
|
|
836
|
-
return [...program.getSyntacticDiagnostics(node), ...program.getSemanticDiagnostics(node)]
|
|
837
|
-
.filter((d) => d.file === node && d.start !== undefined)
|
|
838
|
-
.map((d) => ({ start: d.start, code: d.code, message: flatten(d.messageText) }));
|
|
839
|
-
}
|
|
840
|
-
function flatten(text) {
|
|
841
|
-
return ts.flattenDiagnosticMessageText(text, ' ');
|
|
842
|
-
}
|
|
843
858
|
function key(d) {
|
|
844
859
|
return `${d.start}:${d.code}`;
|
|
845
860
|
}
|
|
@@ -57,6 +57,11 @@ export interface TypeInferrerOptions {
|
|
|
57
57
|
* `node_modules` symlink from an installed dependency (carrick#1264).
|
|
58
58
|
*/
|
|
59
59
|
repoRoot: string;
|
|
60
|
+
/**
|
|
61
|
+
* How long the unwidened reading of one `infer` batch may take
|
|
62
|
+
* (carrick#1516). Defaults to `UNWIDENED_BUDGET_MS`.
|
|
63
|
+
*/
|
|
64
|
+
unwidenedBudgetMs?: number;
|
|
60
65
|
}
|
|
61
66
|
/**
|
|
62
67
|
* TypeInferrer - Extracts types from source code, both explicit and inferred
|
|
@@ -69,6 +74,13 @@ export declare class TypeInferrer {
|
|
|
69
74
|
private readonly project;
|
|
70
75
|
private readonly packageOf;
|
|
71
76
|
private readonly repoRoot;
|
|
77
|
+
/**
|
|
78
|
+
* The node each inferred type was read from, keyed by the location object
|
|
79
|
+
* `getNodeLocation` built for it (the one an `InferredType` carries). The
|
|
80
|
+
* unwidened reading re-reads from here (carrick#1516).
|
|
81
|
+
*/
|
|
82
|
+
private readonly readNodes;
|
|
83
|
+
private readonly unwidenedBudgetMs;
|
|
72
84
|
constructor(options: TypeInferrerOptions);
|
|
73
85
|
/**
|
|
74
86
|
* What the structural printer needs to tell the user's own declarations from
|
|
@@ -85,6 +97,27 @@ export declare class TypeInferrer {
|
|
|
85
97
|
* @returns InferResult with inferred types or errors
|
|
86
98
|
*/
|
|
87
99
|
infer(requests: InferRequestItem[], extractionConfig?: ExtractionConfig): InferResult;
|
|
100
|
+
/**
|
|
101
|
+
* carrick#1516: read each response inference again with the literals on its
|
|
102
|
+
* handler's path marked `as const`, and record the narrower type the handler
|
|
103
|
+
* really returns beside the published one (`unwidened_type_string`). See
|
|
104
|
+
* `unwidened.ts` for what is marked and why the reading is sound.
|
|
105
|
+
*
|
|
106
|
+
* One rewrite for the whole batch: every file on any request's path is
|
|
107
|
+
* rewritten once, type-checked before and after, the requests re-inferred,
|
|
108
|
+
* and every file restored before returning, so the project other requests
|
|
109
|
+
* read is the project the scan loaded.
|
|
110
|
+
*
|
|
111
|
+
* Nothing is read past `deadline`: a batch that runs out keeps the readings
|
|
112
|
+
* it made and publishes none for the rest.
|
|
113
|
+
*/
|
|
114
|
+
private addUnwidenedReadings;
|
|
115
|
+
/**
|
|
116
|
+
* The request as it locates in the rewritten files: a span moved past the
|
|
117
|
+
* insertions before it, an expression text replaced by the rewritten text of
|
|
118
|
+
* the node it named. Lines do not move (an insertion holds no newline).
|
|
119
|
+
*/
|
|
120
|
+
private mappedRequest;
|
|
88
121
|
/**
|
|
89
122
|
* Infer a single type from a request
|
|
90
123
|
*/
|
|
@@ -21,6 +21,7 @@ import * as path from 'node:path';
|
|
|
21
21
|
import { Node, SyntaxKind, ts, } from 'ts-morph';
|
|
22
22
|
import { validateInferRequestItem } from './validators.js';
|
|
23
23
|
import { isExternalOrigin } from './origin.js';
|
|
24
|
+
import { addedDiagnostics, applyInsertions, fileDiagnostics, literalInsertions, mapBack, mapForward, normalise, pathOf, } from './unwidened.js';
|
|
24
25
|
import { expandTypeStructural, } from './type-structural-expander.js';
|
|
25
26
|
/**
|
|
26
27
|
* TS/lib globals and primitives that must never be emitted as a deterministic
|
|
@@ -230,6 +231,18 @@ const TYPE_TEXT_FLAGS = ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.InT
|
|
|
230
231
|
function typeText(type, enclosingNode) {
|
|
231
232
|
return type.getText(enclosingNode, TYPE_TEXT_FLAGS);
|
|
232
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* How long the unwidened reading of one batch may take by default. The
|
|
236
|
+
* reading adds a field and never an answer, so running out costs only the
|
|
237
|
+
* readings not yet made; the rewrite is always undone.
|
|
238
|
+
*/
|
|
239
|
+
const UNWIDENED_BUDGET_MS = 120_000;
|
|
240
|
+
/**
|
|
241
|
+
* No reading starts or continues past this long after the batch began: the
|
|
242
|
+
* scanner's read deadline for one request is 900s, and the inferences the
|
|
243
|
+
* batch already made must reach it.
|
|
244
|
+
*/
|
|
245
|
+
const UNWIDENED_LATEST_MS = 600_000;
|
|
233
246
|
/**
|
|
234
247
|
* TypeInferrer - Extracts types from source code, both explicit and inferred
|
|
235
248
|
*
|
|
@@ -241,10 +254,18 @@ export class TypeInferrer {
|
|
|
241
254
|
project;
|
|
242
255
|
packageOf;
|
|
243
256
|
repoRoot;
|
|
257
|
+
/**
|
|
258
|
+
* The node each inferred type was read from, keyed by the location object
|
|
259
|
+
* `getNodeLocation` built for it (the one an `InferredType` carries). The
|
|
260
|
+
* unwidened reading re-reads from here (carrick#1516).
|
|
261
|
+
*/
|
|
262
|
+
readNodes = new WeakMap();
|
|
263
|
+
unwidenedBudgetMs;
|
|
244
264
|
constructor(options) {
|
|
245
265
|
this.project = options.project;
|
|
246
266
|
this.packageOf = options.packageOf;
|
|
247
267
|
this.repoRoot = options.repoRoot;
|
|
268
|
+
this.unwidenedBudgetMs = options.unwidenedBudgetMs ?? UNWIDENED_BUDGET_MS;
|
|
248
269
|
}
|
|
249
270
|
/**
|
|
250
271
|
* What the structural printer needs to tell the user's own declarations from
|
|
@@ -266,8 +287,11 @@ export class TypeInferrer {
|
|
|
266
287
|
* @returns InferResult with inferred types or errors
|
|
267
288
|
*/
|
|
268
289
|
infer(requests, extractionConfig) {
|
|
290
|
+
const started = performance.now();
|
|
269
291
|
const inferredTypes = [];
|
|
270
292
|
const errors = [];
|
|
293
|
+
/** Response inferences the unwidened reading re-reads (carrick#1516). */
|
|
294
|
+
const responses = [];
|
|
271
295
|
for (const request of requests) {
|
|
272
296
|
// Plain JavaScript has no type annotations to extract, and `checkJs` is
|
|
273
297
|
// off, so inferring against a `.js` file yields nothing useful — it only
|
|
@@ -287,6 +311,9 @@ export class TypeInferrer {
|
|
|
287
311
|
const result = this.inferSingle(request, extractionConfig);
|
|
288
312
|
if (result) {
|
|
289
313
|
inferredTypes.push(result);
|
|
314
|
+
if (request.infer_kind === 'response_body' || request.infer_kind === 'function_return') {
|
|
315
|
+
responses.push({ request, result });
|
|
316
|
+
}
|
|
290
317
|
}
|
|
291
318
|
else {
|
|
292
319
|
errors.push(`Could not infer type at ${request.file_path}:${loc} (${request.infer_kind})`);
|
|
@@ -298,12 +325,193 @@ export class TypeInferrer {
|
|
|
298
325
|
errors.push(`Error inferring type at ${request.file_path}:${loc}: ${error}`);
|
|
299
326
|
}
|
|
300
327
|
}
|
|
328
|
+
try {
|
|
329
|
+
const deadline = Math.min(performance.now() + this.unwidenedBudgetMs, started + UNWIDENED_LATEST_MS);
|
|
330
|
+
this.addUnwidenedReadings(responses, extractionConfig, deadline);
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
// The reading only ever adds a field; it never costs an answer.
|
|
334
|
+
this.logError(`Unwidened reading failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
335
|
+
}
|
|
301
336
|
return {
|
|
302
337
|
success: errors.length === 0 || inferredTypes.length > 0,
|
|
303
338
|
inferred_types: inferredTypes.length > 0 ? inferredTypes : undefined,
|
|
304
339
|
errors: errors.length > 0 ? errors : undefined,
|
|
305
340
|
};
|
|
306
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* carrick#1516: read each response inference again with the literals on its
|
|
344
|
+
* handler's path marked `as const`, and record the narrower type the handler
|
|
345
|
+
* really returns beside the published one (`unwidened_type_string`). See
|
|
346
|
+
* `unwidened.ts` for what is marked and why the reading is sound.
|
|
347
|
+
*
|
|
348
|
+
* One rewrite for the whole batch: every file on any request's path is
|
|
349
|
+
* rewritten once, type-checked before and after, the requests re-inferred,
|
|
350
|
+
* and every file restored before returning, so the project other requests
|
|
351
|
+
* read is the project the scan loaded.
|
|
352
|
+
*
|
|
353
|
+
* Nothing is read past `deadline`: a batch that runs out keeps the readings
|
|
354
|
+
* it made and publishes none for the rest.
|
|
355
|
+
*/
|
|
356
|
+
addUnwidenedReadings(responses, extractionConfig, deadline) {
|
|
357
|
+
const outOfTime = (stage) => {
|
|
358
|
+
if (performance.now() <= deadline)
|
|
359
|
+
return false;
|
|
360
|
+
this.log(`Unwidened reading stopped ${stage}: it ran out of its budget`);
|
|
361
|
+
return true;
|
|
362
|
+
};
|
|
363
|
+
const plans = [];
|
|
364
|
+
const insertionsByFile = new Map();
|
|
365
|
+
const checkedFiles = new Set();
|
|
366
|
+
// Every position is read before the first rewrite: a rewrite forgets
|
|
367
|
+
// every node of its file.
|
|
368
|
+
for (const { request, result } of responses) {
|
|
369
|
+
// A declared return type is the contract; nothing was widened.
|
|
370
|
+
if (result.is_explicit)
|
|
371
|
+
continue;
|
|
372
|
+
const read = this.readNodes.get(result.source_location);
|
|
373
|
+
if (!read)
|
|
374
|
+
continue;
|
|
375
|
+
const functions = pathOf(read);
|
|
376
|
+
let marked = 0;
|
|
377
|
+
for (const fn of functions) {
|
|
378
|
+
const insertions = literalInsertions(fn);
|
|
379
|
+
marked += insertions.length;
|
|
380
|
+
if (insertions.length === 0)
|
|
381
|
+
continue;
|
|
382
|
+
const file = fn.getSourceFile();
|
|
383
|
+
insertionsByFile.set(file, [...(insertionsByFile.get(file) ?? []), ...insertions]);
|
|
384
|
+
}
|
|
385
|
+
// Nothing on the path to mark: the reading would equal the published type.
|
|
386
|
+
if (marked === 0)
|
|
387
|
+
continue;
|
|
388
|
+
const path = functions.map((fn) => ({
|
|
389
|
+
file: fn.getSourceFile(),
|
|
390
|
+
start: fn.getStart(),
|
|
391
|
+
end: fn.getEnd(),
|
|
392
|
+
}));
|
|
393
|
+
for (const fn of path)
|
|
394
|
+
checkedFiles.add(fn.file);
|
|
395
|
+
let locatedByText;
|
|
396
|
+
if (request.expression_text && request.span_start === undefined) {
|
|
397
|
+
const sourceFile = this.getSourceFile(request.file_path);
|
|
398
|
+
const located = sourceFile
|
|
399
|
+
? this.findNodeByText(sourceFile, request.expression_text, request.expression_line)
|
|
400
|
+
: undefined;
|
|
401
|
+
if (located)
|
|
402
|
+
locatedByText = { start: located.getStart(), end: located.getEnd() };
|
|
403
|
+
}
|
|
404
|
+
plans.push({
|
|
405
|
+
request,
|
|
406
|
+
result,
|
|
407
|
+
read: { file: read.getSourceFile(), start: read.getStart(), end: read.getEnd() },
|
|
408
|
+
path,
|
|
409
|
+
locatedByText,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
if (plans.length === 0)
|
|
413
|
+
return;
|
|
414
|
+
const insertions = new Map();
|
|
415
|
+
for (const [file, list] of insertionsByFile)
|
|
416
|
+
insertions.set(file, normalise(list));
|
|
417
|
+
const before = new Map();
|
|
418
|
+
for (const file of checkedFiles) {
|
|
419
|
+
if (outOfTime('before the rewrite'))
|
|
420
|
+
return;
|
|
421
|
+
before.set(file, fileDiagnostics(file));
|
|
422
|
+
}
|
|
423
|
+
const originals = new Map();
|
|
424
|
+
for (const file of insertions.keys())
|
|
425
|
+
originals.set(file, file.getFullText());
|
|
426
|
+
try {
|
|
427
|
+
for (const [file, list] of insertions) {
|
|
428
|
+
file.replaceWithText(applyInsertions(originals.get(file), list));
|
|
429
|
+
}
|
|
430
|
+
// A diagnostic the marking added sits in a function on some request's
|
|
431
|
+
// path (or elsewhere in that function's file): every request whose path
|
|
432
|
+
// holds it loses its reading.
|
|
433
|
+
const tainted = new Set();
|
|
434
|
+
for (const file of checkedFiles) {
|
|
435
|
+
if (outOfTime('while checking the rewrite'))
|
|
436
|
+
return;
|
|
437
|
+
const list = insertions.get(file) ?? [];
|
|
438
|
+
for (const at of addedDiagnostics(before.get(file), fileDiagnostics(file), list)) {
|
|
439
|
+
const holders = plans.flatMap((plan) => plan.path).filter((fn) => fn.file === file && fn.start <= at && at < fn.end);
|
|
440
|
+
for (const plan of plans) {
|
|
441
|
+
const onPath = plan.path.some((fn) => holders.length > 0 ? holders.includes(fn) : fn.file === file);
|
|
442
|
+
if (onPath)
|
|
443
|
+
tainted.add(plan);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
for (const plan of plans) {
|
|
448
|
+
if (outOfTime('while re-reading'))
|
|
449
|
+
return;
|
|
450
|
+
if (tainted.has(plan)) {
|
|
451
|
+
this.log(`Unwidened reading for ${plan.request.file_path}:${plan.request.line_number} ` +
|
|
452
|
+
'dropped: marking its literals added a diagnostic on its path');
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const request = this.mappedRequest(plan.request, plan.locatedByText, insertions);
|
|
456
|
+
let again = null;
|
|
457
|
+
try {
|
|
458
|
+
again = this.inferSingle(request, extractionConfig);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
again = null;
|
|
462
|
+
}
|
|
463
|
+
if (!again || again.type_string === plan.result.type_string)
|
|
464
|
+
continue;
|
|
465
|
+
// An invariant, not a case: `mappedRequest` is what keeps the re-read
|
|
466
|
+
// on the node the inference read, so no fixture reaches this. It is
|
|
467
|
+
// what turns a locator the mapping got wrong into no reading instead
|
|
468
|
+
// of another node's type published as this handler's.
|
|
469
|
+
const readAgain = this.readNodes.get(again.source_location);
|
|
470
|
+
const list = insertions.get(plan.read.file) ?? [];
|
|
471
|
+
const sameNode = readAgain !== undefined &&
|
|
472
|
+
readAgain.getSourceFile() === plan.read.file &&
|
|
473
|
+
mapBack(readAgain.getStart(), list) === plan.read.start &&
|
|
474
|
+
mapBack(readAgain.getEnd(), list) === plan.read.end;
|
|
475
|
+
if (!sameNode) {
|
|
476
|
+
this.log(`Unwidened reading for ${plan.request.file_path}:${plan.request.line_number} ` +
|
|
477
|
+
'dropped: the re-read did not read the node the inference read');
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
plan.result.unwidened_type_string = again.type_string;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
for (const [file, text] of originals)
|
|
485
|
+
file.replaceWithText(text);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* The request as it locates in the rewritten files: a span moved past the
|
|
490
|
+
* insertions before it, an expression text replaced by the rewritten text of
|
|
491
|
+
* the node it named. Lines do not move (an insertion holds no newline).
|
|
492
|
+
*/
|
|
493
|
+
mappedRequest(request, locatedByText, insertions) {
|
|
494
|
+
const sourceFile = this.getSourceFile(request.file_path);
|
|
495
|
+
const list = sourceFile ? insertions.get(sourceFile) ?? [] : [];
|
|
496
|
+
if (list.length === 0)
|
|
497
|
+
return request;
|
|
498
|
+
if (request.span_start !== undefined && request.span_end !== undefined) {
|
|
499
|
+
return {
|
|
500
|
+
...request,
|
|
501
|
+
span_start: mapForward(request.span_start, list, 'start'),
|
|
502
|
+
span_end: mapForward(request.span_end, list, 'end'),
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
if (locatedByText && sourceFile) {
|
|
506
|
+
return {
|
|
507
|
+
...request,
|
|
508
|
+
expression_text: sourceFile
|
|
509
|
+
.getFullText()
|
|
510
|
+
.slice(mapForward(locatedByText.start, list, 'start'), mapForward(locatedByText.end, list, 'end')),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
return request;
|
|
514
|
+
}
|
|
307
515
|
/**
|
|
308
516
|
* Infer a single type from a request
|
|
309
517
|
*/
|
|
@@ -5421,13 +5629,15 @@ export class TypeInferrer {
|
|
|
5421
5629
|
getNodeLocation(node) {
|
|
5422
5630
|
const startLinePos = node.getStartLineNumber();
|
|
5423
5631
|
const endLinePos = node.getEndLineNumber();
|
|
5424
|
-
|
|
5632
|
+
const location = {
|
|
5425
5633
|
file_path: node.getSourceFile().getFilePath(),
|
|
5426
5634
|
start_line: startLinePos,
|
|
5427
5635
|
end_line: endLinePos,
|
|
5428
5636
|
start_column: node.getStart() - node.getStartLinePos(),
|
|
5429
5637
|
end_column: node.getEnd() - node.getStartLinePos(),
|
|
5430
5638
|
};
|
|
5639
|
+
this.readNodes.set(location, node);
|
|
5640
|
+
return location;
|
|
5431
5641
|
}
|
|
5432
5642
|
createInferredType(request, typeString, isExplicit, sourceLocation, payloadTypeString, primaryTypeSymbol, arrayDepth, primaryTypeSymbolSource) {
|
|
5433
5643
|
const alias = request.alias ||
|
|
@@ -300,6 +300,12 @@ export interface RetypeItem {
|
|
|
300
300
|
expression_line?: number;
|
|
301
301
|
/** The producer's response type as TypeScript text, fully inlined. */
|
|
302
302
|
producer_type: string;
|
|
303
|
+
/**
|
|
304
|
+
* The producer's response as its handler returns it, literals read before
|
|
305
|
+
* TypeScript widens them (carrick#1516), when that differs from
|
|
306
|
+
* `producer_type`. Asked only when `producer_type` raised diagnostics.
|
|
307
|
+
*/
|
|
308
|
+
producer_unwidened_type?: string;
|
|
303
309
|
/** Judge the form JSON puts on the wire (an `http` response). */
|
|
304
310
|
wire: boolean;
|
|
305
311
|
}
|
|
@@ -511,11 +517,14 @@ export interface RetypeDiagnostic {
|
|
|
511
517
|
* - `mismatch`: the rewrite added diagnostics; each is a place the consumer
|
|
512
518
|
* uses something the producer's response does not provide.
|
|
513
519
|
* - `agrees`: it added none.
|
|
520
|
+
* - `wider`: the published type added diagnostics and the handler's
|
|
521
|
+
* unwidened return added none (carrick#1516): the producer's type is wider
|
|
522
|
+
* than what it sends. `diagnostics` are the published type's.
|
|
514
523
|
* - `abstain`: the check could not be made; `reason` says why.
|
|
515
524
|
*/
|
|
516
525
|
export interface RetypeOutcome {
|
|
517
526
|
item_id: string;
|
|
518
|
-
outcome: 'mismatch' | 'agrees' | 'abstain';
|
|
527
|
+
outcome: 'mismatch' | 'agrees' | 'wider' | 'abstain';
|
|
519
528
|
diagnostics: RetypeDiagnostic[];
|
|
520
529
|
reason?: string;
|
|
521
530
|
}
|
|
@@ -615,6 +624,16 @@ export interface InferredType {
|
|
|
615
624
|
* Sorted by `path`; absent (not empty) when the type carries no top type.
|
|
616
625
|
*/
|
|
617
626
|
any_provenance?: TypeProvenance[];
|
|
627
|
+
/**
|
|
628
|
+
* carrick#1516, response inferences only: the same inference re-read with
|
|
629
|
+
* every literal on the handler's path kept at its literal type
|
|
630
|
+
* (`unwidened.ts`). `type_string` is what the compiler infers and what the
|
|
631
|
+
* index publishes; this is what the handler actually sends when TypeScript
|
|
632
|
+
* widened a literal in it (`scope: string` published, `scope: 'all' |
|
|
633
|
+
* 'specific'` sent). Absent when the two are the same, or when the reading
|
|
634
|
+
* was dropped as unsound.
|
|
635
|
+
*/
|
|
636
|
+
unwidened_type_string?: string;
|
|
618
637
|
}
|
|
619
638
|
/**
|
|
620
639
|
* Why a type carries `any`/`unknown` at a position, and where.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The unwidened reading of a producer's response (carrick#1516).
|
|
3
|
+
*
|
|
4
|
+
* A handler that returns `{ scope: row.parentId ? 'specific' : 'all' }` sends
|
|
5
|
+
* one of two strings, but its inferred return type says `scope: string`:
|
|
6
|
+
* TypeScript widens a literal that lands in a mutable position (an object
|
|
7
|
+
* property, an array element, a function's return). The index publishes that
|
|
8
|
+
* widened type, so a consumer declaring `scope: 'all' | 'specific'` is judged
|
|
9
|
+
* against a `string` the handler never sends.
|
|
10
|
+
*
|
|
11
|
+
* The published type stays what the compiler inferred. Beside it, this reads
|
|
12
|
+
* the SAME inference again with every literal on the handler's path marked
|
|
13
|
+
* `as const`, which is the compiler's own way of saying "keep this literal's
|
|
14
|
+
* type". A const-asserted literal is a regular literal type, and only fresh
|
|
15
|
+
* literal types widen, so the re-read inference is the handler's return with
|
|
16
|
+
* nothing widened: `'all' | 'specific'` for the conditional, `true`/`false`
|
|
17
|
+
* for a discriminant, `2 | 1` for a numeric choice. No rule of ours decides
|
|
18
|
+
* what a literal becomes; the compiler infers the return again.
|
|
19
|
+
*
|
|
20
|
+
* The judge uses the reading only to classify a mismatch the published type
|
|
21
|
+
* already raised: when this narrower type fits the consumer, the pair is
|
|
22
|
+
* reported as the producer's type being wider than what it sends, not as a
|
|
23
|
+
* break.
|
|
24
|
+
*
|
|
25
|
+
* Soundness rests on three things:
|
|
26
|
+
*
|
|
27
|
+
* - **A literal that feeds a mutable binding keeps its widening.** `let s =
|
|
28
|
+
* 'a'` can later hold `'b'`, so its type really is `string`; the literals
|
|
29
|
+
* that initialise a `let`/`var` are never marked. Nor is a parameter's
|
|
30
|
+
* default: it decides what callers may pass, and a caller the path does not
|
|
31
|
+
* reach could pass another value.
|
|
32
|
+
* - **No new diagnostic on the handler's path.** A marked literal that is
|
|
33
|
+
* later changed (an object built with `'a'` and then assigned `'b'`) makes
|
|
34
|
+
* the compiler report the assignment. Every file holding a function on the
|
|
35
|
+
* path is type-checked before and after, and any diagnostic the marking adds
|
|
36
|
+
* drops the reading for every request whose path includes that function.
|
|
37
|
+
* - **The re-read came from the same place.** The inference is re-run from
|
|
38
|
+
* the request, so its locator runs again; the reading is kept only when it
|
|
39
|
+
* read the same node it read the first time.
|
|
40
|
+
*
|
|
41
|
+
* The path is the function the inference read (or the function around the
|
|
42
|
+
* payload expression it read), plus every source function it calls whose
|
|
43
|
+
* return type is inferred rather than declared, transitively: a controller
|
|
44
|
+
* that returns `this.service.list()` sends what `list` returns. A callee with
|
|
45
|
+
* a declared return type is not followed; that declaration is the contract.
|
|
46
|
+
*/
|
|
47
|
+
import { Node } from 'ts-morph';
|
|
48
|
+
import type { SourceFile } from 'ts-morph';
|
|
49
|
+
/** One insertion into a file's ORIGINAL text. */
|
|
50
|
+
export interface Insertion {
|
|
51
|
+
pos: number;
|
|
52
|
+
text: string;
|
|
53
|
+
/**
|
|
54
|
+
* An opener sits before the literal it wraps, a closer after it. At one
|
|
55
|
+
* position a closer (ending the previous literal) comes before an opener.
|
|
56
|
+
*/
|
|
57
|
+
closes: boolean;
|
|
58
|
+
}
|
|
59
|
+
/** A function on a request's path, by its original span. */
|
|
60
|
+
export interface PathFunction {
|
|
61
|
+
file: SourceFile;
|
|
62
|
+
start: number;
|
|
63
|
+
end: number;
|
|
64
|
+
}
|
|
65
|
+
/** One diagnostic a file's type-check reported. */
|
|
66
|
+
export interface FileDiagnostic {
|
|
67
|
+
/** Start position in the file it was reported on. */
|
|
68
|
+
start: number;
|
|
69
|
+
code: number;
|
|
70
|
+
message: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The functions on the path of a read node: the function it is (or sits in),
|
|
74
|
+
* then every un-annotated source function called from those, breadth first.
|
|
75
|
+
* A read node outside any function is its own path.
|
|
76
|
+
*/
|
|
77
|
+
export declare function pathOf(read: Node): Node[];
|
|
78
|
+
/**
|
|
79
|
+
* The insertions that mark every literal inside `fn` whose widening the
|
|
80
|
+
* reading drops. Positions are original-file positions.
|
|
81
|
+
*/
|
|
82
|
+
export declare function literalInsertions(fn: Node): Insertion[];
|
|
83
|
+
/** Deduplicated, in text order (a closer before an opener at one position). */
|
|
84
|
+
export declare function normalise(insertions: Insertion[]): Insertion[];
|
|
85
|
+
export declare function applyInsertions(text: string, insertions: Insertion[]): string;
|
|
86
|
+
/**
|
|
87
|
+
* Where an original position lands in the rewritten text. A START keeps an
|
|
88
|
+
* opener at its own position outside (so a span starting on a marked literal
|
|
89
|
+
* covers its parentheses); an END takes every insertion at its position in.
|
|
90
|
+
*/
|
|
91
|
+
export declare function mapForward(pos: number, insertions: Insertion[], side: 'start' | 'end'): number;
|
|
92
|
+
/** The original position a rewritten one came from; inside an insertion, its position. */
|
|
93
|
+
export declare function mapBack(pos: number, insertions: Insertion[]): number;
|
|
94
|
+
/** Syntactic and semantic diagnostics reported in this file. */
|
|
95
|
+
export declare function fileDiagnostics(sourceFile: SourceFile): FileDiagnostic[];
|
|
96
|
+
/**
|
|
97
|
+
* Original positions of the diagnostics `after` holds that `before` does not,
|
|
98
|
+
* matched by original position and code. A diagnostic whose message changed
|
|
99
|
+
* because a type narrowed is the same diagnostic.
|
|
100
|
+
*/
|
|
101
|
+
export declare function addedDiagnostics(before: FileDiagnostic[], after: FileDiagnostic[], insertions: Insertion[]): number[];
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The unwidened reading of a producer's response (carrick#1516).
|
|
3
|
+
*
|
|
4
|
+
* A handler that returns `{ scope: row.parentId ? 'specific' : 'all' }` sends
|
|
5
|
+
* one of two strings, but its inferred return type says `scope: string`:
|
|
6
|
+
* TypeScript widens a literal that lands in a mutable position (an object
|
|
7
|
+
* property, an array element, a function's return). The index publishes that
|
|
8
|
+
* widened type, so a consumer declaring `scope: 'all' | 'specific'` is judged
|
|
9
|
+
* against a `string` the handler never sends.
|
|
10
|
+
*
|
|
11
|
+
* The published type stays what the compiler inferred. Beside it, this reads
|
|
12
|
+
* the SAME inference again with every literal on the handler's path marked
|
|
13
|
+
* `as const`, which is the compiler's own way of saying "keep this literal's
|
|
14
|
+
* type". A const-asserted literal is a regular literal type, and only fresh
|
|
15
|
+
* literal types widen, so the re-read inference is the handler's return with
|
|
16
|
+
* nothing widened: `'all' | 'specific'` for the conditional, `true`/`false`
|
|
17
|
+
* for a discriminant, `2 | 1` for a numeric choice. No rule of ours decides
|
|
18
|
+
* what a literal becomes; the compiler infers the return again.
|
|
19
|
+
*
|
|
20
|
+
* The judge uses the reading only to classify a mismatch the published type
|
|
21
|
+
* already raised: when this narrower type fits the consumer, the pair is
|
|
22
|
+
* reported as the producer's type being wider than what it sends, not as a
|
|
23
|
+
* break.
|
|
24
|
+
*
|
|
25
|
+
* Soundness rests on three things:
|
|
26
|
+
*
|
|
27
|
+
* - **A literal that feeds a mutable binding keeps its widening.** `let s =
|
|
28
|
+
* 'a'` can later hold `'b'`, so its type really is `string`; the literals
|
|
29
|
+
* that initialise a `let`/`var` are never marked. Nor is a parameter's
|
|
30
|
+
* default: it decides what callers may pass, and a caller the path does not
|
|
31
|
+
* reach could pass another value.
|
|
32
|
+
* - **No new diagnostic on the handler's path.** A marked literal that is
|
|
33
|
+
* later changed (an object built with `'a'` and then assigned `'b'`) makes
|
|
34
|
+
* the compiler report the assignment. Every file holding a function on the
|
|
35
|
+
* path is type-checked before and after, and any diagnostic the marking adds
|
|
36
|
+
* drops the reading for every request whose path includes that function.
|
|
37
|
+
* - **The re-read came from the same place.** The inference is re-run from
|
|
38
|
+
* the request, so its locator runs again; the reading is kept only when it
|
|
39
|
+
* read the same node it read the first time.
|
|
40
|
+
*
|
|
41
|
+
* The path is the function the inference read (or the function around the
|
|
42
|
+
* payload expression it read), plus every source function it calls whose
|
|
43
|
+
* return type is inferred rather than declared, transitively: a controller
|
|
44
|
+
* that returns `this.service.list()` sends what `list` returns. A callee with
|
|
45
|
+
* a declared return type is not followed; that declaration is the contract.
|
|
46
|
+
*/
|
|
47
|
+
import { Node, SyntaxKind, ts } from 'ts-morph';
|
|
48
|
+
/** How many calls deep the path follows an un-annotated callee. */
|
|
49
|
+
const MAX_FOLLOW_DEPTH = 3;
|
|
50
|
+
/** Functions one request's path may hold before following stops. */
|
|
51
|
+
const MAX_PATH_FUNCTIONS = 32;
|
|
52
|
+
const OPEN = '(';
|
|
53
|
+
const CLOSE = ' as const)';
|
|
54
|
+
/**
|
|
55
|
+
* The functions on the path of a read node: the function it is (or sits in),
|
|
56
|
+
* then every un-annotated source function called from those, breadth first.
|
|
57
|
+
* A read node outside any function is its own path.
|
|
58
|
+
*/
|
|
59
|
+
export function pathOf(read) {
|
|
60
|
+
const root = isFunctionWithBody(read) ? read : read.getFirstAncestor(isFunctionWithBody) ?? read;
|
|
61
|
+
const seen = new Set([root]);
|
|
62
|
+
const out = [root];
|
|
63
|
+
let frontier = [root];
|
|
64
|
+
for (let depth = 0; depth < MAX_FOLLOW_DEPTH && frontier.length > 0; depth++) {
|
|
65
|
+
const next = [];
|
|
66
|
+
for (const fn of frontier) {
|
|
67
|
+
for (const callee of calleesOf(fn)) {
|
|
68
|
+
if (seen.has(callee) || out.length >= MAX_PATH_FUNCTIONS)
|
|
69
|
+
continue;
|
|
70
|
+
seen.add(callee);
|
|
71
|
+
out.push(callee);
|
|
72
|
+
next.push(callee);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
frontier = next;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The source functions `fn` calls whose return type the compiler inferred. A
|
|
81
|
+
* declared return type is the callee's contract: nothing in it was widened,
|
|
82
|
+
* and marking its literals could only cost the reading a diagnostic.
|
|
83
|
+
*/
|
|
84
|
+
function calleesOf(fn) {
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const call of fn.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
87
|
+
const node = call.getProject().getTypeChecker().getResolvedSignature(call)?.getDeclaration();
|
|
88
|
+
if (!node || !isFunctionWithBody(node) || hasDeclaredReturn(node))
|
|
89
|
+
continue;
|
|
90
|
+
const file = node.getSourceFile();
|
|
91
|
+
if (file.isDeclarationFile() || file.isFromExternalLibrary() || file.isInNodeModules())
|
|
92
|
+
continue;
|
|
93
|
+
out.push(node);
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
function isFunctionWithBody(node) {
|
|
98
|
+
return ((Node.isFunctionDeclaration(node) ||
|
|
99
|
+
Node.isMethodDeclaration(node) ||
|
|
100
|
+
Node.isArrowFunction(node) ||
|
|
101
|
+
Node.isFunctionExpression(node)) &&
|
|
102
|
+
node.getBody() !== undefined);
|
|
103
|
+
}
|
|
104
|
+
function hasDeclaredReturn(node) {
|
|
105
|
+
return ((Node.isFunctionDeclaration(node) ||
|
|
106
|
+
Node.isMethodDeclaration(node) ||
|
|
107
|
+
Node.isArrowFunction(node) ||
|
|
108
|
+
Node.isFunctionExpression(node)) &&
|
|
109
|
+
node.getReturnTypeNode() !== undefined);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The insertions that mark every literal inside `fn` whose widening the
|
|
113
|
+
* reading drops. Positions are original-file positions.
|
|
114
|
+
*/
|
|
115
|
+
export function literalInsertions(fn) {
|
|
116
|
+
const out = [];
|
|
117
|
+
const visit = (node) => {
|
|
118
|
+
// A type states no value, and a parameter's default decides what its
|
|
119
|
+
// callers may pass, not what the function returns.
|
|
120
|
+
if (Node.isTypeNode(node) || Node.isParameterDeclaration(node))
|
|
121
|
+
return;
|
|
122
|
+
if (isMarkableLiteral(node)) {
|
|
123
|
+
out.push({ pos: node.getStart(), text: OPEN, closes: false });
|
|
124
|
+
out.push({ pos: node.getEnd(), text: CLOSE, closes: true });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
node.forEachChild(visit);
|
|
128
|
+
};
|
|
129
|
+
visit(fn);
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
/** Whether `node` is a literal value the reading marks `as const`. */
|
|
133
|
+
function isMarkableLiteral(node) {
|
|
134
|
+
const kind = node.getKind();
|
|
135
|
+
const literal = kind === SyntaxKind.StringLiteral ||
|
|
136
|
+
kind === SyntaxKind.NumericLiteral ||
|
|
137
|
+
kind === SyntaxKind.BigIntLiteral ||
|
|
138
|
+
kind === SyntaxKind.NoSubstitutionTemplateLiteral ||
|
|
139
|
+
kind === SyntaxKind.TrueKeyword ||
|
|
140
|
+
kind === SyntaxKind.FalseKeyword ||
|
|
141
|
+
// `-1` is marked whole: `-(1 as const)` is a `number`, and the visit
|
|
142
|
+
// never reaches the `1` inside it.
|
|
143
|
+
(Node.isPrefixUnaryExpression(node) &&
|
|
144
|
+
node.getOperatorToken() === SyntaxKind.MinusToken &&
|
|
145
|
+
(Node.isNumericLiteral(node.getOperand()) || Node.isBigIntLiteral(node.getOperand())));
|
|
146
|
+
if (!literal)
|
|
147
|
+
return false;
|
|
148
|
+
const parent = node.getParent();
|
|
149
|
+
if (!parent)
|
|
150
|
+
return false;
|
|
151
|
+
// Syntax, not a value: a quoted property name, a tagged template's text.
|
|
152
|
+
if (parent.getNameNode?.() === node)
|
|
153
|
+
return false;
|
|
154
|
+
if (Node.isTaggedTemplateExpression(parent))
|
|
155
|
+
return false;
|
|
156
|
+
return !initialisesMutableBinding(node);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Whether the literal becomes the value of a `let`/`var` binding, which can
|
|
160
|
+
* later hold another one, so its type really is the widened one. Reached
|
|
161
|
+
* through the expressions that pass a value through unchanged.
|
|
162
|
+
*/
|
|
163
|
+
function initialisesMutableBinding(literal) {
|
|
164
|
+
let node = literal;
|
|
165
|
+
for (;;) {
|
|
166
|
+
const parent = node.getParent();
|
|
167
|
+
if (!parent)
|
|
168
|
+
return false;
|
|
169
|
+
if (Node.isParenthesizedExpression(parent) ||
|
|
170
|
+
(Node.isConditionalExpression(parent) && parent.getCondition() !== node) ||
|
|
171
|
+
(Node.isBinaryExpression(parent) && passesOperandThrough(parent.getOperatorToken().getKind()))) {
|
|
172
|
+
node = parent;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (!Node.isVariableDeclaration(parent) || parent.getInitializer() !== node)
|
|
176
|
+
return false;
|
|
177
|
+
const list = parent.getParent();
|
|
178
|
+
return Node.isVariableDeclarationList(list) && (list.getFlags() & ts.NodeFlags.Const) === 0;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** Binary operators whose value is one of their operands. */
|
|
182
|
+
function passesOperandThrough(kind) {
|
|
183
|
+
return (kind === SyntaxKind.BarBarToken ||
|
|
184
|
+
kind === SyntaxKind.QuestionQuestionToken ||
|
|
185
|
+
kind === SyntaxKind.AmpersandAmpersandToken ||
|
|
186
|
+
kind === SyntaxKind.CommaToken);
|
|
187
|
+
}
|
|
188
|
+
/** Deduplicated, in text order (a closer before an opener at one position). */
|
|
189
|
+
export function normalise(insertions) {
|
|
190
|
+
const seen = new Set();
|
|
191
|
+
const out = [];
|
|
192
|
+
for (const insertion of insertions) {
|
|
193
|
+
const k = `${insertion.pos}:${insertion.closes}`;
|
|
194
|
+
if (seen.has(k))
|
|
195
|
+
continue;
|
|
196
|
+
seen.add(k);
|
|
197
|
+
out.push(insertion);
|
|
198
|
+
}
|
|
199
|
+
return out.sort((a, b) => a.pos - b.pos || Number(b.closes) - Number(a.closes));
|
|
200
|
+
}
|
|
201
|
+
export function applyInsertions(text, insertions) {
|
|
202
|
+
let out = '';
|
|
203
|
+
let cursor = 0;
|
|
204
|
+
for (const insertion of insertions) {
|
|
205
|
+
out += text.slice(cursor, insertion.pos) + insertion.text;
|
|
206
|
+
cursor = insertion.pos;
|
|
207
|
+
}
|
|
208
|
+
return out + text.slice(cursor);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Where an original position lands in the rewritten text. A START keeps an
|
|
212
|
+
* opener at its own position outside (so a span starting on a marked literal
|
|
213
|
+
* covers its parentheses); an END takes every insertion at its position in.
|
|
214
|
+
*/
|
|
215
|
+
export function mapForward(pos, insertions, side) {
|
|
216
|
+
let shift = 0;
|
|
217
|
+
for (const insertion of insertions) {
|
|
218
|
+
if (insertion.pos < pos || (insertion.pos === pos && (side === 'end' || insertion.closes))) {
|
|
219
|
+
shift += insertion.text.length;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return pos + shift;
|
|
223
|
+
}
|
|
224
|
+
/** The original position a rewritten one came from; inside an insertion, its position. */
|
|
225
|
+
export function mapBack(pos, insertions) {
|
|
226
|
+
let shift = 0;
|
|
227
|
+
for (const insertion of insertions) {
|
|
228
|
+
const at = insertion.pos + shift;
|
|
229
|
+
if (pos < at)
|
|
230
|
+
break;
|
|
231
|
+
if (pos < at + insertion.text.length)
|
|
232
|
+
return insertion.pos;
|
|
233
|
+
shift += insertion.text.length;
|
|
234
|
+
}
|
|
235
|
+
return pos - shift;
|
|
236
|
+
}
|
|
237
|
+
/** Syntactic and semantic diagnostics reported in this file. */
|
|
238
|
+
export function fileDiagnostics(sourceFile) {
|
|
239
|
+
const program = sourceFile.getProject().getProgram().compilerObject;
|
|
240
|
+
const node = sourceFile.compilerNode;
|
|
241
|
+
return [...program.getSyntacticDiagnostics(node), ...program.getSemanticDiagnostics(node)]
|
|
242
|
+
.filter((d) => d.file === node && d.start !== undefined)
|
|
243
|
+
.map((d) => ({
|
|
244
|
+
start: d.start,
|
|
245
|
+
code: d.code,
|
|
246
|
+
message: ts.flattenDiagnosticMessageText(d.messageText, ' '),
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Original positions of the diagnostics `after` holds that `before` does not,
|
|
251
|
+
* matched by original position and code. A diagnostic whose message changed
|
|
252
|
+
* because a type narrowed is the same diagnostic.
|
|
253
|
+
*/
|
|
254
|
+
export function addedDiagnostics(before, after, insertions) {
|
|
255
|
+
const remaining = new Map();
|
|
256
|
+
for (const d of before) {
|
|
257
|
+
const k = `${d.start}:${d.code}`;
|
|
258
|
+
remaining.set(k, (remaining.get(k) ?? 0) + 1);
|
|
259
|
+
}
|
|
260
|
+
const added = [];
|
|
261
|
+
for (const d of after) {
|
|
262
|
+
const start = mapBack(d.start, insertions);
|
|
263
|
+
const k = `${start}:${d.code}`;
|
|
264
|
+
const left = remaining.get(k) ?? 0;
|
|
265
|
+
if (left > 0) {
|
|
266
|
+
remaining.set(k, left - 1);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
added.push(start);
|
|
270
|
+
}
|
|
271
|
+
return added;
|
|
272
|
+
}
|
|
@@ -1142,6 +1142,7 @@ export declare const RetypeCheckRequestSchema: z.ZodObject<{
|
|
|
1142
1142
|
expression_text: z.ZodOptional<z.ZodString>;
|
|
1143
1143
|
expression_line: z.ZodOptional<z.ZodNumber>;
|
|
1144
1144
|
producer_type: z.ZodString;
|
|
1145
|
+
producer_unwidened_type: z.ZodOptional<z.ZodString>;
|
|
1145
1146
|
wire: z.ZodBoolean;
|
|
1146
1147
|
}, "strip", z.ZodTypeAny, {
|
|
1147
1148
|
wire: boolean;
|
|
@@ -1153,6 +1154,7 @@ export declare const RetypeCheckRequestSchema: z.ZodObject<{
|
|
|
1153
1154
|
span_end?: number | undefined;
|
|
1154
1155
|
expression_text?: string | undefined;
|
|
1155
1156
|
expression_line?: number | undefined;
|
|
1157
|
+
producer_unwidened_type?: string | undefined;
|
|
1156
1158
|
}, {
|
|
1157
1159
|
wire: boolean;
|
|
1158
1160
|
file_path: string;
|
|
@@ -1163,6 +1165,7 @@ export declare const RetypeCheckRequestSchema: z.ZodObject<{
|
|
|
1163
1165
|
span_end?: number | undefined;
|
|
1164
1166
|
expression_text?: string | undefined;
|
|
1165
1167
|
expression_line?: number | undefined;
|
|
1168
|
+
producer_unwidened_type?: string | undefined;
|
|
1166
1169
|
}>, "many">;
|
|
1167
1170
|
budget_ms: z.ZodOptional<z.ZodNumber>;
|
|
1168
1171
|
}, "strip", z.ZodTypeAny, {
|
|
@@ -1178,6 +1181,7 @@ export declare const RetypeCheckRequestSchema: z.ZodObject<{
|
|
|
1178
1181
|
span_end?: number | undefined;
|
|
1179
1182
|
expression_text?: string | undefined;
|
|
1180
1183
|
expression_line?: number | undefined;
|
|
1184
|
+
producer_unwidened_type?: string | undefined;
|
|
1181
1185
|
}[];
|
|
1182
1186
|
budget_ms?: number | undefined;
|
|
1183
1187
|
}, {
|
|
@@ -1193,6 +1197,7 @@ export declare const RetypeCheckRequestSchema: z.ZodObject<{
|
|
|
1193
1197
|
span_end?: number | undefined;
|
|
1194
1198
|
expression_text?: string | undefined;
|
|
1195
1199
|
expression_line?: number | undefined;
|
|
1200
|
+
producer_unwidened_type?: string | undefined;
|
|
1196
1201
|
}[];
|
|
1197
1202
|
budget_ms?: number | undefined;
|
|
1198
1203
|
}>;
|
|
@@ -2247,6 +2252,7 @@ export declare const SidecarRequestSchema: z.ZodDiscriminatedUnion<"action", [z.
|
|
|
2247
2252
|
expression_text: z.ZodOptional<z.ZodString>;
|
|
2248
2253
|
expression_line: z.ZodOptional<z.ZodNumber>;
|
|
2249
2254
|
producer_type: z.ZodString;
|
|
2255
|
+
producer_unwidened_type: z.ZodOptional<z.ZodString>;
|
|
2250
2256
|
wire: z.ZodBoolean;
|
|
2251
2257
|
}, "strip", z.ZodTypeAny, {
|
|
2252
2258
|
wire: boolean;
|
|
@@ -2258,6 +2264,7 @@ export declare const SidecarRequestSchema: z.ZodDiscriminatedUnion<"action", [z.
|
|
|
2258
2264
|
span_end?: number | undefined;
|
|
2259
2265
|
expression_text?: string | undefined;
|
|
2260
2266
|
expression_line?: number | undefined;
|
|
2267
|
+
producer_unwidened_type?: string | undefined;
|
|
2261
2268
|
}, {
|
|
2262
2269
|
wire: boolean;
|
|
2263
2270
|
file_path: string;
|
|
@@ -2268,6 +2275,7 @@ export declare const SidecarRequestSchema: z.ZodDiscriminatedUnion<"action", [z.
|
|
|
2268
2275
|
span_end?: number | undefined;
|
|
2269
2276
|
expression_text?: string | undefined;
|
|
2270
2277
|
expression_line?: number | undefined;
|
|
2278
|
+
producer_unwidened_type?: string | undefined;
|
|
2271
2279
|
}>, "many">;
|
|
2272
2280
|
budget_ms: z.ZodOptional<z.ZodNumber>;
|
|
2273
2281
|
}, "strip", z.ZodTypeAny, {
|
|
@@ -2283,6 +2291,7 @@ export declare const SidecarRequestSchema: z.ZodDiscriminatedUnion<"action", [z.
|
|
|
2283
2291
|
span_end?: number | undefined;
|
|
2284
2292
|
expression_text?: string | undefined;
|
|
2285
2293
|
expression_line?: number | undefined;
|
|
2294
|
+
producer_unwidened_type?: string | undefined;
|
|
2286
2295
|
}[];
|
|
2287
2296
|
budget_ms?: number | undefined;
|
|
2288
2297
|
}, {
|
|
@@ -2298,6 +2307,7 @@ export declare const SidecarRequestSchema: z.ZodDiscriminatedUnion<"action", [z.
|
|
|
2298
2307
|
span_end?: number | undefined;
|
|
2299
2308
|
expression_text?: string | undefined;
|
|
2300
2309
|
expression_line?: number | undefined;
|
|
2310
|
+
producer_unwidened_type?: string | undefined;
|
|
2301
2311
|
}[];
|
|
2302
2312
|
budget_ms?: number | undefined;
|
|
2303
2313
|
}>, z.ZodObject<{
|
|
@@ -292,6 +292,7 @@ const RetypeItemSchema = z.object({
|
|
|
292
292
|
expression_text: z.string().optional(),
|
|
293
293
|
expression_line: z.number().int().positive().optional(),
|
|
294
294
|
producer_type: z.string().min(1),
|
|
295
|
+
producer_unwidened_type: z.string().min(1).optional(),
|
|
295
296
|
wire: z.boolean(),
|
|
296
297
|
});
|
|
297
298
|
export const RetypeCheckRequestSchema = BaseRequestSchema.extend({
|