carrick 0.3.72 → 0.3.73
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/package.json +6 -6
- package/sidecar/dist/src/capture/anchors.d.ts +15 -0
- package/sidecar/dist/src/capture/anchors.js +70 -5
- package/sidecar/dist/src/capture/api.d.ts +39 -4
- package/sidecar/dist/src/capture/check-classify.d.ts +2 -0
- package/sidecar/dist/src/capture/check-classify.js +28 -0
- package/sidecar/dist/src/capture/check-deep.js +1 -1
- package/sidecar/dist/src/capture/check-probe.d.ts +1 -1
- package/sidecar/dist/src/capture/check-probe.js +16 -0
- package/sidecar/dist/src/capture/deep-walk.d.ts +33 -6
- package/sidecar/dist/src/capture/deep-walk.js +81 -28
- package/sidecar/dist/src/capture/index.js +16 -6
- package/sidecar/dist/src/capture/installed-package.d.ts +58 -0
- package/sidecar/dist/src/capture/installed-package.js +311 -0
- package/sidecar/dist/src/capture/lockfile.d.ts +8 -0
- package/sidecar/dist/src/capture/lockfile.js +1 -1
- package/sidecar/dist/src/capture/node-builder.d.ts +27 -0
- package/sidecar/dist/src/capture/node-builder.js +107 -1
- package/sidecar/dist/src/capture/paths-rewrite.d.ts +23 -6
- package/sidecar/dist/src/capture/paths-rewrite.js +43 -11
- package/sidecar/dist/src/capture/self-check.js +12 -3
- package/sidecar/dist/src/capture/unresolved.d.ts +28 -0
- package/sidecar/dist/src/capture/unresolved.js +107 -0
- package/sidecar/dist/src/type-inferrer.d.ts +108 -11
- package/sidecar/dist/src/type-inferrer.js +513 -90
- package/sidecar/dist/src/type-structural-expander.d.ts +23 -2
- package/sidecar/dist/src/type-structural-expander.js +55 -17
- package/sidecar/dist/src/type-text-canonicalizer.d.ts +14 -0
- package/sidecar/dist/src/type-text-canonicalizer.js +23 -2
- package/sidecar/dist/src/validators.d.ts +10 -0
- package/sidecar/dist/src/validators.js +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "carrick",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.73",
|
|
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",
|
|
@@ -57,11 +57,11 @@
|
|
|
57
57
|
"zod": "^3.23.0"
|
|
58
58
|
},
|
|
59
59
|
"optionalDependencies": {
|
|
60
|
-
"@carrick-tools/cli-darwin-arm64": "0.3.
|
|
61
|
-
"@carrick-tools/cli-darwin-x64": "0.3.
|
|
62
|
-
"@carrick-tools/cli-linux-arm64": "0.3.
|
|
63
|
-
"@carrick-tools/cli-linux-x64": "0.3.
|
|
64
|
-
"@carrick-tools/cli-win32-x64": "0.3.
|
|
60
|
+
"@carrick-tools/cli-darwin-arm64": "0.3.73",
|
|
61
|
+
"@carrick-tools/cli-darwin-x64": "0.3.73",
|
|
62
|
+
"@carrick-tools/cli-linux-arm64": "0.3.73",
|
|
63
|
+
"@carrick-tools/cli-linux-x64": "0.3.73",
|
|
64
|
+
"@carrick-tools/cli-win32-x64": "0.3.73"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
67
|
"@types/node": "^24.13.3",
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import ts from 'typescript';
|
|
8
8
|
import type { CaptureAnchorRequest, InferAnchorRequest } from './api.js';
|
|
9
|
+
import type { UnresolvedAtAnchor } from './deep-walk.js';
|
|
9
10
|
export interface ResolvedAnchor {
|
|
10
11
|
request: CaptureAnchorRequest;
|
|
11
12
|
/** RHS of `export type <alias> = ...;` in the surface entry. */
|
|
@@ -34,6 +35,20 @@ export interface ResolvedAnchor {
|
|
|
34
35
|
* backfillable; the reason rides `self_check_detail` instead.
|
|
35
36
|
*/
|
|
36
37
|
abstainReason?: string;
|
|
38
|
+
/**
|
|
39
|
+
* carrick#1165: identifiers the alias text names (a literal anchor's text
|
|
40
|
+
* or a node-builder print) that nothing in the producer's program declares.
|
|
41
|
+
* Recorded on the capture record as `undeclared_names`.
|
|
42
|
+
*/
|
|
43
|
+
undeclaredNames?: string[];
|
|
44
|
+
/**
|
|
45
|
+
* carrick#1164: member paths the SOURCE program could not resolve, and the
|
|
46
|
+
* unresolved imports the anchor's file reaches. Printed into the surface
|
|
47
|
+
* those members read `any`, like an author's `any`; the self-check uses this
|
|
48
|
+
* to label them `unresolved_import` instead of `declared`. Absent when the
|
|
49
|
+
* anchor's type holds no unresolved placeholder.
|
|
50
|
+
*/
|
|
51
|
+
unresolved?: UnresolvedAtAnchor;
|
|
37
52
|
}
|
|
38
53
|
/** Repo-root-relative source file -> extensionless specifier from entryDir. */
|
|
39
54
|
export declare function entryRelativeSpecifier(entryDir: string, repoRoot: string, sourceFile: string): string;
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import ts from 'typescript';
|
|
8
8
|
import * as path from 'node:path';
|
|
9
|
-
import { printTypeForDestination } from './node-builder.js';
|
|
9
|
+
import { printTypeForDestination, undeclaredNamesIn } from './node-builder.js';
|
|
10
10
|
import { typeIsOrContainsMachinery } from './machinery.js';
|
|
11
|
+
import { unresolvedAtAnchor } from './unresolved.js';
|
|
11
12
|
/** Repo-root-relative source file -> extensionless specifier from entryDir. */
|
|
12
13
|
export function entryRelativeSpecifier(entryDir, repoRoot, sourceFile) {
|
|
13
14
|
const target = path
|
|
@@ -48,6 +49,14 @@ export function resolveAnchor(program, request, args) {
|
|
|
48
49
|
const siblingSpec = bareIdentifier
|
|
49
50
|
? args.siblingSymbolSpecs?.get(text)
|
|
50
51
|
: undefined;
|
|
52
|
+
// carrick#1165: literal text is printed elsewhere (the v1 walk) and can
|
|
53
|
+
// name a type by a bare identifier that nothing in the program declares
|
|
54
|
+
// (a generated model that was never generated). The stub then self-checks
|
|
55
|
+
// such a name as an error placeholder, which no walk flags. A name a
|
|
56
|
+
// sibling symbol anchor imports is resolved by that import.
|
|
57
|
+
const undeclaredNames = siblingSpec || !args.placeholder
|
|
58
|
+
? []
|
|
59
|
+
: undeclaredNamesInText(text, program, args.placeholder);
|
|
51
60
|
return {
|
|
52
61
|
request,
|
|
53
62
|
aliasText: siblingSpec ? `import('${siblingSpec}').${text}` : text,
|
|
@@ -57,6 +66,7 @@ export function resolveAnchor(program, request, args) {
|
|
|
57
66
|
// them at this tier so the legacy dependence stays measurable and
|
|
58
67
|
// ratchetable. Demotions are distinguished by failureReason.
|
|
59
68
|
serialization: 'structural_fallback',
|
|
69
|
+
...(undeclaredNames.length > 0 ? { undeclaredNames } : {}),
|
|
60
70
|
};
|
|
61
71
|
}
|
|
62
72
|
const sourceAbs = path.join(args.repoRoot, request.source_file);
|
|
@@ -100,10 +110,14 @@ export function resolveAnchor(program, request, args) {
|
|
|
100
110
|
`and no sibling type alias derives from it (e.g. \`typeof ${request.symbol_name}\`); ` +
|
|
101
111
|
'demoted so the surface line stays valid');
|
|
102
112
|
}
|
|
113
|
+
const arrayDepth = Math.max(0, request.array_depth ?? 0);
|
|
114
|
+
const declared = checker.getDeclaredTypeOfSymbol(resolvedExport);
|
|
115
|
+
const unresolved = unresolvedAtAnchor(program, sourceFile, declared, resolvedExport.declarations?.[0] ?? sourceFile, '<0>'.repeat(arrayDepth));
|
|
103
116
|
return {
|
|
104
117
|
request,
|
|
105
118
|
aliasText: `import('${spec}').${request.symbol_name}${arraySuffix}`,
|
|
106
119
|
serialization: 'emitted',
|
|
120
|
+
...(unresolved ? { unresolved } : {}),
|
|
107
121
|
};
|
|
108
122
|
}
|
|
109
123
|
if (request.kind === 'handler_return') {
|
|
@@ -126,10 +140,13 @@ export function resolveAnchor(program, request, args) {
|
|
|
126
140
|
// Type parameters erase to their constraint/unknown under ReturnType<>.
|
|
127
141
|
return demote(`handler '${request.symbol_name}' is generic`);
|
|
128
142
|
}
|
|
143
|
+
const returned = callSignatures[0].getReturnType();
|
|
144
|
+
const unresolved = unresolvedAtAnchor(program, sourceFile, checker.getAwaitedType(returned) ?? returned, declaration ?? sourceFile);
|
|
129
145
|
return {
|
|
130
146
|
request,
|
|
131
147
|
aliasText: `Awaited<ReturnType<typeof import('${spec}').${request.symbol_name}>>`,
|
|
132
148
|
serialization: 'emitted',
|
|
149
|
+
...(unresolved ? { unresolved } : {}),
|
|
133
150
|
};
|
|
134
151
|
}
|
|
135
152
|
// kind === 'infer'
|
|
@@ -151,6 +168,10 @@ export function resolveAnchor(program, request, args) {
|
|
|
151
168
|
if (!located) {
|
|
152
169
|
return demote(locatorFailureReason(request));
|
|
153
170
|
}
|
|
171
|
+
// carrick#1162: a serialised body is the JSON of its argument. The call's own
|
|
172
|
+
// `string` result is never the payload's contract, and publishing it reads
|
|
173
|
+
// incompatible against every object-typed counterparty.
|
|
174
|
+
located = serialisedArgument(located);
|
|
154
175
|
// #439 part 1: a producer anchor whose locator landed inside a fluent
|
|
155
176
|
// builder chain's config-descriptor argument (an all-literal metadata
|
|
156
177
|
// object) must never capture that descriptor as the request type. Re-aim at
|
|
@@ -255,13 +276,24 @@ function finishInferAnchor(program, sourceFile, request, located, placeholder, r
|
|
|
255
276
|
if (!printed.text) {
|
|
256
277
|
return demote(printed.failure ?? 'node builder print failed');
|
|
257
278
|
}
|
|
279
|
+
const unresolved = unresolvedAtAnchor(program, sourceFile, type, located);
|
|
258
280
|
return {
|
|
259
281
|
request,
|
|
260
282
|
aliasText: printed.text,
|
|
261
283
|
serialization: 'node_builder',
|
|
262
284
|
...(reaimNote ? { reaimNote } : {}),
|
|
285
|
+
...(printed.undeclaredNames ? { undeclaredNames: printed.undeclaredNames } : {}),
|
|
286
|
+
...(unresolved ? { unresolved } : {}),
|
|
263
287
|
};
|
|
264
288
|
}
|
|
289
|
+
/** `undeclaredNamesIn` over type text rather than a built node. */
|
|
290
|
+
function undeclaredNamesInText(text, program, destination) {
|
|
291
|
+
const parsed = ts.createSourceFile('literal-anchor.ts', `type __LiteralAnchor = ${text};`, ts.ScriptTarget.Latest, true);
|
|
292
|
+
const statement = parsed.statements[0];
|
|
293
|
+
if (!statement || !ts.isTypeAliasDeclaration(statement))
|
|
294
|
+
return [];
|
|
295
|
+
return undeclaredNamesIn(statement.type, program, destination);
|
|
296
|
+
}
|
|
265
297
|
/**
|
|
266
298
|
* True when the anchor carries a LINE and nothing else — no payload span, no
|
|
267
299
|
* expression text, no parameter name. Such an anchor states where to look, not
|
|
@@ -689,6 +721,26 @@ function paramLocatorHints(request) {
|
|
|
689
721
|
? `line ${request.line_number}`
|
|
690
722
|
: 'no line hint';
|
|
691
723
|
}
|
|
724
|
+
/**
|
|
725
|
+
* The argument of a `JSON.stringify(value)` call (through parentheses), or the
|
|
726
|
+
* node unchanged. The mirror of the v1 inferrer's `unwrapJsonStringifyArg`:
|
|
727
|
+
* the global serialiser is identified by the standard `JSON` object, the one
|
|
728
|
+
* name every runtime shares.
|
|
729
|
+
*/
|
|
730
|
+
function serialisedArgument(node) {
|
|
731
|
+
let current = node;
|
|
732
|
+
while (ts.isParenthesizedExpression(current))
|
|
733
|
+
current = current.expression;
|
|
734
|
+
if (ts.isCallExpression(current) &&
|
|
735
|
+
current.arguments.length > 0 &&
|
|
736
|
+
ts.isPropertyAccessExpression(current.expression) &&
|
|
737
|
+
ts.isIdentifier(current.expression.expression) &&
|
|
738
|
+
current.expression.expression.text === 'JSON' &&
|
|
739
|
+
current.expression.name.text === 'stringify') {
|
|
740
|
+
return current.arguments[0];
|
|
741
|
+
}
|
|
742
|
+
return node;
|
|
743
|
+
}
|
|
692
744
|
function locatorFailureReason(request) {
|
|
693
745
|
const hints = [];
|
|
694
746
|
if (request.span_start !== undefined)
|
|
@@ -741,17 +793,30 @@ function tightestCoveringNode(sourceFile, start, end) {
|
|
|
741
793
|
visit(sourceFile);
|
|
742
794
|
return best;
|
|
743
795
|
}
|
|
796
|
+
/**
|
|
797
|
+
* Locator text with insignificant whitespace removed: runs collapse to one
|
|
798
|
+
* space, and a member chain broken before its dot (`client\n .list(…)`) reads
|
|
799
|
+
* as `client.list(…)` (carrick#1162), the same rule the v1 inferrer applies.
|
|
800
|
+
*/
|
|
801
|
+
function normalizeLocatorText(text) {
|
|
802
|
+
return text
|
|
803
|
+
.replace(/\s+/g, ' ')
|
|
804
|
+
.replace(/\s*(\?\.|\.)\s*/g, '$1')
|
|
805
|
+
.trim();
|
|
806
|
+
}
|
|
744
807
|
function nodeByExpressionText(sourceFile, text, fromLine) {
|
|
745
|
-
const wanted = text
|
|
808
|
+
const wanted = normalizeLocatorText(text);
|
|
746
809
|
let best;
|
|
747
810
|
const visit = (node) => {
|
|
748
811
|
if (best)
|
|
749
812
|
return;
|
|
750
813
|
if (isPreferredTarget(node)) {
|
|
751
|
-
const nodeText = node.getText(sourceFile)
|
|
814
|
+
const nodeText = normalizeLocatorText(node.getText(sourceFile));
|
|
752
815
|
if (nodeText === wanted) {
|
|
753
|
-
|
|
754
|
-
|
|
816
|
+
// On or after the line, or COVERING it: a chain broken before its dot
|
|
817
|
+
// starts a line above the line the analyzer reports for the call.
|
|
818
|
+
const endLine = sourceFile.getLineAndCharacterOfPosition(node.getEnd()).line + 1;
|
|
819
|
+
if (fromLine === undefined || endLine >= fromLine) {
|
|
755
820
|
best = node;
|
|
756
821
|
return;
|
|
757
822
|
}
|
|
@@ -60,6 +60,13 @@ export interface LiteralAnchorRequest {
|
|
|
60
60
|
/** Verbatim TS type text (a bare symbol name or an inline object type). */
|
|
61
61
|
type_text: string;
|
|
62
62
|
anchor_origin: AnchorOrigin;
|
|
63
|
+
/**
|
|
64
|
+
* Repo-root-relative file the text was printed from, when there is one.
|
|
65
|
+
* It joins the analysis program, so a name the text prints bare (an enum
|
|
66
|
+
* member, a recursive reference) is found declared (carrick#1165). The
|
|
67
|
+
* record's `source_file` stays `<inline>`: the answer is still the text.
|
|
68
|
+
*/
|
|
69
|
+
source_file?: string;
|
|
63
70
|
}
|
|
64
71
|
/**
|
|
65
72
|
* Addressable handler: `export type A = Awaited<ReturnType<typeof
|
|
@@ -123,9 +130,14 @@ export type SelfCheckOutcome = 'ok' | 'allowlisted_external' | 'decayed_internal
|
|
|
123
130
|
* honest value is `not_recorded` — never a guess.
|
|
124
131
|
*
|
|
125
132
|
* - `declared`: the captured declaration states `any`/`unknown` at this
|
|
126
|
-
* position
|
|
127
|
-
*
|
|
128
|
-
*
|
|
133
|
+
* position, and the producer's own program had a type there too: an author
|
|
134
|
+
* annotation, not a failed resolution.
|
|
135
|
+
* - `unresolved_import`: the position is `any` in the emitted text because the
|
|
136
|
+
* producer's program could not resolve the type there — an import of a
|
|
137
|
+
* dependency that is not installed, or of a generated module that was never
|
|
138
|
+
* generated, on the scanned checkout (carrick#1164). The printer writes that
|
|
139
|
+
* placeholder as `any`, so the text alone reads like `declared`; the capture
|
|
140
|
+
* tells them apart on the source program at anchor time.
|
|
129
141
|
* - `budget_exhausted`: the subtree was too deep or wide to finish inside the
|
|
130
142
|
* capture walk's budget, so it is reported unverified rather than clean.
|
|
131
143
|
* - `no_payload_evidence`: a handler returned a call whose callee has no
|
|
@@ -141,10 +153,17 @@ export type SelfCheckOutcome = 'ok' | 'allowlisted_external' | 'decayed_internal
|
|
|
141
153
|
* position while its parsed output is concrete, which is what a coercion
|
|
142
154
|
* declares (carrick#1101). The published type carries the output there, so
|
|
143
155
|
* this finding describes what a caller may send, not the printed member.
|
|
156
|
+
* - `no_success_payload`: a route's handler sends only error or redirect
|
|
157
|
+
* responses (by the status each send states), or no success send carries a
|
|
158
|
+
* body a JSON contract can describe, so the route publishes no success body
|
|
159
|
+
* rather than an error body or a redirect location (carrick#1161).
|
|
160
|
+
* - `no_request_body`: the located request read is a validated part the
|
|
161
|
+
* route's validator binds that is not a body (a path parameter, a query), so
|
|
162
|
+
* the route states no request body contract there (carrick#1166).
|
|
144
163
|
* - `not_recorded`: the position carries a top type and this layer has no
|
|
145
164
|
* cause for it.
|
|
146
165
|
*/
|
|
147
|
-
export type TypeProvenanceReason = 'declared' | 'budget_exhausted' | 'no_payload_evidence' | 'machinery_envelope' | 'coerced_input' | 'not_recorded';
|
|
166
|
+
export type TypeProvenanceReason = 'declared' | 'unresolved_import' | 'budget_exhausted' | 'no_payload_evidence' | 'machinery_envelope' | 'coerced_input' | 'no_success_payload' | 'no_request_body' | 'not_recorded';
|
|
148
167
|
/**
|
|
149
168
|
* One `any`/`unknown` finding inside a captured or inferred type, with its
|
|
150
169
|
* position and its cause. Sorted by `path` wherever a list is emitted, so the
|
|
@@ -206,6 +225,22 @@ export interface CaptureAliasRecord {
|
|
|
206
225
|
* Sorted by `path`; absent (not empty) when the walk found nothing.
|
|
207
226
|
*/
|
|
208
227
|
any_provenance?: TypeProvenance[];
|
|
228
|
+
/**
|
|
229
|
+
* Internal module specifiers that fail to resolve anywhere in this alias's
|
|
230
|
+
* closure, as the emitted files write them (carrick#1165). The emitted
|
|
231
|
+
* tree is missing part of what the alias refers to, so a shape printed from
|
|
232
|
+
* it can name types nothing declares. Sorted; absent when there are none.
|
|
233
|
+
*/
|
|
234
|
+
dangling_specifiers?: string[];
|
|
235
|
+
/**
|
|
236
|
+
* Identifiers this alias's printed text names that do not resolve where the
|
|
237
|
+
* surface declares it (carrick#1165): a literal anchor's text naming a type
|
|
238
|
+
* nothing declares, or a node-builder print reusing a source annotation
|
|
239
|
+
* whose own import did not resolve. Checked on the producer's program, so a global the
|
|
240
|
+
* program declares (a runtime or `@types` global) is never listed. Sorted;
|
|
241
|
+
* absent when there are none.
|
|
242
|
+
*/
|
|
243
|
+
undeclared_names?: string[];
|
|
209
244
|
}
|
|
210
245
|
/** Aggregate fidelity metric, emitted per capture (one service). */
|
|
211
246
|
export interface CaptureFidelity {
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* surface import error (probe lines 1-2)-> unverifiable
|
|
11
11
|
* IsAny gate fired (TS2344) -> gate_caught_baked_any
|
|
12
12
|
* IsUnknown/IsNever gate fired (TS2344) -> unverifiable
|
|
13
|
+
* IsVoid gate fired (TS2344) -> unverifiable (no body read)
|
|
14
|
+
* IsFormBody gate fired (TS2344) -> unverifiable (form-encoded body)
|
|
13
15
|
* assignment-class error -> incompatible
|
|
14
16
|
* no diagnostics -> compatible [lowest precedence]
|
|
15
17
|
*
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* surface import error (probe lines 1-2)-> unverifiable
|
|
11
11
|
* IsAny gate fired (TS2344) -> gate_caught_baked_any
|
|
12
12
|
* IsUnknown/IsNever gate fired (TS2344) -> unverifiable
|
|
13
|
+
* IsVoid gate fired (TS2344) -> unverifiable (no body read)
|
|
14
|
+
* IsFormBody gate fired (TS2344) -> unverifiable (form-encoded body)
|
|
13
15
|
* assignment-class error -> incompatible
|
|
14
16
|
* no diagnostics -> compatible [lowest precedence]
|
|
15
17
|
*
|
|
@@ -127,6 +129,32 @@ export function classifyPair(input) {
|
|
|
127
129
|
...notAFact(`the ${side} type is '${kind}'`),
|
|
128
130
|
};
|
|
129
131
|
}
|
|
132
|
+
// 4b. A side that states no contract (carrick#1162), below the decay gates so
|
|
133
|
+
// an `unknown` side is reported as `unknown`: a `void`/`undefined`
|
|
134
|
+
// response a call site reads no body from, and a form-encoded body whose
|
|
135
|
+
// fields are runtime appends.
|
|
136
|
+
const shapeGate = gateDiags
|
|
137
|
+
.map((d) => plan.gateLines.get(d.line))
|
|
138
|
+
.find((name) => name.endsWith(':void') || name.endsWith(':form'));
|
|
139
|
+
if (shapeGate) {
|
|
140
|
+
const { side, kind } = sideForGate(shapeGate, plan);
|
|
141
|
+
if (kind === 'void') {
|
|
142
|
+
return {
|
|
143
|
+
...base,
|
|
144
|
+
bucket: 'unverifiable',
|
|
145
|
+
gate: `${side}:void`,
|
|
146
|
+
diagnostic: `the ${side} type is 'void' (it reads no body), so there is no contract to verify.`,
|
|
147
|
+
...notAFact(`the ${side} type is 'void'`),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
...base,
|
|
152
|
+
bucket: 'unverifiable',
|
|
153
|
+
gate: `${side}:form`,
|
|
154
|
+
diagnostic: `the ${side} sends a form-encoded body, whose fields are appended at runtime and cannot be compared with a declared shape.`,
|
|
155
|
+
...notAFact(`the ${side} sends a form-encoded body`),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
130
158
|
// 5. Assignment-class error on the value assignment line -> incompatible.
|
|
131
159
|
const assignDiag = probeDiags.find((d) => d.line === plan.assignmentLine && ASSIGNMENT_CODES.has(d.code));
|
|
132
160
|
if (assignDiag) {
|
|
@@ -84,7 +84,7 @@ function walkImportedAlias(file, localName, program, checker) {
|
|
|
84
84
|
const type = checker.getDeclaredTypeOfSymbol(target);
|
|
85
85
|
if (!type)
|
|
86
86
|
return undefined;
|
|
87
|
-
return findDisqualifyingTopTypes(type, program, checker, element.name).map(provenanceOf);
|
|
87
|
+
return findDisqualifyingTopTypes(type, program, checker, element.name).map((finding) => provenanceOf(finding));
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
return undefined;
|
|
@@ -43,7 +43,7 @@ export declare function fnv1a(input: string): string;
|
|
|
43
43
|
* path, so it is byte-stable across runs. */
|
|
44
44
|
export declare function pairId(spec: CheckPairSpec): string;
|
|
45
45
|
/** Names each gate line so a TS2344 can be attributed to a specific side+kind. */
|
|
46
|
-
export type GateName = 'sent:any' | 'sent:unknown' | 'sent:never' | 'expected:any' | 'expected:unknown' | 'expected:never';
|
|
46
|
+
export type GateName = 'sent:any' | 'sent:unknown' | 'sent:never' | 'sent:void' | 'sent:form' | 'expected:any' | 'expected:unknown' | 'expected:never' | 'expected:void';
|
|
47
47
|
export interface ProbePlan {
|
|
48
48
|
pairId: string;
|
|
49
49
|
spec: CheckPairSpec;
|
|
@@ -95,6 +95,22 @@ export function buildProbe(spec, packageOf) {
|
|
|
95
95
|
gateLines.set(push(`type _G_expected_any = Assert<Not<IsAny<Expected>>>;`), 'expected:any');
|
|
96
96
|
gateLines.set(push(`type _G_expected_unknown = Assert<Not<IsUnknown<Expected>>>;`), 'expected:unknown');
|
|
97
97
|
gateLines.set(push(`type _G_expected_never = Assert<Not<IsNever<Expected>>>;`), 'expected:never');
|
|
98
|
+
// carrick#1162: `void`/`undefined` is what a call site that reads no body
|
|
99
|
+
// types its response as (a wrapper returning `Promise<void>`). It states no
|
|
100
|
+
// contract, so no counterparty shape can be judged against it.
|
|
101
|
+
// `any` is excluded first: `[any] extends [void]` holds, and an `any` side
|
|
102
|
+
// must keep its own top-type reason, never read as "reads no body".
|
|
103
|
+
push(`type IsVoid<T> = 0 extends 1 & T ? false : [T] extends [never] ? false : [T] extends [void] ? true : false;`);
|
|
104
|
+
gateLines.set(push(`type _G_sent_void = Assert<Not<IsVoid<Sent>>>;`), 'sent:void');
|
|
105
|
+
gateLines.set(push(`type _G_expected_void = Assert<Not<IsVoid<Expected>>>;`), 'expected:void');
|
|
106
|
+
// carrick#1162: a form-encoded request body (the platform `FormData` or
|
|
107
|
+
// `URLSearchParams`) carries its fields as runtime appends, which no type
|
|
108
|
+
// records, so a field-by-field comparison against a declared shape reads
|
|
109
|
+
// every such body as missing all of its fields.
|
|
110
|
+
if (spec.protocol === 'http' && spec.type_kind === 'request') {
|
|
111
|
+
push(`type IsFormBody<T> = 0 extends 1 & T ? false : [T] extends [never] ? false : [T] extends [FormData | URLSearchParams] ? true : false;`);
|
|
112
|
+
gateLines.set(push(`type _G_sent_form = Assert<Not<IsFormBody<Sent>>>;`), 'sent:form');
|
|
113
|
+
}
|
|
98
114
|
push(`declare const sent: Sent;`);
|
|
99
115
|
let assignmentLine;
|
|
100
116
|
if (spec.protocol === 'graphql') {
|
|
@@ -44,19 +44,46 @@ export type DeepTopType = Pick<TypeProvenance, 'kind' | 'path'>;
|
|
|
44
44
|
* void` safely accepts a stricter counterparty), so it is not a masked
|
|
45
45
|
* mismatch and demoting it would over-demote a sound shape;
|
|
46
46
|
* - TypeScript's unresolved-reference `error` placeholder (`intrinsicName ===
|
|
47
|
-
* 'error'`) is excluded (see `
|
|
47
|
+
* 'error'`) is excluded (see `disqualifyingFlag`): it heals when the check installs the
|
|
48
48
|
* pinned external, so it is a healable decay, not an author-baked `any`.
|
|
49
49
|
* A type the walk cannot cheaply finish is NOT flagged — over-demoting a
|
|
50
50
|
* legitimately fully-resolved type is the failure mode this guard must not have.
|
|
51
51
|
*/
|
|
52
52
|
export declare function findDisqualifyingTopTypes(root: ts.Type, program: ts.Program, checker: ts.TypeChecker, location: ts.Node): DeepTopType[];
|
|
53
|
+
/**
|
|
54
|
+
* Member paths at which `root` holds TypeScript's unresolved-reference
|
|
55
|
+
* placeholder (the `error` intrinsic) rather than a type (carrick#1164).
|
|
56
|
+
*
|
|
57
|
+
* Run on the SOURCE program, where a member typed through an import that did
|
|
58
|
+
* not resolve still carries the placeholder. Once printed into the stub the
|
|
59
|
+
* placeholder is the keyword `any`, indistinguishable from an author's `any`,
|
|
60
|
+
* so this is the only point at which the two causes can be told apart. Same
|
|
61
|
+
* walk, same budget and the same path notation as the self-check's walk over
|
|
62
|
+
* the emitted text, so a path found here names the member a self-check finding
|
|
63
|
+
* names. A walk that runs out of budget reports what it found before it did.
|
|
64
|
+
*/
|
|
65
|
+
export declare function findUnresolvedPlaceholders(root: ts.Type, program: ts.Program, checker: ts.TypeChecker, location: ts.Node): string[];
|
|
66
|
+
/** What an anchor's SOURCE program could not resolve (carrick#1164). */
|
|
67
|
+
export interface UnresolvedAtAnchor {
|
|
68
|
+
/** Member paths holding the unresolved-reference placeholder. */
|
|
69
|
+
paths: readonly string[];
|
|
70
|
+
/**
|
|
71
|
+
* Module specifiers, as the source wrote them, that did not resolve from the
|
|
72
|
+
* anchor's file or a module it reaches. Internal specifiers first. May be
|
|
73
|
+
* empty: a name the program never declared leaves the same placeholder.
|
|
74
|
+
*/
|
|
75
|
+
specifiers: readonly string[];
|
|
76
|
+
}
|
|
53
77
|
/**
|
|
54
78
|
* Turn a deep finding into the published provenance entry (carrick#376).
|
|
55
79
|
*
|
|
56
80
|
* The self-check reads EMITTED declaration text, so a top type it finds is
|
|
57
|
-
* text:
|
|
58
|
-
*
|
|
59
|
-
* `
|
|
60
|
-
*
|
|
81
|
+
* text: an author annotation and an emitter that printed an unresolved value
|
|
82
|
+
* both read `any` there. The anchor's source program can still tell them apart
|
|
83
|
+
* (`findUnresolvedPlaceholders`), and when it recorded the finding's path as a
|
|
84
|
+
* placeholder the cause is `unresolved_import`: a dependency or a generated
|
|
85
|
+
* module was missing on the scanned checkout, which installing or generating
|
|
86
|
+
* it fixes (carrick#1164). Anything else at that position is `declared`. The
|
|
87
|
+
* one other cause the walk itself can distinguish is its own budget.
|
|
61
88
|
*/
|
|
62
|
-
export declare function provenanceOf(finding: DeepTopType): TypeProvenance;
|
|
89
|
+
export declare function provenanceOf(finding: DeepTopType, unresolved?: UnresolvedAtAnchor): TypeProvenance;
|
|
@@ -43,12 +43,38 @@ const MAX_DEEP_FINDINGS = 32;
|
|
|
43
43
|
* void` safely accepts a stricter counterparty), so it is not a masked
|
|
44
44
|
* mismatch and demoting it would over-demote a sound shape;
|
|
45
45
|
* - TypeScript's unresolved-reference `error` placeholder (`intrinsicName ===
|
|
46
|
-
* 'error'`) is excluded (see `
|
|
46
|
+
* 'error'`) is excluded (see `disqualifyingFlag`): it heals when the check installs the
|
|
47
47
|
* pinned external, so it is a healable decay, not an author-baked `any`.
|
|
48
48
|
* A type the walk cannot cheaply finish is NOT flagged — over-demoting a
|
|
49
49
|
* legitimately fully-resolved type is the failure mode this guard must not have.
|
|
50
50
|
*/
|
|
51
51
|
export function findDisqualifyingTopTypes(root, program, checker, location) {
|
|
52
|
+
return walkTopTypes(root, program, checker, location, disqualifyingFlag);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Member paths at which `root` holds TypeScript's unresolved-reference
|
|
56
|
+
* placeholder (the `error` intrinsic) rather than a type (carrick#1164).
|
|
57
|
+
*
|
|
58
|
+
* Run on the SOURCE program, where a member typed through an import that did
|
|
59
|
+
* not resolve still carries the placeholder. Once printed into the stub the
|
|
60
|
+
* placeholder is the keyword `any`, indistinguishable from an author's `any`,
|
|
61
|
+
* so this is the only point at which the two causes can be told apart. Same
|
|
62
|
+
* walk, same budget and the same path notation as the self-check's walk over
|
|
63
|
+
* the emitted text, so a path found here names the member a self-check finding
|
|
64
|
+
* names. A walk that runs out of budget reports what it found before it did.
|
|
65
|
+
*/
|
|
66
|
+
export function findUnresolvedPlaceholders(root, program, checker, location) {
|
|
67
|
+
return walkTopTypes(root, program, checker, location, (t) => isErrorPlaceholder(t) ? 'any' : undefined)
|
|
68
|
+
.filter((finding) => finding.kind !== 'budget_exhausted')
|
|
69
|
+
.map((finding) => finding.path);
|
|
70
|
+
}
|
|
71
|
+
/** TypeScript's unresolved-reference placeholder: `TypeFlags.Any` with the
|
|
72
|
+
* internal `intrinsicName === 'error'` (stable since TS 1.x; see `anchors.ts`). */
|
|
73
|
+
function isErrorPlaceholder(t) {
|
|
74
|
+
return ((t.flags & ts.TypeFlags.Any) !== 0 &&
|
|
75
|
+
t.intrinsicName === 'error');
|
|
76
|
+
}
|
|
77
|
+
function walkTopTypes(root, program, checker, location, flagOf) {
|
|
52
78
|
// Cover v1's inline-expander reach with margin so this structural walk is a
|
|
53
79
|
// genuine superset of v1's text-scan disqualifier AT DEPTH: anything v1 could
|
|
54
80
|
// expand-and-flag as `any`/`unknown`, this walk reaches too. v1's expander
|
|
@@ -63,28 +89,6 @@ export function findDisqualifyingTopTypes(root, program, checker, location) {
|
|
|
63
89
|
const MAX_VISITED = 4096;
|
|
64
90
|
const seen = new Set();
|
|
65
91
|
let visited = 0;
|
|
66
|
-
const flagOf = (t) => {
|
|
67
|
-
if (t.flags & ts.TypeFlags.Any) {
|
|
68
|
-
// TypeScript's unresolved-reference placeholder (e.g. `import('ext').Foo`
|
|
69
|
-
// on a bare checkout) carries `TypeFlags.Any` but `intrinsicName ===
|
|
70
|
-
// 'error'` — NOT an author-baked `any`. It resolves to the real type once
|
|
71
|
-
// the check phase installs the pinned external, so it must not count as a
|
|
72
|
-
// disqualifier: treating it as `any` would demote a healable external
|
|
73
|
-
// reference. Genuine author `any` carries `intrinsicName === 'any'`.
|
|
74
|
-
// (`intrinsicName` is internal but stable since TS 1.x — same standing as
|
|
75
|
-
// its use in anchors.ts.)
|
|
76
|
-
//
|
|
77
|
-
// The `error` placeholder also stands in for NON-healable causes (TS2304
|
|
78
|
-
// undefined name, TS2315 wrong-arity generic, a dangling internal
|
|
79
|
-
// specifier). Excluding those here is not a hole: each emits a diagnostic
|
|
80
|
-
// in the alias's own closure, so the closure-failure classification
|
|
81
|
-
// (`internalFailure` -> decayed_internal) or the check-phase POISON rule
|
|
82
|
-
// — NOT this deep walk — is their backstop, and both fail closed.
|
|
83
|
-
const name = t.intrinsicName;
|
|
84
|
-
return name === 'error' ? undefined : 'any';
|
|
85
|
-
}
|
|
86
|
-
return t.flags & ts.TypeFlags.Unknown ? 'unknown' : undefined;
|
|
87
|
-
};
|
|
88
92
|
// Findings accumulate rather than short-circuiting: the FIRST is what the
|
|
89
93
|
// check phase pre-gates on (so the verdict is identical to the
|
|
90
94
|
// stop-at-first walk this replaces), and the rest answer "which fields are
|
|
@@ -216,16 +220,44 @@ export function findDisqualifyingTopTypes(root, program, checker, location) {
|
|
|
216
220
|
rest.sort((a, b) => a.path.localeCompare(b.path));
|
|
217
221
|
return [head, ...rest];
|
|
218
222
|
}
|
|
223
|
+
/** The disqualifier the self-check and the check phase gate on (see
|
|
224
|
+
* `findDisqualifyingTopTypes`). */
|
|
225
|
+
function disqualifyingFlag(t) {
|
|
226
|
+
if (t.flags & ts.TypeFlags.Any) {
|
|
227
|
+
// TypeScript's unresolved-reference placeholder (e.g. `import('ext').Foo`
|
|
228
|
+
// on a bare checkout) carries `TypeFlags.Any` but `intrinsicName ===
|
|
229
|
+
// 'error'` — NOT an author-baked `any`. It resolves to the real type once
|
|
230
|
+
// the check phase installs the pinned external, so it must not count as a
|
|
231
|
+
// disqualifier: treating it as `any` would demote a healable external
|
|
232
|
+
// reference. Genuine author `any` carries `intrinsicName === 'any'`.
|
|
233
|
+
// (`intrinsicName` is internal but stable since TS 1.x — same standing as
|
|
234
|
+
// its use in anchors.ts.)
|
|
235
|
+
//
|
|
236
|
+
// The `error` placeholder also stands in for NON-healable causes (TS2304
|
|
237
|
+
// undefined name, TS2315 wrong-arity generic, a dangling internal
|
|
238
|
+
// specifier). Excluding those here is not a hole: each emits a diagnostic
|
|
239
|
+
// in the alias's own closure, so the closure-failure classification
|
|
240
|
+
// (`internalFailure` -> decayed_internal) or the check-phase POISON rule
|
|
241
|
+
// — NOT this deep walk — is their backstop, and both fail closed.
|
|
242
|
+
return isErrorPlaceholder(t) ? undefined : 'any';
|
|
243
|
+
}
|
|
244
|
+
return t.flags & ts.TypeFlags.Unknown ? 'unknown' : undefined;
|
|
245
|
+
}
|
|
246
|
+
/** How many unresolved specifiers a detail names before it counts the rest. */
|
|
247
|
+
const MAX_NAMED_SPECIFIERS = 3;
|
|
219
248
|
/**
|
|
220
249
|
* Turn a deep finding into the published provenance entry (carrick#376).
|
|
221
250
|
*
|
|
222
251
|
* The self-check reads EMITTED declaration text, so a top type it finds is
|
|
223
|
-
* text:
|
|
224
|
-
*
|
|
225
|
-
* `
|
|
226
|
-
*
|
|
252
|
+
* text: an author annotation and an emitter that printed an unresolved value
|
|
253
|
+
* both read `any` there. The anchor's source program can still tell them apart
|
|
254
|
+
* (`findUnresolvedPlaceholders`), and when it recorded the finding's path as a
|
|
255
|
+
* placeholder the cause is `unresolved_import`: a dependency or a generated
|
|
256
|
+
* module was missing on the scanned checkout, which installing or generating
|
|
257
|
+
* it fixes (carrick#1164). Anything else at that position is `declared`. The
|
|
258
|
+
* one other cause the walk itself can distinguish is its own budget.
|
|
227
259
|
*/
|
|
228
|
-
export function provenanceOf(finding) {
|
|
260
|
+
export function provenanceOf(finding, unresolved) {
|
|
229
261
|
if (finding.kind === 'budget_exhausted') {
|
|
230
262
|
return {
|
|
231
263
|
path: finding.path,
|
|
@@ -234,6 +266,14 @@ export function provenanceOf(finding) {
|
|
|
234
266
|
detail: 'the type is too deep or wide to verify within the capture budget here, so it is reported unverified rather than assumed clean',
|
|
235
267
|
};
|
|
236
268
|
}
|
|
269
|
+
if (finding.kind === 'any' && unresolved?.paths.includes(finding.path)) {
|
|
270
|
+
return {
|
|
271
|
+
path: finding.path,
|
|
272
|
+
kind: finding.kind,
|
|
273
|
+
reason: 'unresolved_import',
|
|
274
|
+
detail: unresolvedDetail(unresolved.specifiers),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
237
277
|
return {
|
|
238
278
|
path: finding.path,
|
|
239
279
|
kind: finding.kind,
|
|
@@ -241,3 +281,16 @@ export function provenanceOf(finding) {
|
|
|
241
281
|
detail: `the captured declaration states '${finding.kind}' at this position, so no counterparty shape can disagree with it`,
|
|
242
282
|
};
|
|
243
283
|
}
|
|
284
|
+
function unresolvedDetail(specifiers) {
|
|
285
|
+
const lead = "the type at this position did not resolve on the scanned checkout, so the compiler printed a placeholder 'any' rather than a declared type";
|
|
286
|
+
if (specifiers.length === 0)
|
|
287
|
+
return lead;
|
|
288
|
+
const named = specifiers
|
|
289
|
+
.slice(0, MAX_NAMED_SPECIFIERS)
|
|
290
|
+
.map((specifier) => `'${specifier}'`)
|
|
291
|
+
.join(', ');
|
|
292
|
+
const more = specifiers.length > MAX_NAMED_SPECIFIERS
|
|
293
|
+
? ` and ${specifiers.length - MAX_NAMED_SPECIFIERS} more`
|
|
294
|
+
: '';
|
|
295
|
+
return `${lead}; unresolved imports reachable from the anchor: ${named}${more}`;
|
|
296
|
+
}
|