carrick 0.3.53
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/LICENSE.md +99 -0
- package/README.md +79 -0
- package/bin/carrick.mjs +168 -0
- package/dist/channel.d.ts +16 -0
- package/dist/channel.js +37 -0
- package/dist/channel.js.map +1 -0
- package/dist/cli.d.ts +29 -0
- package/dist/cli.js +60 -0
- package/dist/cli.js.map +1 -0
- package/dist/contract.d.ts +154 -0
- package/dist/contract.js +141 -0
- package/dist/contract.js.map +1 -0
- package/dist/diagnostics.d.ts +54 -0
- package/dist/diagnostics.js +199 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/hook/post-edit.d.ts +13 -0
- package/dist/hook/post-edit.js +76 -0
- package/dist/hook/post-edit.js.map +1 -0
- package/dist/hook/session-start.d.ts +2 -0
- package/dist/hook/session-start.js +44 -0
- package/dist/hook/session-start.js.map +1 -0
- package/dist/init/identity.d.ts +20 -0
- package/dist/init/identity.js +60 -0
- package/dist/init/identity.js.map +1 -0
- package/dist/init/repos.d.ts +19 -0
- package/dist/init/repos.js +46 -0
- package/dist/init/repos.js.map +1 -0
- package/dist/init/run.d.ts +11 -0
- package/dist/init/run.js +228 -0
- package/dist/init/run.js.map +1 -0
- package/dist/init/settings.d.ts +50 -0
- package/dist/init/settings.js +138 -0
- package/dist/init/settings.js.map +1 -0
- package/dist/log.d.ts +2 -0
- package/dist/log.js +35 -0
- package/dist/log.js.map +1 -0
- package/dist/native.d.ts +48 -0
- package/dist/native.js +127 -0
- package/dist/native.js.map +1 -0
- package/dist/render.d.ts +65 -0
- package/dist/render.js +334 -0
- package/dist/render.js.map +1 -0
- package/dist/root.d.ts +30 -0
- package/dist/root.js +82 -0
- package/dist/root.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +255 -0
- package/dist/server.js.map +1 -0
- package/dist/templates.d.ts +18 -0
- package/dist/templates.js +66 -0
- package/dist/templates.js.map +1 -0
- package/package.json +72 -0
- package/plugin/.claude-plugin/plugin.json +6 -0
- package/plugin/.lsp.json +14 -0
- package/plugin/hooks/hooks.json +27 -0
- package/sidecar/dist/src/bundler.d.ts +141 -0
- package/sidecar/dist/src/bundler.js +680 -0
- package/sidecar/dist/src/capture/anchors.d.ts +61 -0
- package/sidecar/dist/src/capture/anchors.js +1132 -0
- package/sidecar/dist/src/capture/api.d.ts +378 -0
- package/sidecar/dist/src/capture/api.js +10 -0
- package/sidecar/dist/src/capture/augmentations.d.ts +20 -0
- package/sidecar/dist/src/capture/augmentations.js +60 -0
- package/sidecar/dist/src/capture/check-classify.d.ts +58 -0
- package/sidecar/dist/src/capture/check-classify.js +189 -0
- package/sidecar/dist/src/capture/check-deep.d.ts +32 -0
- package/sidecar/dist/src/capture/check-deep.js +91 -0
- package/sidecar/dist/src/capture/check-poison.d.ts +40 -0
- package/sidecar/dist/src/capture/check-poison.js +155 -0
- package/sidecar/dist/src/capture/check-probe.d.ts +70 -0
- package/sidecar/dist/src/capture/check-probe.js +147 -0
- package/sidecar/dist/src/capture/check-scrub.d.ts +31 -0
- package/sidecar/dist/src/capture/check-scrub.js +71 -0
- package/sidecar/dist/src/capture/check-workspace.d.ts +52 -0
- package/sidecar/dist/src/capture/check-workspace.js +218 -0
- package/sidecar/dist/src/capture/check.d.ts +39 -0
- package/sidecar/dist/src/capture/check.js +450 -0
- package/sidecar/dist/src/capture/deep-walk.d.ts +62 -0
- package/sidecar/dist/src/capture/deep-walk.js +243 -0
- package/sidecar/dist/src/capture/index.d.ts +36 -0
- package/sidecar/dist/src/capture/index.js +477 -0
- package/sidecar/dist/src/capture/lockfile.d.ts +48 -0
- package/sidecar/dist/src/capture/lockfile.js +490 -0
- package/sidecar/dist/src/capture/machinery.d.ts +59 -0
- package/sidecar/dist/src/capture/machinery.js +160 -0
- package/sidecar/dist/src/capture/node-builder.d.ts +37 -0
- package/sidecar/dist/src/capture/node-builder.js +123 -0
- package/sidecar/dist/src/capture/paths-rewrite.d.ts +34 -0
- package/sidecar/dist/src/capture/paths-rewrite.js +100 -0
- package/sidecar/dist/src/capture/self-check.d.ts +38 -0
- package/sidecar/dist/src/capture/self-check.js +317 -0
- package/sidecar/dist/src/capture/specifiers.d.ts +39 -0
- package/sidecar/dist/src/capture/specifiers.js +56 -0
- package/sidecar/dist/src/definition-resolver.d.ts +57 -0
- package/sidecar/dist/src/definition-resolver.js +153 -0
- package/sidecar/dist/src/index.d.ts +14 -0
- package/sidecar/dist/src/index.js +564 -0
- package/sidecar/dist/src/monorepo-builder.d.ts +129 -0
- package/sidecar/dist/src/monorepo-builder.js +584 -0
- package/sidecar/dist/src/project-loader.d.ts +130 -0
- package/sidecar/dist/src/project-loader.js +399 -0
- package/sidecar/dist/src/type-inferrer.d.ts +940 -0
- package/sidecar/dist/src/type-inferrer.js +3540 -0
- package/sidecar/dist/src/type-structural-expander.d.ts +61 -0
- package/sidecar/dist/src/type-structural-expander.js +283 -0
- package/sidecar/dist/src/type-text-canonicalizer.d.ts +40 -0
- package/sidecar/dist/src/type-text-canonicalizer.js +297 -0
- package/sidecar/dist/src/types.d.ts +669 -0
- package/sidecar/dist/src/types.js +5 -0
- package/sidecar/dist/src/validators.d.ts +2214 -0
- package/sidecar/dist/src/validators.js +336 -0
- package/sidecar/package.json +6 -0
- package/templates/carrick.json +7 -0
- package/templates/carrick.yml +45 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostic parsing + four-bucket classification for the v2 check phase.
|
|
3
|
+
*
|
|
4
|
+
* The judge is the vendored `tsc` CLI with `--pretty false`, run from the
|
|
5
|
+
* workspace root so file locations print workspace-relative (no temp path in
|
|
6
|
+
* the location prefix). This module turns that text into per-pair verdicts,
|
|
7
|
+
* classifying by diagnostic code + file + line (never by line position alone):
|
|
8
|
+
*
|
|
9
|
+
* poison (stub-file diagnostic) -> unverifiable [highest precedence]
|
|
10
|
+
* surface import error (probe lines 1-2)-> unverifiable
|
|
11
|
+
* IsAny gate fired (TS2344) -> gate_caught_baked_any
|
|
12
|
+
* IsUnknown/IsNever gate fired (TS2344) -> unverifiable
|
|
13
|
+
* assignment-class error -> incompatible
|
|
14
|
+
* no diagnostics -> compatible [lowest precedence]
|
|
15
|
+
*
|
|
16
|
+
* Gate precedence over the assignment line is load-bearing: an `unknown` side
|
|
17
|
+
* produces BOTH a gate TS2344 and an assignment TS2322, and reading the latter
|
|
18
|
+
* would mislabel an unverifiable pair as incompatible.
|
|
19
|
+
*
|
|
20
|
+
* Seam: node builtins + this bundle only.
|
|
21
|
+
*/
|
|
22
|
+
import { scrubDiagnostic } from './check-scrub.js';
|
|
23
|
+
const PRIMARY_RE = /^(?<file>(?:[a-zA-Z]:)?[^(]*?)\((?<line>\d+),(?<col>\d+)\): error TS(?<code>\d+): (?<msg>.*)$/;
|
|
24
|
+
/** Parse `tsc --pretty false` output into structured diagnostics. */
|
|
25
|
+
export function parseTscOutput(stdout) {
|
|
26
|
+
const diags = [];
|
|
27
|
+
let current = null;
|
|
28
|
+
for (const rawLine of stdout.split('\n')) {
|
|
29
|
+
const line = rawLine.replace(/\r$/, '');
|
|
30
|
+
const m = line.match(PRIMARY_RE);
|
|
31
|
+
if (m && m.groups) {
|
|
32
|
+
current = {
|
|
33
|
+
file: m.groups.file.split('\\').join('/'),
|
|
34
|
+
line: Number(m.groups.line),
|
|
35
|
+
col: Number(m.groups.col),
|
|
36
|
+
code: Number(m.groups.code),
|
|
37
|
+
message: m.groups.msg,
|
|
38
|
+
};
|
|
39
|
+
diags.push(current);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
// Indented continuation lines belong to the preceding primary diagnostic.
|
|
43
|
+
if (current && /^\s+\S/.test(line)) {
|
|
44
|
+
current.message += '\n' + line;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
// Blank line or summary ("Found N errors.") ends the current run.
|
|
48
|
+
current = null;
|
|
49
|
+
}
|
|
50
|
+
return diags;
|
|
51
|
+
}
|
|
52
|
+
/** Assignment-class codes: a real structural mismatch on the value assignment. */
|
|
53
|
+
const ASSIGNMENT_CODES = new Set([2322, 2559, 2739, 2740, 2741, 2769, 2345]);
|
|
54
|
+
function sideForGate(name, plan) {
|
|
55
|
+
const [which, kind] = name.split(':');
|
|
56
|
+
const side = which === 'sent' ? plan.direction.sent : plan.direction.expected;
|
|
57
|
+
return { side, kind };
|
|
58
|
+
}
|
|
59
|
+
function endpointAliasFor(side, plan) {
|
|
60
|
+
return side === plan.direction.sent
|
|
61
|
+
? plan.sentEndpoint.alias
|
|
62
|
+
: plan.expectedEndpoint.alias;
|
|
63
|
+
}
|
|
64
|
+
/** Classify one pair into exactly one bucket, honouring the precedence order. */
|
|
65
|
+
export function classifyPair(input) {
|
|
66
|
+
const { plan, probeDiags, poisonReason, scrubCtx } = input;
|
|
67
|
+
const codes = [...new Set(probeDiags.map((d) => d.code))].sort((a, b) => a - b);
|
|
68
|
+
const base = { pair_id: plan.pairId, pair_key: plan.spec.pair_key, codes };
|
|
69
|
+
// Every branch below this point except the last two returns a verdict about
|
|
70
|
+
// a type nobody could read; each states that in one place rather than
|
|
71
|
+
// repeating the reasoning.
|
|
72
|
+
const notAFact = (reason) => ({ resolved: false, unresolved_reason: reason });
|
|
73
|
+
// 1. Poison: a diagnostic in this pair's own alias closure (either side)
|
|
74
|
+
// makes the pair unverifiable, never "no probe error -> compatible". A
|
|
75
|
+
// sibling alias's poison in the same service no longer reaches here.
|
|
76
|
+
for (const side of ['producer', 'consumer']) {
|
|
77
|
+
const endpoint = plan.spec[side];
|
|
78
|
+
const reason = poisonReason(endpoint.service_name, endpoint.alias);
|
|
79
|
+
if (reason) {
|
|
80
|
+
return {
|
|
81
|
+
...base,
|
|
82
|
+
bucket: 'unverifiable',
|
|
83
|
+
gate: `poison:${side}`,
|
|
84
|
+
diagnostic: `the type stub for service '${endpoint.service_name}' does not typecheck (its own declarations carry diagnostics); compatibility cannot be verified.`,
|
|
85
|
+
...notAFact(`the ${side} stub does not typecheck`),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// 2. Surface import error (missing/renamed export): probe import lines.
|
|
90
|
+
const importDiag = probeDiags.find((d) => plan.importLines.includes(d.line));
|
|
91
|
+
if (importDiag) {
|
|
92
|
+
const side = plan.importLines[0] === importDiag.line ? plan.direction.sent : plan.direction.expected;
|
|
93
|
+
const alias = endpointAliasFor(side, plan);
|
|
94
|
+
return {
|
|
95
|
+
...base,
|
|
96
|
+
bucket: 'unverifiable',
|
|
97
|
+
gate: `import:${side}`,
|
|
98
|
+
diagnostic: `surface export '${alias}' for the ${side} is missing or renamed; compatibility cannot be verified.`,
|
|
99
|
+
...notAFact(`the ${side} surface export is missing or renamed`),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// 3/4. Probe gates (TS2344). IsAny outranks IsUnknown/IsNever.
|
|
103
|
+
const gateDiags = probeDiags.filter((d) => d.code === 2344 && plan.gateLines.has(d.line));
|
|
104
|
+
const anyGate = gateDiags
|
|
105
|
+
.map((d) => plan.gateLines.get(d.line))
|
|
106
|
+
.find((name) => name.endsWith(':any'));
|
|
107
|
+
if (anyGate) {
|
|
108
|
+
const { side } = sideForGate(anyGate, plan);
|
|
109
|
+
return {
|
|
110
|
+
...base,
|
|
111
|
+
bucket: 'gate_caught_baked_any',
|
|
112
|
+
gate: `${side}:any`,
|
|
113
|
+
diagnostic: `the ${side} type resolved to 'any' at check time; compatibility cannot be verified (a type inferred through a missing library bakes to any).`,
|
|
114
|
+
...notAFact(`the ${side} type is 'any'`),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const decayGate = gateDiags
|
|
118
|
+
.map((d) => plan.gateLines.get(d.line))
|
|
119
|
+
.find((name) => name.endsWith(':unknown') || name.endsWith(':never'));
|
|
120
|
+
if (decayGate) {
|
|
121
|
+
const { side, kind } = sideForGate(decayGate, plan);
|
|
122
|
+
return {
|
|
123
|
+
...base,
|
|
124
|
+
bucket: 'unverifiable',
|
|
125
|
+
gate: `${side}:${kind}`,
|
|
126
|
+
diagnostic: `the ${side} type resolved to '${kind}' at check time; compatibility cannot be verified.`,
|
|
127
|
+
...notAFact(`the ${side} type is '${kind}'`),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// 5. Assignment-class error on the value assignment line -> incompatible.
|
|
131
|
+
const assignDiag = probeDiags.find((d) => d.line === plan.assignmentLine && ASSIGNMENT_CODES.has(d.code));
|
|
132
|
+
if (assignDiag) {
|
|
133
|
+
return {
|
|
134
|
+
...base,
|
|
135
|
+
bucket: 'incompatible',
|
|
136
|
+
diagnostic: scrubDiagnostic(assignDiag.message, scrubCtx, plan.sentEndpoint.alias, plan.expectedEndpoint.alias),
|
|
137
|
+
...factness(input),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// Any other diagnostic on the assignment line that is not a known assignment
|
|
141
|
+
// code still means the pair could not be cleanly verified.
|
|
142
|
+
const otherAssign = probeDiags.find((d) => d.line === plan.assignmentLine);
|
|
143
|
+
if (otherAssign) {
|
|
144
|
+
return {
|
|
145
|
+
...base,
|
|
146
|
+
bucket: 'unverifiable',
|
|
147
|
+
gate: 'assignment:other',
|
|
148
|
+
diagnostic: scrubDiagnostic(otherAssign.message, scrubCtx, plan.sentEndpoint.alias, plan.expectedEndpoint.alias),
|
|
149
|
+
...notAFact('the probe raised a diagnostic that is not an assignment mismatch'),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// 6. No diagnostics -> compatible.
|
|
153
|
+
return { ...base, bucket: 'compatible', ...factness(input) };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Whether a compared pair is a FACT about two known types (carrick#707, R1d).
|
|
157
|
+
*
|
|
158
|
+
* The probe gates only rule out a WHOLLY top-typed side. A pair whose producer
|
|
159
|
+
* declares `{ id: string; meta: any }` clears every gate and then reads
|
|
160
|
+
* compatible against literally any counterparty `meta`, because `any` is
|
|
161
|
+
* bidirectionally assignable. That is not a compatibility result, and a reader
|
|
162
|
+
* who acts on it acts on nothing. So `resolved` additionally requires the deep
|
|
163
|
+
* walk to have RUN over both sides in the assembled workspace and come back
|
|
164
|
+
* empty. A walk that could not run leaves the verdict not-a-fact: absence of
|
|
165
|
+
* findings is not evidence.
|
|
166
|
+
*/
|
|
167
|
+
function factness(input) {
|
|
168
|
+
const findings = input.deepFindings;
|
|
169
|
+
if (!findings) {
|
|
170
|
+
return {
|
|
171
|
+
resolved: false,
|
|
172
|
+
unresolved_reason: 'the type could not be walked for member-level any/unknown after install, so the comparison is not established as a fact',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
for (const side of ['sent', 'expected']) {
|
|
176
|
+
const first = findings[side][0];
|
|
177
|
+
if (!first)
|
|
178
|
+
continue;
|
|
179
|
+
const label = side === 'sent' ? input.plan.direction.sent : input.plan.direction.expected;
|
|
180
|
+
const where = first.path === '' ? 'its root' : `'${first.path}'`;
|
|
181
|
+
return {
|
|
182
|
+
resolved: false,
|
|
183
|
+
unresolved_reason: first.kind === 'budget_exhausted'
|
|
184
|
+
? `the ${label} type is too deep or wide to verify at ${where}`
|
|
185
|
+
: `the ${label} type carries '${first.kind}' at ${where}, which every counterparty shape satisfies`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return { resolved: true };
|
|
189
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check-time fact-ness walk (carrick#707, R1d).
|
|
3
|
+
*
|
|
4
|
+
* The probe gates prove the two sides are not WHOLLY `any`/`unknown`/`never`.
|
|
5
|
+
* They cannot see a member three levels down, and a member-level `any` accepts
|
|
6
|
+
* every counterparty shape — so "no diagnostic" there is not a compatibility
|
|
7
|
+
* result, it is the absence of one. The capture-time walk catches most of it,
|
|
8
|
+
* but only what the capture could resolve: a member that decayed through a
|
|
9
|
+
* pinned external is TypeScript's `error` placeholder at capture time and is
|
|
10
|
+
* deliberately excluded there, because it heals once the check installs the
|
|
11
|
+
* pin (carrick#450). This walk runs after that install, over the assembled
|
|
12
|
+
* probe workspace, so it sees what the capture could not.
|
|
13
|
+
*
|
|
14
|
+
* It is used for exactly one thing: setting `CheckVerdict.resolved`. No bucket
|
|
15
|
+
* changes because of it, so a scan's verdicts are identical with and without
|
|
16
|
+
* it — what changes is whether a reader is told the verdict is a fact.
|
|
17
|
+
*/
|
|
18
|
+
import type { TypeProvenance } from './api.js';
|
|
19
|
+
import type { ProbePlan } from './check-probe.js';
|
|
20
|
+
/** Deep findings for one pair, per probe side. */
|
|
21
|
+
export interface PairDeepFindings {
|
|
22
|
+
sent: TypeProvenance[];
|
|
23
|
+
expected: TypeProvenance[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Walk both sides of every probe in the assembled workspace.
|
|
27
|
+
*
|
|
28
|
+
* Returns an empty map when the program cannot be built — absence of findings
|
|
29
|
+
* must never be read as "clean", so the caller treats a missing entry as
|
|
30
|
+
* unresolved rather than resolved.
|
|
31
|
+
*/
|
|
32
|
+
export declare function probeDeepFindings(probesDir: string, plans: ProbePlan[]): Map<string, PairDeepFindings>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check-time fact-ness walk (carrick#707, R1d).
|
|
3
|
+
*
|
|
4
|
+
* The probe gates prove the two sides are not WHOLLY `any`/`unknown`/`never`.
|
|
5
|
+
* They cannot see a member three levels down, and a member-level `any` accepts
|
|
6
|
+
* every counterparty shape — so "no diagnostic" there is not a compatibility
|
|
7
|
+
* result, it is the absence of one. The capture-time walk catches most of it,
|
|
8
|
+
* but only what the capture could resolve: a member that decayed through a
|
|
9
|
+
* pinned external is TypeScript's `error` placeholder at capture time and is
|
|
10
|
+
* deliberately excluded there, because it heals once the check installs the
|
|
11
|
+
* pin (carrick#450). This walk runs after that install, over the assembled
|
|
12
|
+
* probe workspace, so it sees what the capture could not.
|
|
13
|
+
*
|
|
14
|
+
* It is used for exactly one thing: setting `CheckVerdict.resolved`. No bucket
|
|
15
|
+
* changes because of it, so a scan's verdicts are identical with and without
|
|
16
|
+
* it — what changes is whether a reader is told the verdict is a fact.
|
|
17
|
+
*/
|
|
18
|
+
import ts from 'typescript';
|
|
19
|
+
import * as fs from 'node:fs';
|
|
20
|
+
import * as path from 'node:path';
|
|
21
|
+
import { findDisqualifyingTopTypes, provenanceOf } from './deep-walk.js';
|
|
22
|
+
/**
|
|
23
|
+
* Walk both sides of every probe in the assembled workspace.
|
|
24
|
+
*
|
|
25
|
+
* Returns an empty map when the program cannot be built — absence of findings
|
|
26
|
+
* must never be read as "clean", so the caller treats a missing entry as
|
|
27
|
+
* unresolved rather than resolved.
|
|
28
|
+
*/
|
|
29
|
+
export function probeDeepFindings(probesDir, plans) {
|
|
30
|
+
const results = new Map();
|
|
31
|
+
if (plans.length === 0)
|
|
32
|
+
return results;
|
|
33
|
+
const configPath = path.join(probesDir, 'tsconfig.json');
|
|
34
|
+
if (!fs.existsSync(configPath))
|
|
35
|
+
return results;
|
|
36
|
+
let program;
|
|
37
|
+
try {
|
|
38
|
+
const raw = ts.readConfigFile(configPath, (f) => fs.readFileSync(f, 'utf8'));
|
|
39
|
+
if (raw.error)
|
|
40
|
+
return results;
|
|
41
|
+
const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, probesDir);
|
|
42
|
+
const fileNames = plans
|
|
43
|
+
.map((plan) => path.join(probesDir, 'probes', plan.fileName))
|
|
44
|
+
.filter((f) => fs.existsSync(f));
|
|
45
|
+
if (fileNames.length === 0)
|
|
46
|
+
return results;
|
|
47
|
+
program = ts.createProgram(fileNames, { ...parsed.options, noEmit: true });
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return results;
|
|
51
|
+
}
|
|
52
|
+
const checker = program.getTypeChecker();
|
|
53
|
+
for (const plan of plans) {
|
|
54
|
+
const file = program.getSourceFile(path.join(probesDir, 'probes', plan.fileName));
|
|
55
|
+
if (!file)
|
|
56
|
+
continue;
|
|
57
|
+
const sent = walkImportedAlias(file, 'Sent', program, checker);
|
|
58
|
+
const expected = walkImportedAlias(file, 'Expected', program, checker);
|
|
59
|
+
if (sent === undefined || expected === undefined)
|
|
60
|
+
continue;
|
|
61
|
+
results.set(plan.pairId, { sent, expected });
|
|
62
|
+
}
|
|
63
|
+
return results;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Findings for one of the probe's two imported surface aliases, or `undefined`
|
|
67
|
+
* when the alias cannot be resolved at all (the import-error path, which the
|
|
68
|
+
* classifier already reports; there is nothing to add here).
|
|
69
|
+
*/
|
|
70
|
+
function walkImportedAlias(file, localName, program, checker) {
|
|
71
|
+
for (const statement of file.statements) {
|
|
72
|
+
if (!ts.isImportDeclaration(statement))
|
|
73
|
+
continue;
|
|
74
|
+
const bindings = statement.importClause?.namedBindings;
|
|
75
|
+
if (!bindings || !ts.isNamedImports(bindings))
|
|
76
|
+
continue;
|
|
77
|
+
for (const element of bindings.elements) {
|
|
78
|
+
if (element.name.text !== localName)
|
|
79
|
+
continue;
|
|
80
|
+
const local = checker.getSymbolAtLocation(element.name);
|
|
81
|
+
if (!local)
|
|
82
|
+
return undefined;
|
|
83
|
+
const target = local.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(local) : local;
|
|
84
|
+
const type = checker.getDeclaredTypeOfSymbol(target);
|
|
85
|
+
if (!type)
|
|
86
|
+
return undefined;
|
|
87
|
+
return findDisqualifyingTopTypes(type, program, checker, element.name).map(provenanceOf);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #438 part 2: stub-tree poison containment.
|
|
3
|
+
*
|
|
4
|
+
* A diagnostic in a service's own stub tree (a broken surface line, a nested
|
|
5
|
+
* declaration file that fails to typecheck) used to poison the WHOLE service —
|
|
6
|
+
* every pair with that service as producer/consumer read `unverifiable`
|
|
7
|
+
* (gate=poison:*), masking every clean pair behind one bad alias. This module
|
|
8
|
+
* contains poison to the aliases actually reachable from the poisoned file:
|
|
9
|
+
*
|
|
10
|
+
* - a diagnostic on the surface entry is attributed to the alias whose
|
|
11
|
+
* `export type` statement SPAN covers the diagnostic's line (a multi-line
|
|
12
|
+
* alias reports on an interior line);
|
|
13
|
+
* - a diagnostic in a nested tree file is attributed to every alias whose
|
|
14
|
+
* import closure (surface import-type seeds, then BFS over relative
|
|
15
|
+
* imports — the same closure the capture self-check computes) includes that
|
|
16
|
+
* file.
|
|
17
|
+
*
|
|
18
|
+
* A stub-tree diagnostic reachable from NO alias (e.g. a cross-stub
|
|
19
|
+
* `declare global` TS2717 collision in an augmentation file, which
|
|
20
|
+
* `skipLibCheck:false` exists to surface — see check-workspace.ts) has no
|
|
21
|
+
* closure to contain it and genuinely can affect any alias's comparison, so it
|
|
22
|
+
* falls back to service-wide poison. Soundness (never falsely compatible) wins
|
|
23
|
+
* over precision in that one case; every attributable diagnostic is contained.
|
|
24
|
+
*
|
|
25
|
+
* Seam: node builtins + `typescript` + this bundle only.
|
|
26
|
+
*/
|
|
27
|
+
import type { AssembledWorkspace } from './check-workspace.js';
|
|
28
|
+
export interface StubPoisonIndex {
|
|
29
|
+
serviceName: string;
|
|
30
|
+
/** Workspace-relative surface key: `packages/<dir>/types/surface.d.ts`. */
|
|
31
|
+
surfaceFile: string;
|
|
32
|
+
/** Every alias declared in the surface. */
|
|
33
|
+
allAliases: Set<string>;
|
|
34
|
+
/** Alias whose `export type` statement span covers the given surface line. */
|
|
35
|
+
aliasAtSurfaceLine(line: number): string | undefined;
|
|
36
|
+
/** Aliases whose import closure includes the given workspace-relative file. */
|
|
37
|
+
aliasesForFile(fileKey: string): string[];
|
|
38
|
+
}
|
|
39
|
+
/** One index per assembled stub, keyed by service name. */
|
|
40
|
+
export declare function buildPoisonIndexes(ws: AssembledWorkspace): Map<string, StubPoisonIndex>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #438 part 2: stub-tree poison containment.
|
|
3
|
+
*
|
|
4
|
+
* A diagnostic in a service's own stub tree (a broken surface line, a nested
|
|
5
|
+
* declaration file that fails to typecheck) used to poison the WHOLE service —
|
|
6
|
+
* every pair with that service as producer/consumer read `unverifiable`
|
|
7
|
+
* (gate=poison:*), masking every clean pair behind one bad alias. This module
|
|
8
|
+
* contains poison to the aliases actually reachable from the poisoned file:
|
|
9
|
+
*
|
|
10
|
+
* - a diagnostic on the surface entry is attributed to the alias whose
|
|
11
|
+
* `export type` statement SPAN covers the diagnostic's line (a multi-line
|
|
12
|
+
* alias reports on an interior line);
|
|
13
|
+
* - a diagnostic in a nested tree file is attributed to every alias whose
|
|
14
|
+
* import closure (surface import-type seeds, then BFS over relative
|
|
15
|
+
* imports — the same closure the capture self-check computes) includes that
|
|
16
|
+
* file.
|
|
17
|
+
*
|
|
18
|
+
* A stub-tree diagnostic reachable from NO alias (e.g. a cross-stub
|
|
19
|
+
* `declare global` TS2717 collision in an augmentation file, which
|
|
20
|
+
* `skipLibCheck:false` exists to surface — see check-workspace.ts) has no
|
|
21
|
+
* closure to contain it and genuinely can affect any alias's comparison, so it
|
|
22
|
+
* falls back to service-wide poison. Soundness (never falsely compatible) wins
|
|
23
|
+
* over precision in that one case; every attributable diagnostic is contained.
|
|
24
|
+
*
|
|
25
|
+
* Seam: node builtins + `typescript` + this bundle only.
|
|
26
|
+
*/
|
|
27
|
+
import ts from 'typescript';
|
|
28
|
+
import * as fs from 'node:fs';
|
|
29
|
+
import * as path from 'node:path';
|
|
30
|
+
import { collectSpecifiers, isRelative } from './specifiers.js';
|
|
31
|
+
/** One index per assembled stub, keyed by service name. */
|
|
32
|
+
export function buildPoisonIndexes(ws) {
|
|
33
|
+
const indexes = new Map();
|
|
34
|
+
for (const stub of ws.stubs) {
|
|
35
|
+
const index = buildOne(ws, stub.serviceName, stub.packageDir);
|
|
36
|
+
if (index)
|
|
37
|
+
indexes.set(stub.serviceName, index);
|
|
38
|
+
}
|
|
39
|
+
return indexes;
|
|
40
|
+
}
|
|
41
|
+
function buildOne(ws, serviceName, packageDir) {
|
|
42
|
+
const typesDirAbs = path.join(ws.workspaceDir, 'packages', packageDir, 'types');
|
|
43
|
+
const surfaceAbs = path.join(typesDirAbs, 'surface.d.ts');
|
|
44
|
+
if (!fs.existsSync(surfaceAbs))
|
|
45
|
+
return undefined;
|
|
46
|
+
const keyPrefix = `packages/${packageDir}/types`;
|
|
47
|
+
const surfaceFile = `${keyPrefix}/surface.d.ts`;
|
|
48
|
+
// Collect the whole `.d.ts` tree, keyed as `packages/<dir>/types/<rel>`.
|
|
49
|
+
const treeAbs = [];
|
|
50
|
+
const walk = (dir) => {
|
|
51
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
52
|
+
const p = path.join(dir, entry.name);
|
|
53
|
+
if (entry.isDirectory())
|
|
54
|
+
walk(p);
|
|
55
|
+
else if (entry.name.endsWith('.d.ts'))
|
|
56
|
+
treeAbs.push(p);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
walk(typesDirAbs);
|
|
60
|
+
const treeAbsSet = new Set(treeAbs.map((f) => path.resolve(f)));
|
|
61
|
+
const keyOf = (abs) => `${keyPrefix}/${path.relative(typesDirAbs, abs).split(path.sep).join('/')}`;
|
|
62
|
+
const resolveRel = (fromAbs, spec) => {
|
|
63
|
+
if (!isRelative(spec))
|
|
64
|
+
return undefined;
|
|
65
|
+
const base = path.resolve(path.dirname(fromAbs), spec);
|
|
66
|
+
const candidates = [
|
|
67
|
+
`${base}.d.ts`,
|
|
68
|
+
path.join(base, 'index.d.ts'),
|
|
69
|
+
base.endsWith('.js') ? `${base.slice(0, -3)}.d.ts` : undefined,
|
|
70
|
+
base, // already `.d.ts`
|
|
71
|
+
].filter((c) => c !== undefined);
|
|
72
|
+
for (const c of candidates) {
|
|
73
|
+
if (treeAbsSet.has(path.resolve(c)))
|
|
74
|
+
return c;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
};
|
|
78
|
+
// Per-file relative-import adjacency, over the fileKey namespace.
|
|
79
|
+
const adjacency = new Map();
|
|
80
|
+
for (const abs of treeAbs) {
|
|
81
|
+
const text = fs.readFileSync(abs, 'utf8');
|
|
82
|
+
const neighbors = [];
|
|
83
|
+
for (const spec of collectSpecifiers(text)) {
|
|
84
|
+
const target = resolveRel(abs, spec);
|
|
85
|
+
if (target)
|
|
86
|
+
neighbors.push(keyOf(target));
|
|
87
|
+
}
|
|
88
|
+
adjacency.set(keyOf(abs), neighbors);
|
|
89
|
+
}
|
|
90
|
+
// Surface alias statement spans + each alias's import-type seed files.
|
|
91
|
+
const surfaceText = fs.readFileSync(surfaceAbs, 'utf8');
|
|
92
|
+
const sf = ts.createSourceFile('surface.d.ts', surfaceText, ts.ScriptTarget.Latest, true);
|
|
93
|
+
const allAliases = new Set();
|
|
94
|
+
const spans = [];
|
|
95
|
+
const seedsByAlias = new Map();
|
|
96
|
+
for (const stmt of sf.statements) {
|
|
97
|
+
if (!ts.isTypeAliasDeclaration(stmt))
|
|
98
|
+
continue;
|
|
99
|
+
const alias = stmt.name.text;
|
|
100
|
+
allAliases.add(alias);
|
|
101
|
+
spans.push({
|
|
102
|
+
alias,
|
|
103
|
+
startLine: sf.getLineAndCharacterOfPosition(stmt.getStart(sf)).line + 1,
|
|
104
|
+
endLine: sf.getLineAndCharacterOfPosition(stmt.getEnd()).line + 1,
|
|
105
|
+
});
|
|
106
|
+
const seeds = [];
|
|
107
|
+
const visit = (n) => {
|
|
108
|
+
if (ts.isImportTypeNode(n) &&
|
|
109
|
+
ts.isLiteralTypeNode(n.argument) &&
|
|
110
|
+
ts.isStringLiteral(n.argument.literal) &&
|
|
111
|
+
isRelative(n.argument.literal.text)) {
|
|
112
|
+
const target = resolveRel(surfaceAbs, n.argument.literal.text);
|
|
113
|
+
if (target)
|
|
114
|
+
seeds.push(keyOf(target));
|
|
115
|
+
}
|
|
116
|
+
n.forEachChild(visit);
|
|
117
|
+
};
|
|
118
|
+
visit(stmt);
|
|
119
|
+
seedsByAlias.set(alias, seeds);
|
|
120
|
+
}
|
|
121
|
+
// Invert per-alias closures into fileKey -> aliases.
|
|
122
|
+
const fileToAliases = new Map();
|
|
123
|
+
for (const [alias, seeds] of seedsByAlias) {
|
|
124
|
+
const closure = new Set();
|
|
125
|
+
const queue = [...seeds];
|
|
126
|
+
while (queue.length > 0) {
|
|
127
|
+
const file = queue.pop();
|
|
128
|
+
if (closure.has(file))
|
|
129
|
+
continue;
|
|
130
|
+
closure.add(file);
|
|
131
|
+
for (const next of adjacency.get(file) ?? [])
|
|
132
|
+
queue.push(next);
|
|
133
|
+
}
|
|
134
|
+
for (const file of closure) {
|
|
135
|
+
if (!fileToAliases.has(file))
|
|
136
|
+
fileToAliases.set(file, new Set());
|
|
137
|
+
fileToAliases.get(file).add(alias);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
serviceName,
|
|
142
|
+
surfaceFile,
|
|
143
|
+
allAliases,
|
|
144
|
+
aliasAtSurfaceLine(line) {
|
|
145
|
+
for (const span of spans) {
|
|
146
|
+
if (line >= span.startLine && line <= span.endLine)
|
|
147
|
+
return span.alias;
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
},
|
|
151
|
+
aliasesForFile(fileKey) {
|
|
152
|
+
return [...(fileToAliases.get(fileKey) ?? [])];
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probe generation for the v2 check phase ("tsc as the judge").
|
|
3
|
+
*
|
|
4
|
+
* One probe file per matched pair. The probe is a value-level assignment in
|
|
5
|
+
* the data-flow direction, guarded by six top/bottom-type gates on BOTH sides
|
|
6
|
+
* (IsAny/IsUnknown/IsNever). Value-level (not `[X] extends [Y]`) because the
|
|
7
|
+
* conditional-type relation diverges around `any`, and because the compiler's
|
|
8
|
+
* elaborated assignment error is the user-facing mismatch report.
|
|
9
|
+
*
|
|
10
|
+
* GraphQL pairs additionally unwrap the producer's resolver-return ENVELOPE
|
|
11
|
+
* before the assignment (see the `graphql` branch in `buildProbe`): a GraphQL
|
|
12
|
+
* producer's captured type is the resolver function's return type with
|
|
13
|
+
* transport layers (Promise / async-iterator) already peeled at inference
|
|
14
|
+
* time, which for wrapper-returning resolvers is a single-payload envelope
|
|
15
|
+
* (`{ data: Order; errors: string[] }`) — not the SDL field payload the
|
|
16
|
+
* consumer's selection set reads. The v1 checker unwrapped this structurally
|
|
17
|
+
* at compare time (ts_check type-checker `unwrapGraphqlPayload`, deleted in
|
|
18
|
+
* WP8); the type-level port here is its v2-native equivalent.
|
|
19
|
+
*
|
|
20
|
+
* Seam: node builtins + `typescript` + this bundle only. No imports needed here.
|
|
21
|
+
*/
|
|
22
|
+
import type { CheckPairEndpoint, CheckPairSpec, ProbeProtocol, ProbeTypeKind } from './api.js';
|
|
23
|
+
/** Which pair endpoint supplies the `sent` value / the `expected` binding. */
|
|
24
|
+
export type Side = 'producer' | 'consumer';
|
|
25
|
+
export interface Direction {
|
|
26
|
+
sent: Side;
|
|
27
|
+
expected: Side;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The direction table, keyed on (protocol, type_kind) — one place. Structurally
|
|
31
|
+
* fixes the confirmed HTTP request-body inversion (data flows consumer ->
|
|
32
|
+
* producer for request bodies, so the check is consumer <= producer).
|
|
33
|
+
*
|
|
34
|
+
* | protocol | type_kind | sent | expected |
|
|
35
|
+
* | http, graphql | response | producer | consumer |
|
|
36
|
+
* | http | request | consumer | producer |
|
|
37
|
+
* | socket, pubsub | both | consumer | producer |
|
|
38
|
+
*/
|
|
39
|
+
export declare function directionFor(protocol: ProbeProtocol, typeKind: ProbeTypeKind): Direction;
|
|
40
|
+
/** Deterministic FNV-1a (32-bit) over the pair's stable key. Never a path. */
|
|
41
|
+
export declare function fnv1a(input: string): string;
|
|
42
|
+
/** The pair ID is derived from the pair's semantic identity, not the workspace
|
|
43
|
+
* path, so it is byte-stable across runs. */
|
|
44
|
+
export declare function pairId(spec: CheckPairSpec): string;
|
|
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';
|
|
47
|
+
export interface ProbePlan {
|
|
48
|
+
pairId: string;
|
|
49
|
+
spec: CheckPairSpec;
|
|
50
|
+
/** Probe file basename, e.g. pair_1a2b3c4d.ts */
|
|
51
|
+
fileName: string;
|
|
52
|
+
/** Which endpoint is sent vs expected (resolved via the direction table). */
|
|
53
|
+
direction: Direction;
|
|
54
|
+
sentEndpoint: CheckPairEndpoint;
|
|
55
|
+
expectedEndpoint: CheckPairEndpoint;
|
|
56
|
+
/** Generated probe source. */
|
|
57
|
+
source: string;
|
|
58
|
+
/** Lines the surface imports sit on (1-based). Errors here => unverifiable. */
|
|
59
|
+
importLines: number[];
|
|
60
|
+
/** 1-based line -> gate name. TS2344 here => baked-any / unverifiable. */
|
|
61
|
+
gateLines: Map<number, GateName>;
|
|
62
|
+
/** 1-based line of the value-level assignment. Errors here => incompatible. */
|
|
63
|
+
assignmentLine: number;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Build one probe, recording the exact line of every gate and the assignment so
|
|
67
|
+
* the classifier never depends on hard-coded offsets. `packageOf` maps a
|
|
68
|
+
* service name to its workspace package specifier (e.g. `@carrick/orders`).
|
|
69
|
+
*/
|
|
70
|
+
export declare function buildProbe(spec: CheckPairSpec, packageOf: (serviceName: string) => string): ProbePlan;
|