carrick 0.3.107 → 0.3.108
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/plugin/.claude-plugin/plugin.json +1 -1
- package/sidecar/dist/src/capture/api.d.ts +15 -0
- package/sidecar/dist/src/capture/check-classify.d.ts +9 -0
- package/sidecar/dist/src/capture/check-classify.js +20 -0
- package/sidecar/dist/src/capture/check-fields.d.ts +24 -0
- package/sidecar/dist/src/capture/check-fields.js +54 -36
- package/sidecar/dist/src/capture/check-union.d.ts +40 -0
- package/sidecar/dist/src/capture/check-union.js +92 -0
- package/sidecar/dist/src/capture/check.js +5 -0
- package/sidecar/dist/src/capture/index.js +84 -44
- package/sidecar/dist/src/definition-resolver.d.ts +5 -8
- package/sidecar/dist/src/definition-resolver.js +5 -7
- package/sidecar/dist/src/index.js +33 -4
- package/sidecar/dist/src/infer-timing.d.ts +54 -0
- package/sidecar/dist/src/infer-timing.js +124 -0
- package/sidecar/dist/src/retype.d.ts +4 -2
- package/sidecar/dist/src/retype.js +9 -4
- package/sidecar/dist/src/type-inferrer.d.ts +146 -1
- package/sidecar/dist/src/type-inferrer.js +416 -19
- package/sidecar/dist/src/types.d.ts +70 -0
- package/sidecar/dist/src/validators.d.ts +54 -16
- package/sidecar/dist/src/validators.js +4 -0
- package/templates/skills/carrick-reuse.md +6 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "carrick",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.108",
|
|
4
4
|
"description": "Maps your entire TypeScript codebase across services and repositories, giving AI agents full context on existing types, routes, and function behaviours over MCP before they write duplicate or breaking code.",
|
|
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.108",
|
|
62
|
+
"@carrick-tools/cli-darwin-x64": "0.3.108",
|
|
63
|
+
"@carrick-tools/cli-linux-arm64": "0.3.108",
|
|
64
|
+
"@carrick-tools/cli-linux-x64": "0.3.108",
|
|
65
|
+
"@carrick-tools/cli-win32-x64": "0.3.108"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@types/node": "^24.13.3",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "carrick",
|
|
3
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.
|
|
4
|
+
"version": "0.3.108",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Carrick",
|
|
7
7
|
"email": "hello@carrick.tools"
|
|
@@ -346,6 +346,14 @@ export interface CaptureStubResult {
|
|
|
346
346
|
ts_version: string;
|
|
347
347
|
errors: string[];
|
|
348
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* The stages of a capture that it reports as it reaches them (carrick#1916),
|
|
351
|
+
* in order: the program the anchors are read in is being built; the anchors
|
|
352
|
+
* are being resolved in it; the declarations are being emitted; the stub is
|
|
353
|
+
* being checked. A capture whose anchors belong to several projects goes
|
|
354
|
+
* through the first two once per project.
|
|
355
|
+
*/
|
|
356
|
+
export type CapturePhase = 'program' | 'anchors' | 'emit' | 'self-check';
|
|
349
357
|
export interface CaptureStubOptions {
|
|
350
358
|
repoRoot: string;
|
|
351
359
|
serviceName: string;
|
|
@@ -360,6 +368,13 @@ export interface CaptureStubOptions {
|
|
|
360
368
|
* lie inside it only beneath a `.carrick` directory (carrick#1768).
|
|
361
369
|
*/
|
|
362
370
|
scanRoot?: string;
|
|
371
|
+
/**
|
|
372
|
+
* Told each stage as the capture reaches it, and each anchor as it is
|
|
373
|
+
* resolved (`message` is then `<done> of <total>`). Called by the work
|
|
374
|
+
* itself, between units, so a capture that stops finishing them stops
|
|
375
|
+
* reporting. It changes nothing the capture writes.
|
|
376
|
+
*/
|
|
377
|
+
onProgress?: (phase: CapturePhase, message: string) => void;
|
|
363
378
|
}
|
|
364
379
|
/** Wire protocol of a matched pair (drives the direction table). */
|
|
365
380
|
export type ProbeProtocol = 'http' | 'graphql' | 'socket' | 'pubsub';
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* IsFormBody gate fired (TS2344) -> unverifiable (form-encoded body)
|
|
15
15
|
* IsByteBody gate fired (TS2344) -> unverifiable (bytes, even agreeing)
|
|
16
16
|
* raw-text read marked on a side -> unverifiable (text, even agreeing)
|
|
17
|
+
* assignment error, sent union taken in part by a weak expected type
|
|
18
|
+
* -> unverifiable (carrick#1995)
|
|
17
19
|
* assignment-class error -> incompatible
|
|
18
20
|
* no diagnostics -> compatible [lowest precedence]
|
|
19
21
|
*
|
|
@@ -27,6 +29,7 @@ import type { CheckVerdict } from './api.js';
|
|
|
27
29
|
import { type ProbePlan, type Side } from './check-probe.js';
|
|
28
30
|
import { type ScrubContext } from './check-scrub.js';
|
|
29
31
|
import type { PairDeepFindings } from './check-deep.js';
|
|
32
|
+
import type { DispatchUnion } from './check-union.js';
|
|
30
33
|
import { type PairFieldReport } from './check-fields.js';
|
|
31
34
|
export interface RawDiagnostic {
|
|
32
35
|
/** Workspace-relative, forward-slash file path (empty for global errors). */
|
|
@@ -66,6 +69,12 @@ export interface ClassifyInput {
|
|
|
66
69
|
* run, in which case the tsc text stands alone.
|
|
67
70
|
*/
|
|
68
71
|
fieldReport?: PairFieldReport;
|
|
72
|
+
/**
|
|
73
|
+
* Set when the sent side is a union the expected side takes only in part,
|
|
74
|
+
* every other member failing by nothing but the compiler's weak-type check
|
|
75
|
+
* (carrick#1995). Read only where the decisive assignment failed.
|
|
76
|
+
*/
|
|
77
|
+
dispatchUnion?: DispatchUnion;
|
|
69
78
|
/**
|
|
70
79
|
* The side whose capture record says it reads the body as raw text
|
|
71
80
|
* (carrick#1842), the sent side when both do. Read from the record, not the
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* IsFormBody gate fired (TS2344) -> unverifiable (form-encoded body)
|
|
15
15
|
* IsByteBody gate fired (TS2344) -> unverifiable (bytes, even agreeing)
|
|
16
16
|
* raw-text read marked on a side -> unverifiable (text, even agreeing)
|
|
17
|
+
* assignment error, sent union taken in part by a weak expected type
|
|
18
|
+
* -> unverifiable (carrick#1995)
|
|
17
19
|
* assignment-class error -> incompatible
|
|
18
20
|
* no diagnostics -> compatible [lowest precedence]
|
|
19
21
|
*
|
|
@@ -220,6 +222,24 @@ export function classifyPair(input) {
|
|
|
220
222
|
// the `string` it becomes) has no error on this line and is not a drift.
|
|
221
223
|
const decisiveLine = decisiveAssignmentLine(plan);
|
|
222
224
|
const assignDiag = probeDiags.find((d) => d.line === decisiveLine && ASSIGNMENT_CODES.has(d.code));
|
|
225
|
+
// 5a. Unless the mismatch is a sent union the expected type takes in part,
|
|
226
|
+
// the members it rejects sharing no field with a type whose every field
|
|
227
|
+
// is optional (carrick#1995). That is a route answering a body per
|
|
228
|
+
// branch read by a caller of one branch: no member breaks a reader whose
|
|
229
|
+
// every read allows absence, and which member the call receives is not
|
|
230
|
+
// in the types. Not a fact either way, and no side to retype.
|
|
231
|
+
if (assignDiag && input.dispatchUnion) {
|
|
232
|
+
const { members, agreeing } = input.dispatchUnion;
|
|
233
|
+
const sent = plan.direction.sent;
|
|
234
|
+
const expected = plan.direction.expected;
|
|
235
|
+
return {
|
|
236
|
+
...base,
|
|
237
|
+
bucket: 'unverifiable',
|
|
238
|
+
gate: `${sent}:union`,
|
|
239
|
+
diagnostic: `the ${sent} type is a union of ${members} bodies; the ${expected} type, every field optional, accepts ${agreeing} of them and shares no field with the other ${members - agreeing}, so the call reads one of the bodies the route answers and the types do not say which.`,
|
|
240
|
+
...notAFact(`the ${sent} type is a union of bodies and the ${expected} type, every field optional, accepts only some of them`),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
223
243
|
if (assignDiag) {
|
|
224
244
|
const text = scrubDiagnostic(assignDiag.message, scrubCtx, plan.sentEndpoint.alias, plan.expectedEndpoint.alias);
|
|
225
245
|
return {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
*
|
|
29
29
|
* Seam: node builtins + `typescript` + this bundle only.
|
|
30
30
|
*/
|
|
31
|
+
import ts from 'typescript';
|
|
31
32
|
import type { ProbePlan, Side } from './check-probe.js';
|
|
32
33
|
import type { ProbeProgram } from './check-deep.js';
|
|
33
34
|
/** What kind of difference one field path carries. */
|
|
@@ -69,6 +70,29 @@ export interface PairFieldReport {
|
|
|
69
70
|
* how many more there are rather than pretending the list is complete.
|
|
70
71
|
*/
|
|
71
72
|
export declare const MAX_NAMED_FIELDS = 8;
|
|
73
|
+
/**
|
|
74
|
+
* The two types the judge's decisive assignment compared for one plan, read in
|
|
75
|
+
* the probe program, with the compiler's own assignability relation.
|
|
76
|
+
* `undefined` when the probe or either alias cannot be read, or the compiler
|
|
77
|
+
* build exposes no relation: every statement made without them would be a
|
|
78
|
+
* guess.
|
|
79
|
+
*
|
|
80
|
+
* The sent side is whatever that assignment actually sent: the GraphQL
|
|
81
|
+
* comparand where one exists, then the JSON wire form where serialising
|
|
82
|
+
* changes the type. Reading `sent` there instead would describe a type the
|
|
83
|
+
* judge did not compare, which is the one way a reader of this pair could
|
|
84
|
+
* contradict it.
|
|
85
|
+
*/
|
|
86
|
+
export declare function comparedTypes(opened: ProbeProgram, plan: ProbePlan): {
|
|
87
|
+
compared: ts.Type;
|
|
88
|
+
expected: {
|
|
89
|
+
type: ts.Type;
|
|
90
|
+
node: ts.Node;
|
|
91
|
+
};
|
|
92
|
+
wireApplied: boolean;
|
|
93
|
+
checker: ts.TypeChecker;
|
|
94
|
+
isAssignableTo: (source: ts.Type, target: ts.Type) => boolean;
|
|
95
|
+
} | undefined;
|
|
72
96
|
/**
|
|
73
97
|
* Field reports for every plan whose probe the program could read, keyed by
|
|
74
98
|
* pair id. A plan with no entry has no report, which is not a claim that its
|
|
@@ -54,6 +54,56 @@ function assignabilityOf(checker) {
|
|
|
54
54
|
const fn = checker.isTypeAssignableTo;
|
|
55
55
|
return typeof fn === 'function' ? fn.bind(checker) : undefined;
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* The two types the judge's decisive assignment compared for one plan, read in
|
|
59
|
+
* the probe program, with the compiler's own assignability relation.
|
|
60
|
+
* `undefined` when the probe or either alias cannot be read, or the compiler
|
|
61
|
+
* build exposes no relation: every statement made without them would be a
|
|
62
|
+
* guess.
|
|
63
|
+
*
|
|
64
|
+
* The sent side is whatever that assignment actually sent: the GraphQL
|
|
65
|
+
* comparand where one exists, then the JSON wire form where serialising
|
|
66
|
+
* changes the type. Reading `sent` there instead would describe a type the
|
|
67
|
+
* judge did not compare, which is the one way a reader of this pair could
|
|
68
|
+
* contradict it.
|
|
69
|
+
*/
|
|
70
|
+
export function comparedTypes(opened, plan) {
|
|
71
|
+
const { program, checker, probesDir } = opened;
|
|
72
|
+
const isAssignableTo = assignabilityOf(checker);
|
|
73
|
+
if (!isAssignableTo)
|
|
74
|
+
return undefined;
|
|
75
|
+
const file = program.getSourceFile(`${probesDir}/probes/${plan.fileName}`.split('\\').join('/'));
|
|
76
|
+
const source = file ??
|
|
77
|
+
program.getSourceFiles().find((sf) => sf.fileName.endsWith(`/probes/${plan.fileName}`));
|
|
78
|
+
if (!source)
|
|
79
|
+
return undefined;
|
|
80
|
+
const declared = declaredConstType(source, checker, 'sentComparand') ??
|
|
81
|
+
declaredConstType(source, checker, 'sent');
|
|
82
|
+
const expected = declaredConstType(source, checker, 'expected');
|
|
83
|
+
if (!declared || !expected)
|
|
84
|
+
return undefined;
|
|
85
|
+
const wire = declaredConstType(source, checker, 'sentWire');
|
|
86
|
+
// Whether serialising changes anything observable about the sent type.
|
|
87
|
+
const wireApplied = wire !== undefined &&
|
|
88
|
+
!(isAssignableTo(wire.type, declared.type) && isAssignableTo(declared.type, wire.type));
|
|
89
|
+
// The DECLARED type unless serialising really changed it.
|
|
90
|
+
//
|
|
91
|
+
// The probe declares its wire comparand through a conditional alias that
|
|
92
|
+
// short-circuits back to the declared type whenever that already assigns,
|
|
93
|
+
// and a conditional the checker has not had to resolve carries no members
|
|
94
|
+
// to walk. Reading it unconditionally therefore emptied the field report on
|
|
95
|
+
// exactly the pairs that AGREE — the ones whose only statement is an
|
|
96
|
+
// optionality gap or the wire note (carrick#1341). Where serialisation did
|
|
97
|
+
// change the type the wire form is a mapped type with real members, and it
|
|
98
|
+
// stays the thing compared, because that is what the judge judged.
|
|
99
|
+
return {
|
|
100
|
+
compared: wireApplied ? wire.type : declared.type,
|
|
101
|
+
expected,
|
|
102
|
+
wireApplied,
|
|
103
|
+
checker,
|
|
104
|
+
isAssignableTo,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
57
107
|
/**
|
|
58
108
|
* Field reports for every plan whose probe the program could read, keyed by
|
|
59
109
|
* pair id. A plan with no entry has no report, which is not a claim that its
|
|
@@ -63,44 +113,12 @@ export function pairFieldReports(opened, plans) {
|
|
|
63
113
|
const results = new Map();
|
|
64
114
|
if (!opened)
|
|
65
115
|
return results;
|
|
66
|
-
const { program, checker, probesDir } = opened;
|
|
67
|
-
const isAssignableTo = assignabilityOf(checker);
|
|
68
|
-
if (!isAssignableTo)
|
|
69
|
-
return results;
|
|
70
116
|
for (const plan of plans) {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
program
|
|
74
|
-
.getSourceFiles()
|
|
75
|
-
.find((sf) => sf.fileName.endsWith(`/probes/${plan.fileName}`));
|
|
76
|
-
if (!source)
|
|
77
|
-
continue;
|
|
78
|
-
// Whatever the judge's decisive assignment actually sent: the GraphQL
|
|
79
|
-
// comparand where one exists, then the JSON wire form where one exists.
|
|
80
|
-
// Reading `sent` there instead would describe a type the judge did not
|
|
81
|
-
// compare, which is the one way this walk could contradict it.
|
|
82
|
-
const declared = declaredConstType(source, checker, 'sentComparand') ??
|
|
83
|
-
declaredConstType(source, checker, 'sent');
|
|
84
|
-
const expected = declaredConstType(source, checker, 'expected');
|
|
85
|
-
if (!declared || !expected)
|
|
117
|
+
const pair = comparedTypes(opened, plan);
|
|
118
|
+
if (!pair)
|
|
86
119
|
continue;
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
const wireChanges = wire !== undefined &&
|
|
90
|
-
!(isAssignableTo(wire.type, declared.type) && isAssignableTo(declared.type, wire.type));
|
|
91
|
-
// Walk the DECLARED type unless serialising really changed it.
|
|
92
|
-
//
|
|
93
|
-
// The probe declares its wire comparand through a conditional alias that
|
|
94
|
-
// short-circuits back to the declared type whenever that already assigns,
|
|
95
|
-
// and a conditional the checker has not had to resolve carries no members
|
|
96
|
-
// to walk. Reading it unconditionally therefore emptied the report on
|
|
97
|
-
// exactly the pairs that AGREE — the ones whose only statement is an
|
|
98
|
-
// optionality gap or the wire note (carrick#1341). Where serialisation
|
|
99
|
-
// did change the type the wire form is a mapped type with real members,
|
|
100
|
-
// and it stays the thing compared, because that is what the judge judged.
|
|
101
|
-
const compared = wireChanges ? wire.type : declared.type;
|
|
102
|
-
const report = diffReport(compared, expected.type, checker, isAssignableTo, expected.node, plan.spec.protocol === 'graphql' ? GRAPHQL_SERVER_SUPPLIED : NONE_SERVER_SUPPLIED);
|
|
103
|
-
report.wireApplied = wireChanges;
|
|
120
|
+
const report = diffReport(pair.compared, pair.expected.type, pair.checker, pair.isAssignableTo, pair.expected.node, plan.spec.protocol === 'graphql' ? GRAPHQL_SERVER_SUPPLIED : NONE_SERVER_SUPPLIED);
|
|
121
|
+
report.wireApplied = pair.wireApplied;
|
|
104
122
|
results.set(plan.pairId, report);
|
|
105
123
|
}
|
|
106
124
|
return results;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A sent union whose members the expected type takes only in part, where the
|
|
3
|
+
* rest fail by nothing but the compiler's weak-type check (carrick#1995).
|
|
4
|
+
*
|
|
5
|
+
* A handler that answers a different body per branch (an action that switches
|
|
6
|
+
* on a field of the request) publishes the union of those bodies. A caller of
|
|
7
|
+
* one branch reads the response with a type whose every field is optional. The
|
|
8
|
+
* compiler then rejects each member that shares no field with that type
|
|
9
|
+
* (TS2559, "has no properties in common"): a type whose every property is
|
|
10
|
+
* optional is a "weak type", and the check exists to catch a misspelt property
|
|
11
|
+
* bag. It is not a soundness rule. At runtime every member satisfies a type
|
|
12
|
+
* whose reads all allow absence, and the call only ever receives the body of
|
|
13
|
+
* the branch it asked for, which the types do not say.
|
|
14
|
+
*
|
|
15
|
+
* So the pair is neither compatible nor incompatible: the judge cannot say
|
|
16
|
+
* which member the call receives. Kept narrow on purpose:
|
|
17
|
+
* - the sent type must be a union with at least one member that assigns;
|
|
18
|
+
* - the expected type must be one object type whose every property is
|
|
19
|
+
* optional, with no index or call signatures;
|
|
20
|
+
* - every member that does not assign must be an object with properties, none
|
|
21
|
+
* of which the expected type names.
|
|
22
|
+
* A sent type that is not a union and shares no field with a weak expected
|
|
23
|
+
* type is the misspelling the check exists for, and stays a mismatch.
|
|
24
|
+
*
|
|
25
|
+
* Read in the probe program, over the two types the decisive assignment
|
|
26
|
+
* compared, with the compiler's own relation. Seam: typescript + this bundle.
|
|
27
|
+
*/
|
|
28
|
+
import ts from 'typescript';
|
|
29
|
+
import type { ProbePlan } from './check-probe.js';
|
|
30
|
+
import type { ProbeProgram } from './check-deep.js';
|
|
31
|
+
export interface DispatchUnion {
|
|
32
|
+
/** Members of the sent union. */
|
|
33
|
+
members: number;
|
|
34
|
+
/** Members the expected type takes. */
|
|
35
|
+
agreeing: number;
|
|
36
|
+
}
|
|
37
|
+
/** The finding for every plan whose compared types have this shape, by pair id. */
|
|
38
|
+
export declare function pairDispatchUnions(opened: ProbeProgram | undefined, plans: ProbePlan[]): Map<string, DispatchUnion>;
|
|
39
|
+
/** The shape above for one compared pair, or `undefined` when it does not hold. */
|
|
40
|
+
export declare function dispatchUnion(sent: ts.Type, expected: ts.Type, checker: ts.TypeChecker, isAssignableTo: (source: ts.Type, target: ts.Type) => boolean): DispatchUnion | undefined;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A sent union whose members the expected type takes only in part, where the
|
|
3
|
+
* rest fail by nothing but the compiler's weak-type check (carrick#1995).
|
|
4
|
+
*
|
|
5
|
+
* A handler that answers a different body per branch (an action that switches
|
|
6
|
+
* on a field of the request) publishes the union of those bodies. A caller of
|
|
7
|
+
* one branch reads the response with a type whose every field is optional. The
|
|
8
|
+
* compiler then rejects each member that shares no field with that type
|
|
9
|
+
* (TS2559, "has no properties in common"): a type whose every property is
|
|
10
|
+
* optional is a "weak type", and the check exists to catch a misspelt property
|
|
11
|
+
* bag. It is not a soundness rule. At runtime every member satisfies a type
|
|
12
|
+
* whose reads all allow absence, and the call only ever receives the body of
|
|
13
|
+
* the branch it asked for, which the types do not say.
|
|
14
|
+
*
|
|
15
|
+
* So the pair is neither compatible nor incompatible: the judge cannot say
|
|
16
|
+
* which member the call receives. Kept narrow on purpose:
|
|
17
|
+
* - the sent type must be a union with at least one member that assigns;
|
|
18
|
+
* - the expected type must be one object type whose every property is
|
|
19
|
+
* optional, with no index or call signatures;
|
|
20
|
+
* - every member that does not assign must be an object with properties, none
|
|
21
|
+
* of which the expected type names.
|
|
22
|
+
* A sent type that is not a union and shares no field with a weak expected
|
|
23
|
+
* type is the misspelling the check exists for, and stays a mismatch.
|
|
24
|
+
*
|
|
25
|
+
* Read in the probe program, over the two types the decisive assignment
|
|
26
|
+
* compared, with the compiler's own relation. Seam: typescript + this bundle.
|
|
27
|
+
*/
|
|
28
|
+
import ts from 'typescript';
|
|
29
|
+
import { comparedTypes } from './check-fields.js';
|
|
30
|
+
/** The finding for every plan whose compared types have this shape, by pair id. */
|
|
31
|
+
export function pairDispatchUnions(opened, plans) {
|
|
32
|
+
const results = new Map();
|
|
33
|
+
if (!opened)
|
|
34
|
+
return results;
|
|
35
|
+
for (const plan of plans) {
|
|
36
|
+
const pair = comparedTypes(opened, plan);
|
|
37
|
+
if (!pair)
|
|
38
|
+
continue;
|
|
39
|
+
const finding = dispatchUnion(pair.compared, pair.expected.type, pair.checker, pair.isAssignableTo);
|
|
40
|
+
if (finding)
|
|
41
|
+
results.set(plan.pairId, finding);
|
|
42
|
+
}
|
|
43
|
+
return results;
|
|
44
|
+
}
|
|
45
|
+
/** The shape above for one compared pair, or `undefined` when it does not hold. */
|
|
46
|
+
export function dispatchUnion(sent, expected, checker, isAssignableTo) {
|
|
47
|
+
if (!sent.isUnion())
|
|
48
|
+
return undefined;
|
|
49
|
+
const names = weakPropertyNames(expected, checker);
|
|
50
|
+
if (!names)
|
|
51
|
+
return undefined;
|
|
52
|
+
let agreeing = 0;
|
|
53
|
+
for (const member of sent.types) {
|
|
54
|
+
if (isAssignableTo(member, expected)) {
|
|
55
|
+
agreeing += 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!sharesNoProperty(member, names, checker))
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
if (agreeing === 0 || agreeing === sent.types.length)
|
|
62
|
+
return undefined;
|
|
63
|
+
return { members: sent.types.length, agreeing };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The property names of a weak object type: one object type, at least one
|
|
67
|
+
* property, every property optional, no index signature and no call or
|
|
68
|
+
* construct signature. `undefined` for any other type.
|
|
69
|
+
*/
|
|
70
|
+
function weakPropertyNames(type, checker) {
|
|
71
|
+
if (!(type.flags & ts.TypeFlags.Object))
|
|
72
|
+
return undefined;
|
|
73
|
+
if (checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0 ||
|
|
74
|
+
checker.getSignaturesOfType(type, ts.SignatureKind.Construct).length > 0 ||
|
|
75
|
+
checker.getIndexInfosOfType(type).length > 0) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const properties = checker.getPropertiesOfType(type);
|
|
79
|
+
if (properties.length === 0)
|
|
80
|
+
return undefined;
|
|
81
|
+
if (!properties.every((property) => (property.flags & ts.SymbolFlags.Optional) !== 0)) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
return new Set(properties.map((property) => property.name));
|
|
85
|
+
}
|
|
86
|
+
/** Whether `member` is an object with properties, none of them in `names`. */
|
|
87
|
+
function sharesNoProperty(member, names, checker) {
|
|
88
|
+
if (!(member.flags & ts.TypeFlags.Object))
|
|
89
|
+
return false;
|
|
90
|
+
const properties = checker.getPropertiesOfType(member);
|
|
91
|
+
return properties.length > 0 && properties.every((property) => !names.has(property.name));
|
|
92
|
+
}
|
|
@@ -26,6 +26,7 @@ import { assembleWorkspace, writeProbes, } from './check-workspace.js';
|
|
|
26
26
|
import { buildPoisonIndexes } from './check-poison.js';
|
|
27
27
|
import { openProbeProgram, probeDeepFindings } from './check-deep.js';
|
|
28
28
|
import { pairFieldReports } from './check-fields.js';
|
|
29
|
+
import { pairDispatchUnions } from './check-union.js';
|
|
29
30
|
function runProcess(command, args, cwd) {
|
|
30
31
|
return new Promise((resolve, reject) => {
|
|
31
32
|
const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
@@ -262,6 +263,9 @@ export async function runCheck(opts, onProgress) {
|
|
|
262
263
|
// incompatible (carrick-tools/carrick-cloud#1118). It names what the verdict
|
|
263
264
|
// is about; it never decides one.
|
|
264
265
|
const fieldsByPair = pairFieldReports(probeProgram, probing);
|
|
266
|
+
// And which pairs compared a union the expected side takes only in part,
|
|
267
|
+
// the rest failing by nothing but the weak-type check (carrick#1995).
|
|
268
|
+
const unionsByPair = pairDispatchUnions(probeProgram, probing);
|
|
265
269
|
const verdicts = sortVerdicts([
|
|
266
270
|
...probing.map((plan) => classifyPair({
|
|
267
271
|
plan,
|
|
@@ -270,6 +274,7 @@ export async function runCheck(opts, onProgress) {
|
|
|
270
274
|
scrubCtx,
|
|
271
275
|
deepFindings: deepByPair.get(plan.pairId),
|
|
272
276
|
fieldReport: fieldsByPair.get(plan.pairId),
|
|
277
|
+
dispatchUnion: unionsByPair.get(plan.pairId),
|
|
273
278
|
rawTextSide: rawTextSideOf(plan, aliasRecords),
|
|
274
279
|
})),
|
|
275
280
|
...preGated,
|
|
@@ -256,7 +256,8 @@ export function captureStub(opts) {
|
|
|
256
256
|
}
|
|
257
257
|
// ---- Phase A: analysis program over placeholder entry + anchor sources ----
|
|
258
258
|
let resolved;
|
|
259
|
-
const
|
|
259
|
+
const progress = opts.onProgress ?? (() => { });
|
|
260
|
+
const analysisCtx = { repoRoot, entryDir: path.dirname(entryPath), entryPath, guard, progress };
|
|
260
261
|
try {
|
|
261
262
|
resolved = ownerGroups && emitProject
|
|
262
263
|
? resolveAnchorsByOwner(opts, ownerGroups, emitProject, analysisCtx, errors)
|
|
@@ -277,14 +278,9 @@ export function captureStub(opts) {
|
|
|
277
278
|
}
|
|
278
279
|
const scratch = WriteGuard.scratch('carrick-capture-v2-');
|
|
279
280
|
const staging = scratch.dir;
|
|
280
|
-
|
|
281
|
-
// Input .d.ts files (ambient stubs, augmentation declarations, local
|
|
282
|
-
// hand-written declarations in the import closure) are never re-emitted by
|
|
283
|
-
// tsc; they must ship verbatim or the tree's references to them dangle.
|
|
284
|
-
const declarationSources = new Map();
|
|
285
|
-
const sourceByEmitted = new Map();
|
|
286
|
-
let emitPartial = false;
|
|
281
|
+
let written;
|
|
287
282
|
try {
|
|
283
|
+
progress('emit', 'emitting declarations');
|
|
288
284
|
guard.writeFile(entryPath, entryLines.join('\n') + '\n');
|
|
289
285
|
const emitOptions = {
|
|
290
286
|
...parsed.options,
|
|
@@ -300,35 +296,13 @@ export function captureStub(opts) {
|
|
|
300
296
|
outDir: staging,
|
|
301
297
|
rootDir: entryDir,
|
|
302
298
|
};
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
// emitSkipped is PER-PROGRAM even when only one file's declaration emit
|
|
311
|
-
// failed (e.g. TS4023 from a hand-rolled ambient stub shadowing a real
|
|
312
|
-
// package): every other file's .d.ts was still written to the callback.
|
|
313
|
-
// Fail wholesale only when nothing at all emitted; otherwise keep the
|
|
314
|
-
// emitted subset and demote exactly the aliases it cannot support.
|
|
315
|
-
if (emitResult.emitSkipped && emitted.size === 0) {
|
|
316
|
-
return fail(stubDir, packageName, ['declaration emit was skipped']);
|
|
317
|
-
}
|
|
318
|
-
emitPartial = emitResult.emitSkipped;
|
|
319
|
-
for (const d of emitResult.diagnostics) {
|
|
320
|
-
errors.push(ts.flattenDiagnosticMessageText(d.messageText, '\n'));
|
|
321
|
-
}
|
|
322
|
-
for (const sourceFile of program.getSourceFiles()) {
|
|
323
|
-
if (!sourceFile.isDeclarationFile)
|
|
324
|
-
continue;
|
|
325
|
-
const abs = path.resolve(sourceFile.fileName);
|
|
326
|
-
const rel = path.relative(entryDir, abs).split(path.sep).join('/');
|
|
327
|
-
if (rel.startsWith('..') || rel.includes('node_modules/'))
|
|
328
|
-
continue;
|
|
329
|
-
declarationSources.set(rel, sourceFile.getFullText());
|
|
330
|
-
sourceByEmitted.set(rel, abs);
|
|
331
|
-
}
|
|
299
|
+
written = emitDeclarations({
|
|
300
|
+
rootNames: [entryPath, ...augmentationSources],
|
|
301
|
+
options: emitOptions,
|
|
302
|
+
host: deno?.host(emitOptions),
|
|
303
|
+
staging,
|
|
304
|
+
entryDir,
|
|
305
|
+
});
|
|
332
306
|
}
|
|
333
307
|
catch (err) {
|
|
334
308
|
return fail(stubDir, packageName, [err instanceof Error ? err.message : String(err)]);
|
|
@@ -338,6 +312,9 @@ export function captureStub(opts) {
|
|
|
338
312
|
guard.unlink(entryPath);
|
|
339
313
|
scratch.guard.remove(staging);
|
|
340
314
|
}
|
|
315
|
+
const { emitted, declarationSources, sourceByEmitted } = written;
|
|
316
|
+
const emitPartial = written.partial;
|
|
317
|
+
errors.push(...written.diagnostics);
|
|
341
318
|
// ---- Partial-emit recovery ----
|
|
342
319
|
// The corpus-2 notifications-svc shape: one file's declaration emit was
|
|
343
320
|
// skipped but the rest of the tree emitted fine. Keep the tree; demote any
|
|
@@ -490,6 +467,7 @@ export function captureStub(opts) {
|
|
|
490
467
|
target: parsed.options.target !== undefined ? ts.ScriptTarget[parsed.options.target] : undefined,
|
|
491
468
|
}, null, 2) + '\n');
|
|
492
469
|
// ---- Capture-time self-check (per-alias closure attribution) ----
|
|
470
|
+
progress('self-check', 'checking the stub');
|
|
493
471
|
const aliases = selfCheckStub({
|
|
494
472
|
guard: stubGuard,
|
|
495
473
|
stubDir,
|
|
@@ -526,6 +504,58 @@ export function captureStub(opts) {
|
|
|
526
504
|
errors,
|
|
527
505
|
};
|
|
528
506
|
}
|
|
507
|
+
/**
|
|
508
|
+
* Phase B: build the program of the final entry and emit its declarations.
|
|
509
|
+
*
|
|
510
|
+
* A function of its own so that its program dies with it (carrick#1916). The
|
|
511
|
+
* program is as large as the service, and nothing after the emit reads it:
|
|
512
|
+
* what the capture needs is the text returned here. While the emit ran inside
|
|
513
|
+
* `captureStub`, that one long function's frame went on holding the program
|
|
514
|
+
* for as long as the capture ran, so the self-check loaded its own program
|
|
515
|
+
* beside it; on a 2,345-file service that was 1,290 MB of a 2,741 MB peak.
|
|
516
|
+
*
|
|
517
|
+
* The caller builds the options and the host. A host is held by its program
|
|
518
|
+
* and holds none itself, so the caller keeping one keeps no program alive.
|
|
519
|
+
*
|
|
520
|
+
* Throws when the emit was skipped and wrote nothing.
|
|
521
|
+
*/
|
|
522
|
+
function emitDeclarations(args) {
|
|
523
|
+
const emitted = new Map();
|
|
524
|
+
const declarationSources = new Map();
|
|
525
|
+
const sourceByEmitted = new Map();
|
|
526
|
+
const program = ts.createProgram(args.rootNames, args.options, args.host);
|
|
527
|
+
const emitResult = program.emit(undefined, (fileName, text, _bom, _error, sources) => {
|
|
528
|
+
emitted.set(fileName, text);
|
|
529
|
+
if (sources?.[0])
|
|
530
|
+
sourceByEmitted.set(path.relative(args.staging, fileName).split(path.sep).join('/'), sources[0].fileName);
|
|
531
|
+
}, undefined,
|
|
532
|
+
/* emitOnlyDtsFiles */ true);
|
|
533
|
+
// emitSkipped is PER-PROGRAM even when only one file's declaration emit
|
|
534
|
+
// failed (e.g. TS4023 from a hand-rolled ambient stub shadowing a real
|
|
535
|
+
// package): every other file's .d.ts was still written to the callback.
|
|
536
|
+
// Fail wholesale only when nothing at all emitted; otherwise keep the
|
|
537
|
+
// emitted subset and demote exactly the aliases it cannot support.
|
|
538
|
+
if (emitResult.emitSkipped && emitted.size === 0) {
|
|
539
|
+
throw new Error('declaration emit was skipped');
|
|
540
|
+
}
|
|
541
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
542
|
+
if (!sourceFile.isDeclarationFile)
|
|
543
|
+
continue;
|
|
544
|
+
const abs = path.resolve(sourceFile.fileName);
|
|
545
|
+
const rel = path.relative(args.entryDir, abs).split(path.sep).join('/');
|
|
546
|
+
if (rel.startsWith('..') || rel.includes('node_modules/'))
|
|
547
|
+
continue;
|
|
548
|
+
declarationSources.set(rel, sourceFile.getFullText());
|
|
549
|
+
sourceByEmitted.set(rel, abs);
|
|
550
|
+
}
|
|
551
|
+
return {
|
|
552
|
+
emitted,
|
|
553
|
+
declarationSources,
|
|
554
|
+
sourceByEmitted,
|
|
555
|
+
partial: emitResult.emitSkipped,
|
|
556
|
+
diagnostics: emitResult.diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n')),
|
|
557
|
+
};
|
|
558
|
+
}
|
|
529
559
|
/**
|
|
530
560
|
* Partial-emit demotion: demote every anchor whose alias text names, by a
|
|
531
561
|
* relative or an absolute path, a module the stub will not resolve, and
|
|
@@ -655,6 +685,7 @@ function resolveAnchors(opts, parsed, ctx, deno) {
|
|
|
655
685
|
}
|
|
656
686
|
ctx.guard.writeFile(ctx.entryPath, placeholderLines.join('\n') + '\n');
|
|
657
687
|
try {
|
|
688
|
+
ctx.progress('program', 'building the program the anchors are read in');
|
|
658
689
|
const anchorSources = [
|
|
659
690
|
...new Set(opts.anchors
|
|
660
691
|
.flatMap((a) => (a.source_file ? [path.join(ctx.repoRoot, a.source_file)] : []))),
|
|
@@ -664,6 +695,11 @@ function resolveAnchors(opts, parsed, ctx, deno) {
|
|
|
664
695
|
noEmit: true,
|
|
665
696
|
};
|
|
666
697
|
const program = ts.createProgram([ctx.entryPath, ...anchorSources, ...(deno?.globals ?? [])], options, deno?.host(options));
|
|
698
|
+
// The checker binds every file of the program when it is first asked for,
|
|
699
|
+
// which the first anchor would otherwise do: asked for here, so that the
|
|
700
|
+
// report below says the program is whole before any anchor is read.
|
|
701
|
+
program.getTypeChecker();
|
|
702
|
+
ctx.progress('anchors', `0 of ${opts.anchors.length}`);
|
|
667
703
|
const entrySource = program.getSourceFile(ctx.entryPath);
|
|
668
704
|
const placeholders = new Map();
|
|
669
705
|
if (entrySource) {
|
|
@@ -696,13 +732,17 @@ function resolveAnchors(opts, parsed, ctx, deno) {
|
|
|
696
732
|
siblingSymbolSpecs.set(anchor.symbol_name, entryRelativeSpecifier(ctx.entryDir, ctx.repoRoot, anchor.source_file, resolveFromEntry));
|
|
697
733
|
}
|
|
698
734
|
}
|
|
699
|
-
return opts.anchors.map((request) =>
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
735
|
+
return opts.anchors.map((request, index) => {
|
|
736
|
+
const anchor = resolveAnchor(program, request, {
|
|
737
|
+
repoRoot: ctx.repoRoot,
|
|
738
|
+
entryDir: ctx.entryDir,
|
|
739
|
+
placeholder: placeholders.get(request.alias),
|
|
740
|
+
siblingSymbolSpecs,
|
|
741
|
+
resolveFromEntry,
|
|
742
|
+
});
|
|
743
|
+
ctx.progress('anchors', `${index + 1} of ${opts.anchors.length}`);
|
|
744
|
+
return anchor;
|
|
745
|
+
});
|
|
706
746
|
}
|
|
707
747
|
finally {
|
|
708
748
|
if (fs.existsSync(ctx.entryPath))
|
|
@@ -26,11 +26,12 @@
|
|
|
26
26
|
* inference path in `type-inferrer.ts`), which walks the resolved `Type` and
|
|
27
27
|
* rebuilds the inlined text.
|
|
28
28
|
*
|
|
29
|
-
* Each resolve call builds its own throwaway
|
|
30
|
-
*
|
|
31
|
-
*
|
|
29
|
+
* Each resolve call builds its own throwaway project over the stub tree and
|
|
30
|
+
* reads nothing else: not the service's project, and not whether the sidecar
|
|
31
|
+
* was ever initialised. So the request costs what the stub costs, in a process
|
|
32
|
+
* that has built nothing as in one that has (carrick#1927), and the sidecar's
|
|
33
|
+
* long-lived project never sees stub files.
|
|
32
34
|
*/
|
|
33
|
-
import { Project } from 'ts-morph';
|
|
34
35
|
export interface ResolvedDefinition {
|
|
35
36
|
type_alias: string;
|
|
36
37
|
/** Original declaration text as written (preserves named types) */
|
|
@@ -39,10 +40,6 @@ export interface ResolvedDefinition {
|
|
|
39
40
|
expanded: string;
|
|
40
41
|
}
|
|
41
42
|
export declare class DefinitionResolver {
|
|
42
|
-
private readonly project;
|
|
43
|
-
constructor(options: {
|
|
44
|
-
project: Project;
|
|
45
|
-
});
|
|
46
43
|
/**
|
|
47
44
|
* Resolve surface aliases from a capture stub package directory
|
|
48
45
|
* (`<stub_dir>/types/surface.d.ts` + its declaration tree).
|