@specific.dev/spectest 0.91.2 → 0.92.0
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/dist/daemon.js +9 -172
- package/dist/harness/eval-typecheck.d.ts +9 -0
- package/dist/harness/eval-typecheck.js +93 -0
- package/dist/harness/typecheck.d.ts +53 -0
- package/dist/harness/typecheck.js +184 -0
- package/dist/harness/wrapper-rules.d.ts +2 -0
- package/dist/harness/wrapper-rules.js +8 -6
- package/package.json +1 -1
- package/src/daemon.ts +13 -212
- package/src/harness/eval-typecheck.test.ts +163 -0
- package/src/harness/eval-typecheck.ts +100 -0
- package/src/harness/typecheck.ts +237 -0
- package/src/harness/wrapper-rules.ts +12 -7
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { runWrapperRules, type WrapperDiagnostic } from "./wrapper-rules.js";
|
|
5
|
+
|
|
6
|
+
export interface TypecheckOptions {
|
|
7
|
+
appDir: string;
|
|
8
|
+
typecheckDir: string;
|
|
9
|
+
projectEntry?: string;
|
|
10
|
+
run: (file: string, args: string[], timeoutMs: number) => Promise<{ code: number; stdout: string; stderr: string }>;
|
|
11
|
+
configFile?: string;
|
|
12
|
+
/** Eval reports only the submitted snippet, not unrelated imported project code. */
|
|
13
|
+
diagnosticFile?: string;
|
|
14
|
+
extraDiagnostics?: () => Promise<WrapperDiagnostic[]>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Both entry points honor the same project config before generating defaults. */
|
|
18
|
+
export function projectTypecheckConfig(opts: Pick<TypecheckOptions, "appDir" | "projectEntry">): string | undefined {
|
|
19
|
+
return [
|
|
20
|
+
path.join(opts.appDir, "tsconfig.json"),
|
|
21
|
+
...(opts.projectEntry ? [path.join(path.dirname(opts.projectEntry), "tsconfig.json")] : []),
|
|
22
|
+
].find(existsSync);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Cap on errors shipped in the report; `totalErrors` carries the true count. */
|
|
26
|
+
const TYPECHECK_ERROR_CAP = 50;
|
|
27
|
+
|
|
28
|
+
export interface TypecheckError {
|
|
29
|
+
/** Path relative to the project's spectest/ dir (e.g. `tests/checkout.ts`). */
|
|
30
|
+
file: string;
|
|
31
|
+
line: number;
|
|
32
|
+
column: number;
|
|
33
|
+
/** `TS2345` from the compiler, or a `SPECTEST….` rule code. */
|
|
34
|
+
code: string;
|
|
35
|
+
message: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TypecheckReport {
|
|
39
|
+
status: "ok" | "errors" | "skipped" | "failed";
|
|
40
|
+
errors: TypecheckError[];
|
|
41
|
+
totalErrors: number;
|
|
42
|
+
durationMs: number;
|
|
43
|
+
/** Why the check was skipped / how it failed. */
|
|
44
|
+
detail?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Findings that FAIL the run, as opposed to `errors`, which only advise.
|
|
47
|
+
* A diagnostic qualifies only when it is true of the program that runs
|
|
48
|
+
* rather than of the declared types alone — see `harness/wrapper-rules.ts`
|
|
49
|
+
* for why no `tsc` code can be in here and why these can.
|
|
50
|
+
*
|
|
51
|
+
* The list is the rollout mechanism as well as the payload: an older SDK
|
|
52
|
+
* never sends the field, `#[serde(default)]` leaves it empty on the server,
|
|
53
|
+
* and the gate is inert for every project that has not moved to a version
|
|
54
|
+
* carrying it. The version a project runs is already its own choice
|
|
55
|
+
* (`sdk.rs` honours the declared lower bound), so the bump is the consent.
|
|
56
|
+
*/
|
|
57
|
+
blocking?: TypecheckError[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The tsconfig used when the project doesn't ship its own. `lib` is left to
|
|
61
|
+
* the target default (which includes DOM — browser.evaluate callbacks
|
|
62
|
+
* reference `document`); `types` pulls Bun's ambient globals from the baked
|
|
63
|
+
* install via `typeRoots` (the walk-up from APP_DIR never reaches it). */
|
|
64
|
+
export function generatedTsconfig(appDir: string, typecheckDir: string): string {
|
|
65
|
+
return JSON.stringify(
|
|
66
|
+
{
|
|
67
|
+
compilerOptions: {
|
|
68
|
+
target: "esnext",
|
|
69
|
+
module: "esnext",
|
|
70
|
+
moduleResolution: "bundler",
|
|
71
|
+
strict: true,
|
|
72
|
+
noEmit: true,
|
|
73
|
+
skipLibCheck: true,
|
|
74
|
+
esModuleInterop: true,
|
|
75
|
+
resolveJsonModule: true,
|
|
76
|
+
jsx: "react-jsx",
|
|
77
|
+
types: ["bun"],
|
|
78
|
+
typeRoots: [
|
|
79
|
+
path.join(appDir, "node_modules", "@types"),
|
|
80
|
+
path.join(typecheckDir, "node_modules", "@types"),
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
include: ["**/*.ts", "**/*.tsx"],
|
|
84
|
+
exclude: ["node_modules", ".spectest-eval"],
|
|
85
|
+
},
|
|
86
|
+
null,
|
|
87
|
+
2,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Parse `--pretty false` tsc output: `file(line,col): error TScode: msg`,
|
|
92
|
+
* with indented elaboration lines folded into the preceding error. */
|
|
93
|
+
function parseTscOutput(output: string, appDir: string, diagnosticFile?: string): {
|
|
94
|
+
errors: TypecheckError[];
|
|
95
|
+
total: number;
|
|
96
|
+
suppressed: number;
|
|
97
|
+
} {
|
|
98
|
+
const errors: TypecheckError[] = [];
|
|
99
|
+
let total = 0;
|
|
100
|
+
let suppressed = 0;
|
|
101
|
+
let last: TypecheckError | null = null;
|
|
102
|
+
for (const line of output.split("\n")) {
|
|
103
|
+
const m = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(line);
|
|
104
|
+
if (!m) {
|
|
105
|
+
// Elaboration lines are indented; fold them into the last kept error.
|
|
106
|
+
if (last && /^\s+\S/.test(line)) last.message += `\n${line.trimEnd()}`;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const [, rawFile, ln, col, code, message] = m;
|
|
110
|
+
// Keep only diagnostics in the user's files. tsc runs with cwd=APP_DIR
|
|
111
|
+
// (the daemon's cwd), so project files come out relative ("tests/x.ts")
|
|
112
|
+
// and anything outside — the SDK at /opt/spectest/sdk, node_modules —
|
|
113
|
+
// is `../…` or absolute. Errors in our own SDK are ours to fix, not the
|
|
114
|
+
// user's to read.
|
|
115
|
+
const abs = path.resolve(appDir, rawFile!);
|
|
116
|
+
const inApp = abs.startsWith(appDir + path.sep) && !abs.includes(`${path.sep}node_modules${path.sep}`);
|
|
117
|
+
if (!inApp || (diagnosticFile && abs !== diagnosticFile)) {
|
|
118
|
+
suppressed += 1;
|
|
119
|
+
last = null;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
total += 1;
|
|
123
|
+
const err: TypecheckError = {
|
|
124
|
+
file: path.relative(appDir, abs),
|
|
125
|
+
line: Number(ln),
|
|
126
|
+
column: Number(col),
|
|
127
|
+
code: code!,
|
|
128
|
+
message: message!,
|
|
129
|
+
};
|
|
130
|
+
if (errors.length < TYPECHECK_ERROR_CAP) {
|
|
131
|
+
errors.push(err);
|
|
132
|
+
last = err;
|
|
133
|
+
} else {
|
|
134
|
+
last = null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { errors, total, suppressed };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function runTypecheck(opts: TypecheckOptions): Promise<TypecheckReport> {
|
|
141
|
+
const { appDir, typecheckDir, run } = opts;
|
|
142
|
+
const started = Date.now();
|
|
143
|
+
// The project's own typescript wins over the baked copy.
|
|
144
|
+
const tsc = [
|
|
145
|
+
path.join(appDir, "node_modules", "typescript", "bin", "tsc"),
|
|
146
|
+
path.join(typecheckDir, "node_modules", "typescript", "bin", "tsc"),
|
|
147
|
+
].find((p) => existsSync(p));
|
|
148
|
+
if (!tsc) {
|
|
149
|
+
return {
|
|
150
|
+
status: "skipped",
|
|
151
|
+
errors: [],
|
|
152
|
+
totalErrors: 0,
|
|
153
|
+
durationMs: 0,
|
|
154
|
+
detail: "typescript is not installed (base image predates the baked typechecker)",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
let config = opts.configFile ?? projectTypecheckConfig(opts);
|
|
158
|
+
if (!config) {
|
|
159
|
+
config = path.join(appDir, ".spectest-tsconfig.json");
|
|
160
|
+
await fs.writeFile(config, generatedTsconfig(appDir, typecheckDir));
|
|
161
|
+
}
|
|
162
|
+
const res = await run(process.execPath, [tsc, "--noEmit", "--pretty", "false", "-p", config], 120_000);
|
|
163
|
+
const durationMs = Date.now() - started;
|
|
164
|
+
if (res.code === 124) {
|
|
165
|
+
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
|
|
166
|
+
}
|
|
167
|
+
const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr, appDir, opts.diagnosticFile);
|
|
168
|
+
const wrapper = [...(await runWrapperRulesReport(config, opts)), ...(await opts.extraDiagnostics?.() ?? [])];
|
|
169
|
+
const strip = ({ file, line, column, code, message }: WrapperDiagnostic): TypecheckError => ({
|
|
170
|
+
file,
|
|
171
|
+
line,
|
|
172
|
+
column,
|
|
173
|
+
code,
|
|
174
|
+
message,
|
|
175
|
+
});
|
|
176
|
+
const blocking = wrapper.filter((d) => d.blocking).map(strip);
|
|
177
|
+
const advisory = wrapper.filter((d) => !d.blocking).map(strip);
|
|
178
|
+
if (errors.length === 0 && wrapper.length === 0) {
|
|
179
|
+
// Exit 0 → clean. Non-zero with no *user-file* diagnostics is either
|
|
180
|
+
// all-suppressed (still ok from the user's perspective) or a compiler
|
|
181
|
+
// crash (config not found, OOM) — surface the latter.
|
|
182
|
+
if (res.code !== 0 && suppressed === 0) {
|
|
183
|
+
const tail = (res.stderr || res.stdout).trim().split("\n").slice(-5).join("\n");
|
|
184
|
+
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: tail || `tsc exited ${res.code}` };
|
|
185
|
+
}
|
|
186
|
+
if (suppressed > 0) {
|
|
187
|
+
console.warn(`[typecheck] ${suppressed} diagnostic(s) outside the project suppressed`);
|
|
188
|
+
}
|
|
189
|
+
return { status: "ok", errors: [], totalErrors: 0, durationMs };
|
|
190
|
+
}
|
|
191
|
+
// The wrapper findings ride the same list the CLI and the dashboard already
|
|
192
|
+
// render; `blocking` is a second view of the ones that also fail the run, so
|
|
193
|
+
// nothing has to learn a new shape to show them. Blocking leads, because
|
|
194
|
+
// `errors` is capped and the entries that stopped the run must never be the
|
|
195
|
+
// ones the cap drops.
|
|
196
|
+
const all = [...blocking, ...advisory, ...errors];
|
|
197
|
+
return {
|
|
198
|
+
status: "errors",
|
|
199
|
+
errors: all.slice(0, TYPECHECK_ERROR_CAP),
|
|
200
|
+
totalErrors: total + wrapper.length,
|
|
201
|
+
durationMs,
|
|
202
|
+
...(blocking.length > 0 ? { blocking } : {}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The blocking rules, run against the BAKED compiler whatever the project
|
|
208
|
+
* pins. Best-effort in every direction: an install that predates the API, a
|
|
209
|
+
* config the rules cannot open, or a throw from `typescript/unstable/*` all
|
|
210
|
+
* yield no findings, so a run proceeds exactly as it does today. Only a
|
|
211
|
+
* definite finding can stop one.
|
|
212
|
+
*/
|
|
213
|
+
async function runWrapperRulesReport(config: string, opts: TypecheckOptions): Promise<WrapperDiagnostic[]> {
|
|
214
|
+
const { appDir, typecheckDir } = opts;
|
|
215
|
+
const typescriptDir = path.join(typecheckDir, "node_modules", "typescript");
|
|
216
|
+
if (!existsSync(path.join(typescriptDir, "dist", "api", "async", "api.js"))) return [];
|
|
217
|
+
try {
|
|
218
|
+
const run = await runWrapperRules({
|
|
219
|
+
typescriptDir,
|
|
220
|
+
configFile: config,
|
|
221
|
+
appDir,
|
|
222
|
+
diagnosticFile: opts.diagnosticFile,
|
|
223
|
+
readFile: (f) => fs.readFile(f, "utf8"),
|
|
224
|
+
relative: path.relative,
|
|
225
|
+
join: path.join,
|
|
226
|
+
dirname: path.dirname,
|
|
227
|
+
});
|
|
228
|
+
if (run.status !== "ok") {
|
|
229
|
+
console.warn(`[typecheck] wrapper rules ${run.status}: ${run.detail ?? "no detail"}`);
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
return run.diagnostics;
|
|
233
|
+
} catch (err) {
|
|
234
|
+
console.warn(`[typecheck] wrapper rules threw: ${(err as Error)?.message ?? err}`);
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -351,11 +351,6 @@ export interface WrapperRuleRun {
|
|
|
351
351
|
durationMs: number;
|
|
352
352
|
}
|
|
353
353
|
|
|
354
|
-
/** `TypeFlags.Object | TypeFlags.Union` — the only shapes a carrier can wear.
|
|
355
|
-
* Filtering on the flags the batch call already returned keeps the printed
|
|
356
|
-
* name (one round trip each) off every ordinary `boolean` condition. */
|
|
357
|
-
const OBJECT_OR_UNION = (1 << 19) | (1 << 20);
|
|
358
|
-
|
|
359
354
|
/** Candidate positions, and what each one means if the operand is a carrier. */
|
|
360
355
|
interface Candidate {
|
|
361
356
|
node: TsNode;
|
|
@@ -431,6 +426,8 @@ export async function runWrapperRules(opts: {
|
|
|
431
426
|
configFile: string;
|
|
432
427
|
/** Only files under here produce diagnostics. */
|
|
433
428
|
appDir: string;
|
|
429
|
+
/** Restrict eval findings to its submitted module. */
|
|
430
|
+
diagnosticFile?: string;
|
|
434
431
|
readFile: (p: string) => Promise<string>;
|
|
435
432
|
relative: (from: string, to: string) => string;
|
|
436
433
|
join: (...parts: string[]) => string;
|
|
@@ -456,6 +453,13 @@ export async function runWrapperRules(opts: {
|
|
|
456
453
|
const utils = (await import(opts.join(base, "ast", "utils.js"))) as {
|
|
457
454
|
formatSyntaxKind(k: number): string;
|
|
458
455
|
};
|
|
456
|
+
// Native TypeScript's flag values differ from the JavaScript compiler's.
|
|
457
|
+
// Read them from the compiler serving this API; hard-coded TS 5 flags
|
|
458
|
+
// silently miss wrapped boolean unions in TS 7.
|
|
459
|
+
const { TypeFlags } = await import(opts.join(base, "enums", "typeFlags.js")) as {
|
|
460
|
+
TypeFlags: { Object: number; Union: number };
|
|
461
|
+
};
|
|
462
|
+
const objectOrUnion = TypeFlags.Object | TypeFlags.Union;
|
|
459
463
|
const visitor = (await import(opts.join(base, "ast", "visitor.js"))) as {
|
|
460
464
|
visitEachChild(n: unknown, cb: (c: unknown) => unknown, ctx: unknown): unknown;
|
|
461
465
|
};
|
|
@@ -505,7 +509,8 @@ export async function runWrapperRules(opts: {
|
|
|
505
509
|
const names = await project.program.getSourceFileNames();
|
|
506
510
|
const files = names.filter(
|
|
507
511
|
(f) =>
|
|
508
|
-
f.startsWith(`${opts.appDir}/`) && !f.includes("/node_modules/") && !f.endsWith(".d.ts")
|
|
512
|
+
f.startsWith(`${opts.appDir}/`) && !f.includes("/node_modules/") && !f.endsWith(".d.ts") &&
|
|
513
|
+
(!opts.diagnosticFile || f === opts.diagnosticFile),
|
|
509
514
|
);
|
|
510
515
|
|
|
511
516
|
const diagnostics: WrapperDiagnostic[] = [];
|
|
@@ -522,7 +527,7 @@ export async function runWrapperRules(opts: {
|
|
|
522
527
|
const printed = new Map<number, string>();
|
|
523
528
|
for (let i = 0; i < nodes.length; i += 1) {
|
|
524
529
|
const t = types[i];
|
|
525
|
-
if (t && (t.flags &
|
|
530
|
+
if (t && (t.flags & objectOrUnion) !== 0) {
|
|
526
531
|
printed.set(i, await project.checker.typeToString(t));
|
|
527
532
|
}
|
|
528
533
|
}
|