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,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 check core: "tsc as the judge"
|
|
3
|
+
* (docs/reference/type-compat-synthetic-monorepo.md, Check phase).
|
|
4
|
+
*
|
|
5
|
+
* Consumes two or more capture stub packages, assembles a scratch synthetic
|
|
6
|
+
* monorepo, installs pinned deps with the vendored pnpm (isolated linker),
|
|
7
|
+
* generates one probe per matched pair, runs the vendored `tsc` CLI, and
|
|
8
|
+
* classifies diagnostics into the four buckets. Deterministic given the stubs
|
|
9
|
+
* and pairs: verdicts are byte-stable across runs (the scrub pass removes the
|
|
10
|
+
* per-run temp path, and the pair IDs / ordering derive from the pair specs).
|
|
11
|
+
*
|
|
12
|
+
* Seam: this file imports only node builtins, `typescript` (for the version
|
|
13
|
+
* string), and the rest of this bundle. The sidecar reaches it only through
|
|
14
|
+
* ./index.js. The judge is the `tsc` CLI (no compiler API), which keeps the
|
|
15
|
+
* check phase TS7-ready by construction.
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import * as fs from 'node:fs';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import ts from 'typescript';
|
|
22
|
+
import { buildProbe } from './check-probe.js';
|
|
23
|
+
import { classifyPair, parseTscOutput, } from './check-classify.js';
|
|
24
|
+
import { scrubPaths } from './check-scrub.js';
|
|
25
|
+
import { assembleWorkspace, writeProbes, } from './check-workspace.js';
|
|
26
|
+
import { buildPoisonIndexes } from './check-poison.js';
|
|
27
|
+
import { probeDeepFindings } from './check-deep.js';
|
|
28
|
+
function runProcess(command, args, cwd) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
31
|
+
let stdout = '';
|
|
32
|
+
let stderr = '';
|
|
33
|
+
child.stdout.on('data', (d) => (stdout += d.toString()));
|
|
34
|
+
child.stderr.on('data', (d) => (stderr += d.toString()));
|
|
35
|
+
child.on('error', reject);
|
|
36
|
+
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** Resolve the sidecar root (where the vendored pnpm/tsc live) from this bundle. */
|
|
40
|
+
function sidecarRoot() {
|
|
41
|
+
// dist/src/capture/check.js -> up three -> sidecar root.
|
|
42
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
return path.resolve(here, '..', '..', '..');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Locate a vendored executable the way node locates a package: walk up from
|
|
47
|
+
* the bundle and take the first `node_modules/.bin/<name>` that exists.
|
|
48
|
+
*
|
|
49
|
+
* Where the bins sit depends on how the sidecar arrived. A checkout and the
|
|
50
|
+
* release tarball both keep them in the sidecar's own `node_modules`, so the
|
|
51
|
+
* first candidate hits and nothing changes. An npm install never can: npm
|
|
52
|
+
* strips nested `node_modules` from a published tarball, so the sidecar ships
|
|
53
|
+
* as a plain directory inside the package and its dependencies are installed
|
|
54
|
+
* by the package that carries it — hoisted to the install root, or nested one
|
|
55
|
+
* level up when a version conflict forces it. Assuming one shape is what made
|
|
56
|
+
* an installed `carrick` degrade every pair to unverifiable (carrick#833).
|
|
57
|
+
*
|
|
58
|
+
* `from` is injectable so the layouts can be pinned by test; production always
|
|
59
|
+
* starts at the bundle. Returns the sidecar-local path when nothing exists,
|
|
60
|
+
* which is the caller's "not found" signal and the most useful thing to name.
|
|
61
|
+
*/
|
|
62
|
+
export function resolveVendoredBin(name, from = sidecarRoot()) {
|
|
63
|
+
const local = path.join(from, 'node_modules', '.bin', name);
|
|
64
|
+
for (let dir = from;; dir = path.dirname(dir)) {
|
|
65
|
+
const candidate = path.join(dir, 'node_modules', '.bin', name);
|
|
66
|
+
if (fs.existsSync(candidate))
|
|
67
|
+
return candidate;
|
|
68
|
+
if (path.dirname(dir) === dir)
|
|
69
|
+
return local;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function binPath(name) {
|
|
73
|
+
return resolveVendoredBin(name);
|
|
74
|
+
}
|
|
75
|
+
function unverifiableAll(plans, gate, diagnostic) {
|
|
76
|
+
return plans.map((plan) => ({
|
|
77
|
+
pair_id: plan.pairId,
|
|
78
|
+
pair_key: plan.spec.pair_key,
|
|
79
|
+
bucket: 'unverifiable',
|
|
80
|
+
gate,
|
|
81
|
+
diagnostic,
|
|
82
|
+
codes: [],
|
|
83
|
+
resolved: false,
|
|
84
|
+
unresolved_reason: diagnostic,
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
function sortVerdicts(verdicts) {
|
|
88
|
+
return [...verdicts].sort((a, b) => a.pair_id < b.pair_id ? -1 : a.pair_id > b.pair_id ? 1 : 0);
|
|
89
|
+
}
|
|
90
|
+
/** Run the deterministic check phase. Async: install + tsc run off the event
|
|
91
|
+
* loop so the sidecar stays responsive and can emit keepalive frames. */
|
|
92
|
+
export async function runCheck(opts, onProgress) {
|
|
93
|
+
const progress = onProgress ?? (() => { });
|
|
94
|
+
const cleanup = opts.cleanup !== false;
|
|
95
|
+
const pnpmPath = opts.pnpmPath ?? binPath('pnpm');
|
|
96
|
+
const tscPath = opts.tscPath ?? binPath('tsc');
|
|
97
|
+
const tsVersion = ts.version;
|
|
98
|
+
// Isolation is non-negotiable: without the vendored pnpm we would fall back
|
|
99
|
+
// to a flat npm install that manufactures nominal false-incompatibles. Fail
|
|
100
|
+
// explicitly instead (design Check step 2).
|
|
101
|
+
if (!fs.existsSync(pnpmPath)) {
|
|
102
|
+
const planned = planPairs(opts, (s) => `@carrick/${s}`);
|
|
103
|
+
return {
|
|
104
|
+
success: false,
|
|
105
|
+
workspace_dir: '',
|
|
106
|
+
isolation: 'unavailable',
|
|
107
|
+
install_ok: false,
|
|
108
|
+
install_error: 'vendored pnpm not found; type isolation is unavailable',
|
|
109
|
+
ts_version: tsVersion,
|
|
110
|
+
verdicts: sortVerdicts(unverifiableAll(planned, 'isolation:unavailable', 'type isolation unavailable (pnpm missing); compatibility cannot be verified.')),
|
|
111
|
+
degraded_services: opts.stubs.map((s) => ({
|
|
112
|
+
service_name: s.service_name,
|
|
113
|
+
reason: 'isolation unavailable',
|
|
114
|
+
})),
|
|
115
|
+
errors: ['vendored pnpm not found'],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
progress('assembling', 'assembling synthetic workspace');
|
|
119
|
+
const ws = assembleWorkspace({
|
|
120
|
+
stubs: opts.stubs,
|
|
121
|
+
workspaceRoot: opts.workspaceRoot,
|
|
122
|
+
});
|
|
123
|
+
const scrubCtx = {
|
|
124
|
+
workspaceRoot: ws.workspaceDir,
|
|
125
|
+
packageLabelOf: ws.packageLabelOf,
|
|
126
|
+
};
|
|
127
|
+
// Generate probes. A pair naming a service with no stub is unverifiable.
|
|
128
|
+
const plans = [];
|
|
129
|
+
const unresolved = [];
|
|
130
|
+
for (const spec of opts.pairs) {
|
|
131
|
+
try {
|
|
132
|
+
plans.push(buildProbe(spec, ws.packageOf));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
unresolved.push({
|
|
136
|
+
pair_id: '',
|
|
137
|
+
pair_key: spec.pair_key,
|
|
138
|
+
bucket: 'unverifiable',
|
|
139
|
+
gate: 'missing:stub',
|
|
140
|
+
diagnostic: 'a stub for one side of this pair was not provided; compatibility cannot be verified.',
|
|
141
|
+
codes: [],
|
|
142
|
+
resolved: false,
|
|
143
|
+
unresolved_reason: 'one side of this pair has no captured type surface',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Backfill deterministic pair IDs for unresolved pairs from the spec.
|
|
148
|
+
for (const v of unresolved) {
|
|
149
|
+
const spec = opts.pairs.find((p) => p.pair_key === v.pair_key);
|
|
150
|
+
v.pair_id = fnvOfSpec(spec);
|
|
151
|
+
}
|
|
152
|
+
// Capture-time deep-decay pre-gate (adversarial-review finding 1): a
|
|
153
|
+
// member-level `any`/`unknown` recorded by the capture self-check with no
|
|
154
|
+
// failing-external explanation. The probe gates below are WHOLE-type only
|
|
155
|
+
// — `{ orderId: string; metadata: any }` sails through IsAny and the
|
|
156
|
+
// assignment compiles clean — so such pairs must never reach a probe.
|
|
157
|
+
// `any` routes to gate_caught_baked_any, `unknown` to unverifiable; both
|
|
158
|
+
// read as None downstream, never compatible.
|
|
159
|
+
const aliasRecords = readStubAliasRecords(opts.stubs);
|
|
160
|
+
const preGated = [];
|
|
161
|
+
const probing = [];
|
|
162
|
+
for (const plan of plans) {
|
|
163
|
+
const hit = deepDecayOf(plan, aliasRecords);
|
|
164
|
+
if (!hit) {
|
|
165
|
+
probing.push(plan);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
preGated.push({
|
|
169
|
+
pair_id: plan.pairId,
|
|
170
|
+
pair_key: plan.spec.pair_key,
|
|
171
|
+
// `any` is a confirmed baked top type -> gate_caught_baked_any; `unknown`
|
|
172
|
+
// and `budget_exhausted` (a subtree the capture walk could not finish)
|
|
173
|
+
// are "cannot verify" -> unverifiable. All read as None downstream.
|
|
174
|
+
bucket: hit.kind === 'any' ? 'gate_caught_baked_any' : 'unverifiable',
|
|
175
|
+
gate: `capture:${hit.side}:${hit.kind}`,
|
|
176
|
+
diagnostic: hit.kind === 'budget_exhausted'
|
|
177
|
+
? `the ${hit.side} type is too deep or wide to verify within the ` +
|
|
178
|
+
`capture budget (at '${hit.path}'); compatibility cannot be ` +
|
|
179
|
+
`verified — abstaining so a buried 'any' can never read compatible.`
|
|
180
|
+
: `the ${hit.side} type carries '${hit.kind}' at '${hit.path}' from ` +
|
|
181
|
+
`capture; compatibility cannot be verified (a partially-unresolved ` +
|
|
182
|
+
`type would let an arbitrary shape read compatible).`,
|
|
183
|
+
codes: [],
|
|
184
|
+
resolved: false,
|
|
185
|
+
unresolved_reason: hit.kind === 'budget_exhausted'
|
|
186
|
+
? `the ${hit.side} type is too deep or wide to verify at '${hit.path}'`
|
|
187
|
+
: `the ${hit.side} type carries '${hit.kind}' at '${hit.path}'`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
writeProbes(ws, probing);
|
|
191
|
+
const errors = [];
|
|
192
|
+
const degraded = [];
|
|
193
|
+
// ---- Install (async, off the event loop) --------------------------------
|
|
194
|
+
progress('installing', 'installing pinned dependencies');
|
|
195
|
+
const install = await runProcess(pnpmPath, ['install'], ws.workspaceDir);
|
|
196
|
+
const installOk = install.code === 0;
|
|
197
|
+
let installError;
|
|
198
|
+
if (!installOk) {
|
|
199
|
+
installError = scrubPaths((install.stderr || install.stdout).trim().slice(0, 2000), scrubCtx);
|
|
200
|
+
for (const s of opts.stubs) {
|
|
201
|
+
degraded.push({ service_name: s.service_name, reason: 'workspace install failed' });
|
|
202
|
+
}
|
|
203
|
+
const verdicts = sortVerdicts([
|
|
204
|
+
...unverifiableAll(probing, 'install:failed', 'workspace dependency install failed; compatibility cannot be verified.'),
|
|
205
|
+
// Capture-decay verdicts stand regardless of the install outcome.
|
|
206
|
+
...preGated,
|
|
207
|
+
...unresolved,
|
|
208
|
+
]);
|
|
209
|
+
if (cleanup)
|
|
210
|
+
safeRm(ws.workspaceDir);
|
|
211
|
+
return {
|
|
212
|
+
success: false,
|
|
213
|
+
workspace_dir: cleanup ? '' : ws.workspaceDir,
|
|
214
|
+
isolation: 'pnpm',
|
|
215
|
+
install_ok: false,
|
|
216
|
+
install_error: installError,
|
|
217
|
+
ts_version: tsVersion,
|
|
218
|
+
verdicts,
|
|
219
|
+
degraded_services: degraded,
|
|
220
|
+
errors,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
// ---- Judge (async, off the event loop) ----------------------------------
|
|
224
|
+
progress('checking', 'type-checking probes');
|
|
225
|
+
const tsc = await runProcess(tscPath, ['--noEmit', '--pretty', 'false', '-p', path.join(ws.probesRel, 'tsconfig.json')], ws.workspaceDir);
|
|
226
|
+
const diagnostics = parseTscOutput(tsc.stdout + '\n' + tsc.stderr);
|
|
227
|
+
// Abnormal tsc termination: OOM/SIGKILL (exit code null), an internal
|
|
228
|
+
// crash, or a fileless config error (e.g. TS18003, printed without a
|
|
229
|
+
// (line,col) prefix) all yield ZERO parseable diagnostics — and an empty
|
|
230
|
+
// diagnostic set must never fall through to "compatible". A non-zero exit
|
|
231
|
+
// WITH parsed diagnostics is the legitimate incompatible path (tsc exits 1
|
|
232
|
+
// on ordinary type errors) and stays untouched. Mirrors the install step's
|
|
233
|
+
// `code === 0` guard.
|
|
234
|
+
if (tsc.code !== 0 && diagnostics.length === 0) {
|
|
235
|
+
const excerpt = scrubPaths((tsc.stderr || tsc.stdout).trim().slice(0, 2000), scrubCtx);
|
|
236
|
+
errors.push(`tsc terminated abnormally (exit code ${tsc.code ?? 'null'}) ` +
|
|
237
|
+
`with no parseable diagnostics${excerpt ? `: ${excerpt}` : ''}`);
|
|
238
|
+
for (const s of opts.stubs) {
|
|
239
|
+
degraded.push({
|
|
240
|
+
service_name: s.service_name,
|
|
241
|
+
reason: 'type checker terminated abnormally',
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
const verdicts = sortVerdicts([
|
|
245
|
+
...unverifiableAll(probing, 'tsc:abnormal-termination', 'the type checker terminated abnormally; compatibility cannot be verified.'),
|
|
246
|
+
...preGated,
|
|
247
|
+
...unresolved,
|
|
248
|
+
]);
|
|
249
|
+
if (cleanup)
|
|
250
|
+
safeRm(ws.workspaceDir);
|
|
251
|
+
return {
|
|
252
|
+
success: false,
|
|
253
|
+
workspace_dir: cleanup ? '' : ws.workspaceDir,
|
|
254
|
+
isolation: 'pnpm',
|
|
255
|
+
install_ok: true,
|
|
256
|
+
ts_version: tsVersion,
|
|
257
|
+
verdicts,
|
|
258
|
+
degraded_services: dedupeDegraded(degraded),
|
|
259
|
+
errors,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const { probeDiagsByPair, poison, globalErrors } = attributeDiagnostics(diagnostics, ws, probing);
|
|
263
|
+
for (const e of globalErrors)
|
|
264
|
+
errors.push(scrubPaths(e, scrubCtx));
|
|
265
|
+
// Only SERVICE-WIDE poison (an unattributable stub diagnostic) degrades the
|
|
266
|
+
// whole service; contained alias-scoped poison rides its own verdicts.
|
|
267
|
+
for (const [service, scope] of poison) {
|
|
268
|
+
if (scope.all)
|
|
269
|
+
degraded.push({ service_name: service, reason: scope.reason });
|
|
270
|
+
}
|
|
271
|
+
const poisonReason = (service, alias) => {
|
|
272
|
+
const scope = poison.get(service);
|
|
273
|
+
if (!scope)
|
|
274
|
+
return undefined;
|
|
275
|
+
return scope.all || scope.aliases.has(alias) ? scope.reason : undefined;
|
|
276
|
+
};
|
|
277
|
+
// Fact-ness walk (carrick#707, R1d): the probe gates are whole-type only,
|
|
278
|
+
// so a compared pair is only a fact about two known types when neither side
|
|
279
|
+
// carries a member-level any/unknown HERE — after install, where the capture
|
|
280
|
+
// could not look (carrick#450). It sets `resolved` and nothing else; no
|
|
281
|
+
// bucket depends on it.
|
|
282
|
+
const deepByPair = probeDeepFindings(ws.probesDir, probing);
|
|
283
|
+
const verdicts = sortVerdicts([
|
|
284
|
+
...probing.map((plan) => classifyPair({
|
|
285
|
+
plan,
|
|
286
|
+
probeDiags: probeDiagsByPair.get(plan.pairId) ?? [],
|
|
287
|
+
poisonReason,
|
|
288
|
+
scrubCtx,
|
|
289
|
+
deepFindings: deepByPair.get(plan.pairId),
|
|
290
|
+
})),
|
|
291
|
+
...preGated,
|
|
292
|
+
...unresolved,
|
|
293
|
+
]);
|
|
294
|
+
const workspaceDirOut = cleanup ? '' : ws.workspaceDir;
|
|
295
|
+
if (cleanup)
|
|
296
|
+
safeRm(ws.workspaceDir);
|
|
297
|
+
return {
|
|
298
|
+
success: true,
|
|
299
|
+
workspace_dir: workspaceDirOut,
|
|
300
|
+
isolation: 'pnpm',
|
|
301
|
+
install_ok: true,
|
|
302
|
+
ts_version: tsVersion,
|
|
303
|
+
verdicts,
|
|
304
|
+
degraded_services: dedupeDegraded(degraded),
|
|
305
|
+
errors,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Per-service alias records from each stub's carrick-manifest.json. Absent
|
|
310
|
+
* or unparseable manifests (e.g. hand-authored stubs in tests) contribute
|
|
311
|
+
* nothing — only positively-recorded deep decay pre-gates a pair.
|
|
312
|
+
*/
|
|
313
|
+
function readStubAliasRecords(stubs) {
|
|
314
|
+
const byService = new Map();
|
|
315
|
+
for (const stub of stubs) {
|
|
316
|
+
try {
|
|
317
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(stub.stub_dir, 'carrick-manifest.json'), 'utf8'));
|
|
318
|
+
const byAlias = new Map();
|
|
319
|
+
for (const record of manifest.aliases ?? []) {
|
|
320
|
+
if (record?.alias)
|
|
321
|
+
byAlias.set(record.alias, record);
|
|
322
|
+
}
|
|
323
|
+
byService.set(stub.service_name, byAlias);
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
// No manifest: nothing to pre-gate for this stub.
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return byService;
|
|
330
|
+
}
|
|
331
|
+
/** First side (producer, then consumer) whose capture recorded a deep decay. */
|
|
332
|
+
function deepDecayOf(plan, aliasRecords) {
|
|
333
|
+
for (const side of ['producer', 'consumer']) {
|
|
334
|
+
const endpoint = plan.spec[side];
|
|
335
|
+
const record = aliasRecords.get(endpoint.service_name)?.get(endpoint.alias);
|
|
336
|
+
// The capture emits every deep finding it made; the FIRST is the one the
|
|
337
|
+
// pre-gate reads, exactly as when the walk stopped at the first hit.
|
|
338
|
+
const finding = record?.any_provenance?.[0];
|
|
339
|
+
if (finding) {
|
|
340
|
+
return { side, kind: finding.kind, path: finding.path };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
const POISON_REASON = 'stub type tree carries its own diagnostics';
|
|
346
|
+
/**
|
|
347
|
+
* Group diagnostics: per-probe (for classification), plus stub-tree poison
|
|
348
|
+
* CONTAINED to the aliases reachable from the poisoned file (#438 part 2). An
|
|
349
|
+
* unattributable stub diagnostic (an augmentation-file collision in no alias's
|
|
350
|
+
* closure) falls back to service-wide poison; everything else is scoped.
|
|
351
|
+
*/
|
|
352
|
+
function attributeDiagnostics(diagnostics, ws, plans) {
|
|
353
|
+
const probeFileToPair = new Map();
|
|
354
|
+
for (const plan of plans) {
|
|
355
|
+
probeFileToPair.set(`${ws.probesRel}/probes/${plan.fileName}`, plan.pairId);
|
|
356
|
+
}
|
|
357
|
+
const stubDirs = new Set(ws.stubs.map((s) => s.packageDir));
|
|
358
|
+
// Lazy: the clean path (no stub-tree diagnostic) must not walk and parse
|
|
359
|
+
// every stub `.d.ts` tree. Built on first stub-tree diagnostic, at most once.
|
|
360
|
+
let indexesMemo;
|
|
361
|
+
const getIndexes = () => (indexesMemo ??= buildPoisonIndexes(ws));
|
|
362
|
+
const probeDiagsByPair = new Map();
|
|
363
|
+
const poison = new Map();
|
|
364
|
+
const globalErrors = [];
|
|
365
|
+
const scopeFor = (service) => {
|
|
366
|
+
let scope = poison.get(service);
|
|
367
|
+
if (!scope) {
|
|
368
|
+
scope = { all: false, aliases: new Set(), reason: POISON_REASON };
|
|
369
|
+
poison.set(service, scope);
|
|
370
|
+
}
|
|
371
|
+
return scope;
|
|
372
|
+
};
|
|
373
|
+
for (const d of diagnostics) {
|
|
374
|
+
if (!d.file) {
|
|
375
|
+
globalErrors.push(`TS${d.code}: ${d.message}`);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const pairId = probeFileToPair.get(d.file);
|
|
379
|
+
if (pairId) {
|
|
380
|
+
if (!probeDiagsByPair.has(pairId))
|
|
381
|
+
probeDiagsByPair.set(pairId, []);
|
|
382
|
+
probeDiagsByPair.get(pairId).push(d);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
// Stub-tree diagnostics poison that stub (its OWN sources, not deps).
|
|
386
|
+
const pkgMatch = d.file.match(/^packages\/([^/]+)\//);
|
|
387
|
+
if (pkgMatch && !d.file.includes('/node_modules/') && stubDirs.has(pkgMatch[1])) {
|
|
388
|
+
const service = ws.serviceOfPackageDir(pkgMatch[1]);
|
|
389
|
+
if (!service)
|
|
390
|
+
continue;
|
|
391
|
+
const scope = scopeFor(service);
|
|
392
|
+
const index = getIndexes().get(service);
|
|
393
|
+
if (index && d.file === index.surfaceFile) {
|
|
394
|
+
// Surface diagnostic: attribute by the alias statement span it sits in.
|
|
395
|
+
const alias = index.aliasAtSurfaceLine(d.line);
|
|
396
|
+
if (alias)
|
|
397
|
+
scope.aliases.add(alias);
|
|
398
|
+
else
|
|
399
|
+
scope.all = true;
|
|
400
|
+
}
|
|
401
|
+
else if (index) {
|
|
402
|
+
// Nested-file diagnostic: attribute to every alias whose closure reads
|
|
403
|
+
// it; a file in no closure (augmentation collision) falls back wide.
|
|
404
|
+
const affected = index.aliasesForFile(d.file);
|
|
405
|
+
if (affected.length > 0)
|
|
406
|
+
for (const a of affected)
|
|
407
|
+
scope.aliases.add(a);
|
|
408
|
+
else
|
|
409
|
+
scope.all = true;
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
scope.all = true;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// Anything else (node_modules, TS lib) is environmental: ignored for
|
|
416
|
+
// verdicts so it cannot inject nondeterministic noise.
|
|
417
|
+
}
|
|
418
|
+
return { probeDiagsByPair, poison, globalErrors };
|
|
419
|
+
}
|
|
420
|
+
function dedupeDegraded(list) {
|
|
421
|
+
const seen = new Map();
|
|
422
|
+
for (const d of list)
|
|
423
|
+
if (!seen.has(d.service_name))
|
|
424
|
+
seen.set(d.service_name, d);
|
|
425
|
+
return [...seen.values()].sort((a, b) => a.service_name < b.service_name ? -1 : a.service_name > b.service_name ? 1 : 0);
|
|
426
|
+
}
|
|
427
|
+
/** Plan pairs without a workspace (for the isolation-unavailable early exit). */
|
|
428
|
+
function planPairs(opts, packageOf) {
|
|
429
|
+
const plans = [];
|
|
430
|
+
for (const spec of opts.pairs) {
|
|
431
|
+
try {
|
|
432
|
+
plans.push(buildProbe(spec, packageOf));
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
/* skipped in the early-exit path */
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return plans;
|
|
439
|
+
}
|
|
440
|
+
function fnvOfSpec(spec) {
|
|
441
|
+
return buildProbe(spec, (s) => `@carrick/${s}`).pairId;
|
|
442
|
+
}
|
|
443
|
+
function safeRm(dir) {
|
|
444
|
+
try {
|
|
445
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
/* best effort */
|
|
449
|
+
}
|
|
450
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The structural any/unknown walk, shared by the two type surfaces that need it
|
|
3
|
+
* (carrick#448, carrick#707).
|
|
4
|
+
*
|
|
5
|
+
* Capture-time it runs over the emitted stub tree and decides whether an alias
|
|
6
|
+
* carries a member-level decay the check phase must pre-gate. Check-time it
|
|
7
|
+
* runs over the assembled probe workspace, where the pinned externals ARE
|
|
8
|
+
* installed, and decides whether a verdict is a fact about two known types or
|
|
9
|
+
* an artefact of a type nobody could see (`CheckVerdict.resolved`). The
|
|
10
|
+
* whole-type probe gates cannot answer that: `any` three members down passes
|
|
11
|
+
* every one of them.
|
|
12
|
+
*
|
|
13
|
+
* One walk, one budget, one path notation, so a finding means the same thing
|
|
14
|
+
* wherever it is reported.
|
|
15
|
+
*/
|
|
16
|
+
import ts from 'typescript';
|
|
17
|
+
import type { TypeProvenance } from './api.js';
|
|
18
|
+
/** A disqualifying finding below the root of a captured type: an author-baked
|
|
19
|
+
* `any`/`unknown`, or `budget_exhausted` — a subtree the walk could not finish
|
|
20
|
+
* within its depth/node budget, treated as unverifiable (fail closed) rather
|
|
21
|
+
* than silently clean. */
|
|
22
|
+
export type DeepTopType = Pick<TypeProvenance, 'kind' | 'path'>;
|
|
23
|
+
/**
|
|
24
|
+
* Depth-bounded structural walk for a disqualifying top type at ANY depth:
|
|
25
|
+
* a member, array element, index signature, type argument, or callable RETURN
|
|
26
|
+
* that resolved to `any` (bidirectionally assignable — an arbitrary
|
|
27
|
+
* counterparty shape reads compatible) or `unknown` (a failed-inference bake;
|
|
28
|
+
* carries no shape information). The check phase's probe gates are WHOLE-type
|
|
29
|
+
* only, so this walk owns the depths they cannot see. It is a genuine SUPERSET
|
|
30
|
+
* of v1's text-scan disqualifier (`contains_disqualifying_top_type`) at ALL
|
|
31
|
+
* depths/widths — so removing the `type_state == Unknown` pre-verdict (carrick
|
|
32
|
+
* #448) never turns a shape v1 would have abstained on into a false-compatible.
|
|
33
|
+
* The superset holds unconditionally because budget EXHAUSTION FAILS CLOSED
|
|
34
|
+
* (returns the `budget_exhausted` sentinel, not "clean"): a subtree too deep or
|
|
35
|
+
* wide to finish within budget is unverifiable, never silently compatible. A
|
|
36
|
+
* bigger bound would only relocate the fail-open cliff; failing closed removes
|
|
37
|
+
* it. v1's text scan is itself unbounded, so any disqualifier it would flag
|
|
38
|
+
* that lies past this walk's finite budget is still caught — as
|
|
39
|
+
* `budget_exhausted` rather than its exact kind.
|
|
40
|
+
*
|
|
41
|
+
* Cycle-safe. Two deliberate exceptions to "flag any/unknown anywhere":
|
|
42
|
+
* - callable PARAMETER types are NOT descended (only return types): a
|
|
43
|
+
* parameter `any` is contravariant and genuinely permissive (`(x: any) =>
|
|
44
|
+
* void` safely accepts a stricter counterparty), so it is not a masked
|
|
45
|
+
* mismatch and demoting it would over-demote a sound shape;
|
|
46
|
+
* - TypeScript's unresolved-reference `error` placeholder (`intrinsicName ===
|
|
47
|
+
* 'error'`) is excluded (see `flagOf`): it heals when the check installs the
|
|
48
|
+
* pinned external, so it is a healable decay, not an author-baked `any`.
|
|
49
|
+
* A type the walk cannot cheaply finish is NOT flagged — over-demoting a
|
|
50
|
+
* legitimately fully-resolved type is the failure mode this guard must not have.
|
|
51
|
+
*/
|
|
52
|
+
export declare function findDisqualifyingTopTypes(root: ts.Type, program: ts.Program, checker: ts.TypeChecker, location: ts.Node): DeepTopType[];
|
|
53
|
+
/**
|
|
54
|
+
* Turn a deep finding into the published provenance entry (carrick#376).
|
|
55
|
+
*
|
|
56
|
+
* The self-check reads EMITTED declaration text, so a top type it finds is
|
|
57
|
+
* text: whatever produced it — an author annotation, or an emitter that
|
|
58
|
+
* printed a value it could not resolve — no install re-resolves it. That is
|
|
59
|
+
* `declared`, and saying so is more useful than the bare `any` a reader gets
|
|
60
|
+
* today. The one other cause it can distinguish is its own budget.
|
|
61
|
+
*/
|
|
62
|
+
export declare function provenanceOf(finding: DeepTopType): TypeProvenance;
|