@specific.dev/spectest 0.91.1 → 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/components/k3s.d.ts +2 -0
- package/dist/components/k3s.js +27 -2
- 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/components/k3s-apply.test.ts +90 -0
- package/src/components/k3s.ts +21 -2
- 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
package/dist/components/k3s.d.ts
CHANGED
|
@@ -328,6 +328,8 @@ export interface K3sHelpers {
|
|
|
328
328
|
limit?: number;
|
|
329
329
|
}): Promise<Wrapped<KubernetesListObject<T>>>;
|
|
330
330
|
}
|
|
331
|
+
/** @internal Exported for regression tests; not part of the components API. */
|
|
332
|
+
export declare function isTransientApplyError(err: unknown): boolean;
|
|
331
333
|
export declare function k3s(opts?: K3sOptions): {
|
|
332
334
|
image: {
|
|
333
335
|
type: "registry";
|
package/dist/components/k3s.js
CHANGED
|
@@ -910,8 +910,33 @@ const TRANSIENT_APPLY_PATTERNS = [
|
|
|
910
910
|
function errorText(err) {
|
|
911
911
|
return err?.message ?? String(err);
|
|
912
912
|
}
|
|
913
|
-
|
|
914
|
-
|
|
913
|
+
/** ApiException formats its body with JSON.stringify, escaping Status.message.
|
|
914
|
+
* Read the actual response instead of matching that presentation string. */
|
|
915
|
+
function applyErrorStatus(err) {
|
|
916
|
+
let body = err?.body;
|
|
917
|
+
if (typeof body === "string") {
|
|
918
|
+
try {
|
|
919
|
+
body = JSON.parse(body);
|
|
920
|
+
}
|
|
921
|
+
catch {
|
|
922
|
+
return undefined;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return body && typeof body === "object" && !Array.isArray(body) ? body : undefined;
|
|
926
|
+
}
|
|
927
|
+
/** @internal Exported for regression tests; not part of the components API. */
|
|
928
|
+
export function isTransientApplyError(err) {
|
|
929
|
+
const status = applyErrorStatus(err);
|
|
930
|
+
const code = status?.code ?? err?.code;
|
|
931
|
+
if ([429, 500, 503, 504].includes(code))
|
|
932
|
+
return true;
|
|
933
|
+
// A missing namespace can converge while another apply creates it. An
|
|
934
|
+
// arbitrary resource 404 cannot: don't classify by HTTP status alone.
|
|
935
|
+
if (code === 404 && status?.reason === "NotFound" &&
|
|
936
|
+
status.details?.kind === "namespaces" && !status.details.group &&
|
|
937
|
+
typeof status.details.name === "string" && status.details.name.length > 0)
|
|
938
|
+
return true;
|
|
939
|
+
const msg = typeof status?.message === "string" ? status.message : errorText(err);
|
|
915
940
|
return TRANSIENT_APPLY_PATTERNS.some((re) => re.test(msg));
|
|
916
941
|
}
|
|
917
942
|
function describeDoc(doc) {
|
package/dist/daemon.js
CHANGED
|
@@ -45,7 +45,9 @@ import { summarizeBuildKit } from "./harness/buildkit-progress.js";
|
|
|
45
45
|
import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
|
|
46
46
|
import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
|
|
47
47
|
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
48
|
-
import { fsUsageDiagnostics
|
|
48
|
+
import { fsUsageDiagnostics } from "./harness/wrapper-rules.js";
|
|
49
|
+
import { runTypecheck } from "./harness/typecheck.js";
|
|
50
|
+
import { evaluateChecked } from "./harness/eval-typecheck.js";
|
|
49
51
|
import { cpus } from "node:os";
|
|
50
52
|
import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
|
|
51
53
|
import { compactReach, projectReach, renderReachRules, renderReadsLcov } from "./harness/reach.js";
|
|
@@ -5209,7 +5211,8 @@ async function evalCode(code) {
|
|
|
5209
5211
|
filePath = path.join(EVAL_DIR, `${randomUUID()}.ts`);
|
|
5210
5212
|
await fs.writeFile(filePath, prepared.code);
|
|
5211
5213
|
const moduleUrl = pathToFileURL(filePath).href;
|
|
5212
|
-
const mod =
|
|
5214
|
+
const mod = await evaluateChecked({ file: filePath, appDir: APP_DIR, typecheckDir: TYPECHECK_DIR, run: shx,
|
|
5215
|
+
projectEntry: loaded ? resolveEntry() : undefined }, (text) => { chunks.push(text); }, () => runInstrumented(fetchScope, () => import(moduleUrl)));
|
|
5213
5216
|
outcome = { ok: true, result: safeSerialize(mod.default) };
|
|
5214
5217
|
}
|
|
5215
5218
|
catch (err) {
|
|
@@ -5292,12 +5295,10 @@ async function evalCode(code) {
|
|
|
5292
5295
|
* blocking rules, which always use THIS copy rather than the project's own —
|
|
5293
5296
|
* can be driven outside a VM. */
|
|
5294
5297
|
const TYPECHECK_DIR = process.env.SPECTEST_TYPECHECK_DIR ?? "/opt/spectest/typecheck";
|
|
5295
|
-
/** Cap on errors shipped in the report; `totalErrors` carries the true count. */
|
|
5296
|
-
const TYPECHECK_ERROR_CAP = 50;
|
|
5297
5298
|
let TYPECHECK = null;
|
|
5298
5299
|
/** Fire-and-forget kickoff; the promise is parked for POST /typecheck. */
|
|
5299
5300
|
function startTypecheck() {
|
|
5300
|
-
TYPECHECK =
|
|
5301
|
+
TYPECHECK = runProjectTypecheck().catch((err) => ({
|
|
5301
5302
|
status: "failed",
|
|
5302
5303
|
errors: [],
|
|
5303
5304
|
totalErrors: 0,
|
|
@@ -5307,141 +5308,9 @@ function startTypecheck() {
|
|
|
5307
5308
|
// Parked promises must never surface as unhandled rejections.
|
|
5308
5309
|
TYPECHECK.catch(() => { });
|
|
5309
5310
|
}
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
|
|
5313
|
-
* install via `typeRoots` (the walk-up from APP_DIR never reaches it). */
|
|
5314
|
-
function generatedTsconfig() {
|
|
5315
|
-
return JSON.stringify({
|
|
5316
|
-
compilerOptions: {
|
|
5317
|
-
target: "esnext",
|
|
5318
|
-
module: "esnext",
|
|
5319
|
-
moduleResolution: "bundler",
|
|
5320
|
-
strict: true,
|
|
5321
|
-
noEmit: true,
|
|
5322
|
-
skipLibCheck: true,
|
|
5323
|
-
esModuleInterop: true,
|
|
5324
|
-
resolveJsonModule: true,
|
|
5325
|
-
jsx: "react-jsx",
|
|
5326
|
-
types: ["bun"],
|
|
5327
|
-
typeRoots: [
|
|
5328
|
-
path.join(APP_DIR, "node_modules", "@types"),
|
|
5329
|
-
path.join(TYPECHECK_DIR, "node_modules", "@types"),
|
|
5330
|
-
],
|
|
5331
|
-
},
|
|
5332
|
-
include: ["**/*.ts", "**/*.tsx"],
|
|
5333
|
-
exclude: ["node_modules"],
|
|
5334
|
-
}, null, 2);
|
|
5335
|
-
}
|
|
5336
|
-
/** Parse `--pretty false` tsc output: `file(line,col): error TScode: msg`,
|
|
5337
|
-
* with indented elaboration lines folded into the preceding error. */
|
|
5338
|
-
function parseTscOutput(output) {
|
|
5339
|
-
const errors = [];
|
|
5340
|
-
let total = 0;
|
|
5341
|
-
let suppressed = 0;
|
|
5342
|
-
let last = null;
|
|
5343
|
-
for (const line of output.split("\n")) {
|
|
5344
|
-
const m = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(line);
|
|
5345
|
-
if (!m) {
|
|
5346
|
-
// Elaboration lines are indented; fold them into the last kept error.
|
|
5347
|
-
if (last && /^\s+\S/.test(line))
|
|
5348
|
-
last.message += `\n${line.trimEnd()}`;
|
|
5349
|
-
continue;
|
|
5350
|
-
}
|
|
5351
|
-
const [, rawFile, ln, col, code, message] = m;
|
|
5352
|
-
// Keep only diagnostics in the user's files. tsc runs with cwd=APP_DIR
|
|
5353
|
-
// (the daemon's cwd), so project files come out relative ("tests/x.ts")
|
|
5354
|
-
// and anything outside — the SDK at /opt/spectest/sdk, node_modules —
|
|
5355
|
-
// is `../…` or absolute. Errors in our own SDK are ours to fix, not the
|
|
5356
|
-
// user's to read.
|
|
5357
|
-
const abs = path.resolve(APP_DIR, rawFile);
|
|
5358
|
-
const inApp = abs.startsWith(APP_DIR + path.sep) && !abs.includes(`${path.sep}node_modules${path.sep}`);
|
|
5359
|
-
if (!inApp) {
|
|
5360
|
-
suppressed += 1;
|
|
5361
|
-
last = null;
|
|
5362
|
-
continue;
|
|
5363
|
-
}
|
|
5364
|
-
total += 1;
|
|
5365
|
-
const err = {
|
|
5366
|
-
file: path.relative(APP_DIR, abs),
|
|
5367
|
-
line: Number(ln),
|
|
5368
|
-
column: Number(col),
|
|
5369
|
-
code: code,
|
|
5370
|
-
message: message,
|
|
5371
|
-
};
|
|
5372
|
-
if (errors.length < TYPECHECK_ERROR_CAP) {
|
|
5373
|
-
errors.push(err);
|
|
5374
|
-
last = err;
|
|
5375
|
-
}
|
|
5376
|
-
else {
|
|
5377
|
-
last = null;
|
|
5378
|
-
}
|
|
5379
|
-
}
|
|
5380
|
-
return { errors, total, suppressed };
|
|
5381
|
-
}
|
|
5382
|
-
async function runTypecheck() {
|
|
5383
|
-
const started = Date.now();
|
|
5384
|
-
// The project's own typescript wins over the baked copy.
|
|
5385
|
-
const tsc = [
|
|
5386
|
-
path.join(APP_DIR, "node_modules", "typescript", "bin", "tsc"),
|
|
5387
|
-
path.join(TYPECHECK_DIR, "node_modules", "typescript", "bin", "tsc"),
|
|
5388
|
-
].find((p) => existsSync(p));
|
|
5389
|
-
if (!tsc) {
|
|
5390
|
-
return {
|
|
5391
|
-
status: "skipped",
|
|
5392
|
-
errors: [],
|
|
5393
|
-
totalErrors: 0,
|
|
5394
|
-
durationMs: 0,
|
|
5395
|
-
detail: "typescript is not installed (base image predates the baked typechecker)",
|
|
5396
|
-
};
|
|
5397
|
-
}
|
|
5398
|
-
let config = path.join(APP_DIR, "tsconfig.json");
|
|
5399
|
-
if (!existsSync(config)) {
|
|
5400
|
-
config = path.join(APP_DIR, ".spectest-tsconfig.json");
|
|
5401
|
-
await fs.writeFile(config, generatedTsconfig());
|
|
5402
|
-
}
|
|
5403
|
-
const res = await shx(process.execPath, [tsc, "--noEmit", "--pretty", "false", "-p", config], 120_000);
|
|
5404
|
-
const durationMs = Date.now() - started;
|
|
5405
|
-
if (res.code === 124) {
|
|
5406
|
-
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
|
|
5407
|
-
}
|
|
5408
|
-
const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
|
|
5409
|
-
const wrapper = [...(await runWrapperRulesReport(config)), ...(await rawFsReport())];
|
|
5410
|
-
const strip = ({ file, line, column, code, message }) => ({
|
|
5411
|
-
file,
|
|
5412
|
-
line,
|
|
5413
|
-
column,
|
|
5414
|
-
code,
|
|
5415
|
-
message,
|
|
5416
|
-
});
|
|
5417
|
-
const blocking = wrapper.filter((d) => d.blocking).map(strip);
|
|
5418
|
-
const advisory = wrapper.filter((d) => !d.blocking).map(strip);
|
|
5419
|
-
if (errors.length === 0 && wrapper.length === 0) {
|
|
5420
|
-
// Exit 0 → clean. Non-zero with no *user-file* diagnostics is either
|
|
5421
|
-
// all-suppressed (still ok from the user's perspective) or a compiler
|
|
5422
|
-
// crash (config not found, OOM) — surface the latter.
|
|
5423
|
-
if (res.code !== 0 && suppressed === 0) {
|
|
5424
|
-
const tail = (res.stderr || res.stdout).trim().split("\n").slice(-5).join("\n");
|
|
5425
|
-
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: tail || `tsc exited ${res.code}` };
|
|
5426
|
-
}
|
|
5427
|
-
if (suppressed > 0) {
|
|
5428
|
-
console.warn(`[typecheck] ${suppressed} diagnostic(s) outside the project suppressed`);
|
|
5429
|
-
}
|
|
5430
|
-
return { status: "ok", errors: [], totalErrors: 0, durationMs };
|
|
5431
|
-
}
|
|
5432
|
-
// The wrapper findings ride the same list the CLI and the dashboard already
|
|
5433
|
-
// render; `blocking` is a second view of the ones that also fail the run, so
|
|
5434
|
-
// nothing has to learn a new shape to show them. Blocking leads, because
|
|
5435
|
-
// `errors` is capped and the entries that stopped the run must never be the
|
|
5436
|
-
// ones the cap drops.
|
|
5437
|
-
const all = [...blocking, ...advisory, ...errors];
|
|
5438
|
-
return {
|
|
5439
|
-
status: "errors",
|
|
5440
|
-
errors: all.slice(0, TYPECHECK_ERROR_CAP),
|
|
5441
|
-
totalErrors: total + wrapper.length,
|
|
5442
|
-
durationMs,
|
|
5443
|
-
...(blocking.length > 0 ? { blocking } : {}),
|
|
5444
|
-
};
|
|
5311
|
+
async function runProjectTypecheck() {
|
|
5312
|
+
return runTypecheck({ appDir: APP_DIR, typecheckDir: TYPECHECK_DIR, run: shx,
|
|
5313
|
+
projectEntry: loaded ? resolveEntry() : undefined, extraDiagnostics: rawFsReport });
|
|
5445
5314
|
}
|
|
5446
5315
|
/**
|
|
5447
5316
|
* The raw-fs rule (`SPECTEST2003`, advisory) over the test tree: a test
|
|
@@ -5468,38 +5337,6 @@ async function rawFsReport() {
|
|
|
5468
5337
|
return [];
|
|
5469
5338
|
}
|
|
5470
5339
|
}
|
|
5471
|
-
/**
|
|
5472
|
-
* The blocking rules, run against the BAKED compiler whatever the project
|
|
5473
|
-
* pins. Best-effort in every direction: an install that predates the API, a
|
|
5474
|
-
* config the rules cannot open, or a throw from `typescript/unstable/*` all
|
|
5475
|
-
* yield no findings, so a run proceeds exactly as it does today. Only a
|
|
5476
|
-
* definite finding can stop one.
|
|
5477
|
-
*/
|
|
5478
|
-
async function runWrapperRulesReport(config) {
|
|
5479
|
-
const typescriptDir = path.join(TYPECHECK_DIR, "node_modules", "typescript");
|
|
5480
|
-
if (!existsSync(path.join(typescriptDir, "dist", "api", "async", "api.js")))
|
|
5481
|
-
return [];
|
|
5482
|
-
try {
|
|
5483
|
-
const run = await runWrapperRules({
|
|
5484
|
-
typescriptDir,
|
|
5485
|
-
configFile: config,
|
|
5486
|
-
appDir: APP_DIR,
|
|
5487
|
-
readFile: (f) => fs.readFile(f, "utf8"),
|
|
5488
|
-
relative: path.relative,
|
|
5489
|
-
join: path.join,
|
|
5490
|
-
dirname: path.dirname,
|
|
5491
|
-
});
|
|
5492
|
-
if (run.status !== "ok") {
|
|
5493
|
-
console.warn(`[typecheck] wrapper rules ${run.status}: ${run.detail ?? "no detail"}`);
|
|
5494
|
-
return [];
|
|
5495
|
-
}
|
|
5496
|
-
return run.diagnostics;
|
|
5497
|
-
}
|
|
5498
|
-
catch (err) {
|
|
5499
|
-
console.warn(`[typecheck] wrapper rules threw: ${err?.message ?? err}`);
|
|
5500
|
-
return [];
|
|
5501
|
-
}
|
|
5502
|
-
}
|
|
5503
5340
|
/** Run `job` under a mutual-exclusion slot, refusing if one is held. */
|
|
5504
5341
|
async function exclusive(state, slot, busy, job) {
|
|
5505
5342
|
if (state[slot])
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type TypecheckOptions, type TypecheckReport } from "./typecheck.js";
|
|
2
|
+
interface EvalTypecheckOptions extends Pick<TypecheckOptions, "appDir" | "typecheckDir" | "run"> {
|
|
3
|
+
file: string;
|
|
4
|
+
projectEntry?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function checkEval(opts: EvalTypecheckOptions): Promise<TypecheckReport>;
|
|
7
|
+
/** The same advisory/blocking policy as a managed test, before module import. */
|
|
8
|
+
export declare function evaluateChecked<T>(opts: EvalTypecheckOptions, log: (text: string) => void, evaluate: () => Promise<T>): Promise<T>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { generatedTsconfig, projectTypecheckConfig, runTypecheck } from "./typecheck.js";
|
|
4
|
+
/** Describe the globals installed by evalCode, without importing/executing the project. */
|
|
5
|
+
function globals(projectEntry) {
|
|
6
|
+
return `import type * as SDK from "@specific.dev/spectest";
|
|
7
|
+
${projectEntry ? `import type project from ${JSON.stringify(projectEntry)};` : ""}
|
|
8
|
+
type EvalContext = ${projectEntry
|
|
9
|
+
? "typeof project extends SDK.Project<infer S, infer F> ? SDK.TestContext<undefined, S, F> : SDK.TestContext<undefined>"
|
|
10
|
+
: "SDK.TestContext<undefined>"};
|
|
11
|
+
declare global {
|
|
12
|
+
var ctx: EvalContext;
|
|
13
|
+
var expect: typeof SDK.expect;
|
|
14
|
+
var expectRaw: typeof SDK.expectRaw;
|
|
15
|
+
var assert: typeof SDK.assert;
|
|
16
|
+
var state: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
`;
|
|
19
|
+
}
|
|
20
|
+
export async function checkEval(opts) {
|
|
21
|
+
const started = Date.now();
|
|
22
|
+
let scratch;
|
|
23
|
+
try {
|
|
24
|
+
scratch = await fs.mkdtemp(path.join(path.dirname(opts.file), "check-"));
|
|
25
|
+
const declarations = path.join(scratch, "globals.d.ts");
|
|
26
|
+
await fs.writeFile(declarations, globals(opts.projectEntry));
|
|
27
|
+
const projectConfig = projectTypecheckConfig(opts);
|
|
28
|
+
const defaults = JSON.parse(generatedTsconfig(opts.appDir, opts.typecheckDir));
|
|
29
|
+
const config = {
|
|
30
|
+
...(projectConfig ? { extends: projectConfig } : defaults),
|
|
31
|
+
compilerOptions: {
|
|
32
|
+
...(!projectConfig ? defaults.compilerOptions : {}),
|
|
33
|
+
// Bun runs eval as ESM TypeScript, regardless of the app's build output.
|
|
34
|
+
noEmit: true,
|
|
35
|
+
skipLibCheck: true,
|
|
36
|
+
target: "esnext",
|
|
37
|
+
module: "esnext",
|
|
38
|
+
moduleResolution: "bundler",
|
|
39
|
+
moduleDetection: "force",
|
|
40
|
+
allowImportingTsExtensions: true,
|
|
41
|
+
composite: false,
|
|
42
|
+
incremental: false,
|
|
43
|
+
rootDir: opts.appDir,
|
|
44
|
+
},
|
|
45
|
+
// Project includes/excludes may omit hidden files or include the entire
|
|
46
|
+
// application. Check this snippet, with imports resolved normally.
|
|
47
|
+
files: [opts.file, declarations],
|
|
48
|
+
include: [],
|
|
49
|
+
exclude: [],
|
|
50
|
+
};
|
|
51
|
+
const configFile = path.join(scratch, "tsconfig.json");
|
|
52
|
+
await fs.writeFile(configFile, JSON.stringify(config));
|
|
53
|
+
const report = await runTypecheck({ ...opts, configFile, diagnosticFile: opts.file });
|
|
54
|
+
// Stable, snippet-relative locations; don't expose random temporary paths.
|
|
55
|
+
const normalize = (d) => ({ ...d, file: "eval.ts" });
|
|
56
|
+
return {
|
|
57
|
+
...report,
|
|
58
|
+
errors: report.errors.map(normalize),
|
|
59
|
+
...(report.blocking ? { blocking: report.blocking.map(normalize) } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
// Match managed tests: unavailable/broken checkers report their failure,
|
|
64
|
+
// while only a definite blocking finding prevents execution.
|
|
65
|
+
return {
|
|
66
|
+
status: "failed", errors: [], totalErrors: 0, durationMs: Date.now() - started,
|
|
67
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
if (scratch)
|
|
72
|
+
await fs.rm(scratch, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** The same advisory/blocking policy as a managed test, before module import. */
|
|
76
|
+
export async function evaluateChecked(opts, log, evaluate) {
|
|
77
|
+
const report = await checkEval(opts);
|
|
78
|
+
if (report.status === "failed" || report.status === "skipped") {
|
|
79
|
+
log(`[typecheck] ${report.status}: ${report.detail ?? "unavailable"}\n`);
|
|
80
|
+
}
|
|
81
|
+
else if (report.totalErrors > 0) {
|
|
82
|
+
log(`[typecheck] ${report.totalErrors} diagnostic(s)${report.blocking?.length ? "" : " (advisory)"}\n`);
|
|
83
|
+
for (const d of report.errors)
|
|
84
|
+
log(`${d.file}:${d.line}:${d.column}: ${d.code}: ${d.message}\n`);
|
|
85
|
+
if (report.totalErrors > report.errors.length) {
|
|
86
|
+
log(`[typecheck] ${report.totalErrors - report.errors.length} more diagnostic(s)\n`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (report.blocking?.length) {
|
|
90
|
+
throw new Error(`Typecheck: ${report.blocking.length} blocking error(s) in eval — the snippet did not run. Fix the diagnostics above before retrying.`);
|
|
91
|
+
}
|
|
92
|
+
return evaluate();
|
|
93
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type WrapperDiagnostic } from "./wrapper-rules.js";
|
|
2
|
+
export interface TypecheckOptions {
|
|
3
|
+
appDir: string;
|
|
4
|
+
typecheckDir: string;
|
|
5
|
+
projectEntry?: string;
|
|
6
|
+
run: (file: string, args: string[], timeoutMs: number) => Promise<{
|
|
7
|
+
code: number;
|
|
8
|
+
stdout: string;
|
|
9
|
+
stderr: string;
|
|
10
|
+
}>;
|
|
11
|
+
configFile?: string;
|
|
12
|
+
/** Eval reports only the submitted snippet, not unrelated imported project code. */
|
|
13
|
+
diagnosticFile?: string;
|
|
14
|
+
extraDiagnostics?: () => Promise<WrapperDiagnostic[]>;
|
|
15
|
+
}
|
|
16
|
+
/** Both entry points honor the same project config before generating defaults. */
|
|
17
|
+
export declare function projectTypecheckConfig(opts: Pick<TypecheckOptions, "appDir" | "projectEntry">): string | undefined;
|
|
18
|
+
export interface TypecheckError {
|
|
19
|
+
/** Path relative to the project's spectest/ dir (e.g. `tests/checkout.ts`). */
|
|
20
|
+
file: string;
|
|
21
|
+
line: number;
|
|
22
|
+
column: number;
|
|
23
|
+
/** `TS2345` from the compiler, or a `SPECTEST….` rule code. */
|
|
24
|
+
code: string;
|
|
25
|
+
message: string;
|
|
26
|
+
}
|
|
27
|
+
export interface TypecheckReport {
|
|
28
|
+
status: "ok" | "errors" | "skipped" | "failed";
|
|
29
|
+
errors: TypecheckError[];
|
|
30
|
+
totalErrors: number;
|
|
31
|
+
durationMs: number;
|
|
32
|
+
/** Why the check was skipped / how it failed. */
|
|
33
|
+
detail?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Findings that FAIL the run, as opposed to `errors`, which only advise.
|
|
36
|
+
* A diagnostic qualifies only when it is true of the program that runs
|
|
37
|
+
* rather than of the declared types alone — see `harness/wrapper-rules.ts`
|
|
38
|
+
* for why no `tsc` code can be in here and why these can.
|
|
39
|
+
*
|
|
40
|
+
* The list is the rollout mechanism as well as the payload: an older SDK
|
|
41
|
+
* never sends the field, `#[serde(default)]` leaves it empty on the server,
|
|
42
|
+
* and the gate is inert for every project that has not moved to a version
|
|
43
|
+
* carrying it. The version a project runs is already its own choice
|
|
44
|
+
* (`sdk.rs` honours the declared lower bound), so the bump is the consent.
|
|
45
|
+
*/
|
|
46
|
+
blocking?: TypecheckError[];
|
|
47
|
+
}
|
|
48
|
+
/** The tsconfig used when the project doesn't ship its own. `lib` is left to
|
|
49
|
+
* the target default (which includes DOM — browser.evaluate callbacks
|
|
50
|
+
* reference `document`); `types` pulls Bun's ambient globals from the baked
|
|
51
|
+
* install via `typeRoots` (the walk-up from APP_DIR never reaches it). */
|
|
52
|
+
export declare function generatedTsconfig(appDir: string, typecheckDir: string): string;
|
|
53
|
+
export declare function runTypecheck(opts: TypecheckOptions): Promise<TypecheckReport>;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { runWrapperRules } from "./wrapper-rules.js";
|
|
5
|
+
/** Both entry points honor the same project config before generating defaults. */
|
|
6
|
+
export function projectTypecheckConfig(opts) {
|
|
7
|
+
return [
|
|
8
|
+
path.join(opts.appDir, "tsconfig.json"),
|
|
9
|
+
...(opts.projectEntry ? [path.join(path.dirname(opts.projectEntry), "tsconfig.json")] : []),
|
|
10
|
+
].find(existsSync);
|
|
11
|
+
}
|
|
12
|
+
/** Cap on errors shipped in the report; `totalErrors` carries the true count. */
|
|
13
|
+
const TYPECHECK_ERROR_CAP = 50;
|
|
14
|
+
/** The tsconfig used when the project doesn't ship its own. `lib` is left to
|
|
15
|
+
* the target default (which includes DOM — browser.evaluate callbacks
|
|
16
|
+
* reference `document`); `types` pulls Bun's ambient globals from the baked
|
|
17
|
+
* install via `typeRoots` (the walk-up from APP_DIR never reaches it). */
|
|
18
|
+
export function generatedTsconfig(appDir, typecheckDir) {
|
|
19
|
+
return JSON.stringify({
|
|
20
|
+
compilerOptions: {
|
|
21
|
+
target: "esnext",
|
|
22
|
+
module: "esnext",
|
|
23
|
+
moduleResolution: "bundler",
|
|
24
|
+
strict: true,
|
|
25
|
+
noEmit: true,
|
|
26
|
+
skipLibCheck: true,
|
|
27
|
+
esModuleInterop: true,
|
|
28
|
+
resolveJsonModule: true,
|
|
29
|
+
jsx: "react-jsx",
|
|
30
|
+
types: ["bun"],
|
|
31
|
+
typeRoots: [
|
|
32
|
+
path.join(appDir, "node_modules", "@types"),
|
|
33
|
+
path.join(typecheckDir, "node_modules", "@types"),
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
include: ["**/*.ts", "**/*.tsx"],
|
|
37
|
+
exclude: ["node_modules", ".spectest-eval"],
|
|
38
|
+
}, null, 2);
|
|
39
|
+
}
|
|
40
|
+
/** Parse `--pretty false` tsc output: `file(line,col): error TScode: msg`,
|
|
41
|
+
* with indented elaboration lines folded into the preceding error. */
|
|
42
|
+
function parseTscOutput(output, appDir, diagnosticFile) {
|
|
43
|
+
const errors = [];
|
|
44
|
+
let total = 0;
|
|
45
|
+
let suppressed = 0;
|
|
46
|
+
let last = null;
|
|
47
|
+
for (const line of output.split("\n")) {
|
|
48
|
+
const m = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(line);
|
|
49
|
+
if (!m) {
|
|
50
|
+
// Elaboration lines are indented; fold them into the last kept error.
|
|
51
|
+
if (last && /^\s+\S/.test(line))
|
|
52
|
+
last.message += `\n${line.trimEnd()}`;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const [, rawFile, ln, col, code, message] = m;
|
|
56
|
+
// Keep only diagnostics in the user's files. tsc runs with cwd=APP_DIR
|
|
57
|
+
// (the daemon's cwd), so project files come out relative ("tests/x.ts")
|
|
58
|
+
// and anything outside — the SDK at /opt/spectest/sdk, node_modules —
|
|
59
|
+
// is `../…` or absolute. Errors in our own SDK are ours to fix, not the
|
|
60
|
+
// user's to read.
|
|
61
|
+
const abs = path.resolve(appDir, rawFile);
|
|
62
|
+
const inApp = abs.startsWith(appDir + path.sep) && !abs.includes(`${path.sep}node_modules${path.sep}`);
|
|
63
|
+
if (!inApp || (diagnosticFile && abs !== diagnosticFile)) {
|
|
64
|
+
suppressed += 1;
|
|
65
|
+
last = null;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
total += 1;
|
|
69
|
+
const err = {
|
|
70
|
+
file: path.relative(appDir, abs),
|
|
71
|
+
line: Number(ln),
|
|
72
|
+
column: Number(col),
|
|
73
|
+
code: code,
|
|
74
|
+
message: message,
|
|
75
|
+
};
|
|
76
|
+
if (errors.length < TYPECHECK_ERROR_CAP) {
|
|
77
|
+
errors.push(err);
|
|
78
|
+
last = err;
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
last = null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { errors, total, suppressed };
|
|
85
|
+
}
|
|
86
|
+
export async function runTypecheck(opts) {
|
|
87
|
+
const { appDir, typecheckDir, run } = opts;
|
|
88
|
+
const started = Date.now();
|
|
89
|
+
// The project's own typescript wins over the baked copy.
|
|
90
|
+
const tsc = [
|
|
91
|
+
path.join(appDir, "node_modules", "typescript", "bin", "tsc"),
|
|
92
|
+
path.join(typecheckDir, "node_modules", "typescript", "bin", "tsc"),
|
|
93
|
+
].find((p) => existsSync(p));
|
|
94
|
+
if (!tsc) {
|
|
95
|
+
return {
|
|
96
|
+
status: "skipped",
|
|
97
|
+
errors: [],
|
|
98
|
+
totalErrors: 0,
|
|
99
|
+
durationMs: 0,
|
|
100
|
+
detail: "typescript is not installed (base image predates the baked typechecker)",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
let config = opts.configFile ?? projectTypecheckConfig(opts);
|
|
104
|
+
if (!config) {
|
|
105
|
+
config = path.join(appDir, ".spectest-tsconfig.json");
|
|
106
|
+
await fs.writeFile(config, generatedTsconfig(appDir, typecheckDir));
|
|
107
|
+
}
|
|
108
|
+
const res = await run(process.execPath, [tsc, "--noEmit", "--pretty", "false", "-p", config], 120_000);
|
|
109
|
+
const durationMs = Date.now() - started;
|
|
110
|
+
if (res.code === 124) {
|
|
111
|
+
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
|
|
112
|
+
}
|
|
113
|
+
const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr, appDir, opts.diagnosticFile);
|
|
114
|
+
const wrapper = [...(await runWrapperRulesReport(config, opts)), ...(await opts.extraDiagnostics?.() ?? [])];
|
|
115
|
+
const strip = ({ file, line, column, code, message }) => ({
|
|
116
|
+
file,
|
|
117
|
+
line,
|
|
118
|
+
column,
|
|
119
|
+
code,
|
|
120
|
+
message,
|
|
121
|
+
});
|
|
122
|
+
const blocking = wrapper.filter((d) => d.blocking).map(strip);
|
|
123
|
+
const advisory = wrapper.filter((d) => !d.blocking).map(strip);
|
|
124
|
+
if (errors.length === 0 && wrapper.length === 0) {
|
|
125
|
+
// Exit 0 → clean. Non-zero with no *user-file* diagnostics is either
|
|
126
|
+
// all-suppressed (still ok from the user's perspective) or a compiler
|
|
127
|
+
// crash (config not found, OOM) — surface the latter.
|
|
128
|
+
if (res.code !== 0 && suppressed === 0) {
|
|
129
|
+
const tail = (res.stderr || res.stdout).trim().split("\n").slice(-5).join("\n");
|
|
130
|
+
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: tail || `tsc exited ${res.code}` };
|
|
131
|
+
}
|
|
132
|
+
if (suppressed > 0) {
|
|
133
|
+
console.warn(`[typecheck] ${suppressed} diagnostic(s) outside the project suppressed`);
|
|
134
|
+
}
|
|
135
|
+
return { status: "ok", errors: [], totalErrors: 0, durationMs };
|
|
136
|
+
}
|
|
137
|
+
// The wrapper findings ride the same list the CLI and the dashboard already
|
|
138
|
+
// render; `blocking` is a second view of the ones that also fail the run, so
|
|
139
|
+
// nothing has to learn a new shape to show them. Blocking leads, because
|
|
140
|
+
// `errors` is capped and the entries that stopped the run must never be the
|
|
141
|
+
// ones the cap drops.
|
|
142
|
+
const all = [...blocking, ...advisory, ...errors];
|
|
143
|
+
return {
|
|
144
|
+
status: "errors",
|
|
145
|
+
errors: all.slice(0, TYPECHECK_ERROR_CAP),
|
|
146
|
+
totalErrors: total + wrapper.length,
|
|
147
|
+
durationMs,
|
|
148
|
+
...(blocking.length > 0 ? { blocking } : {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The blocking rules, run against the BAKED compiler whatever the project
|
|
153
|
+
* pins. Best-effort in every direction: an install that predates the API, a
|
|
154
|
+
* config the rules cannot open, or a throw from `typescript/unstable/*` all
|
|
155
|
+
* yield no findings, so a run proceeds exactly as it does today. Only a
|
|
156
|
+
* definite finding can stop one.
|
|
157
|
+
*/
|
|
158
|
+
async function runWrapperRulesReport(config, opts) {
|
|
159
|
+
const { appDir, typecheckDir } = opts;
|
|
160
|
+
const typescriptDir = path.join(typecheckDir, "node_modules", "typescript");
|
|
161
|
+
if (!existsSync(path.join(typescriptDir, "dist", "api", "async", "api.js")))
|
|
162
|
+
return [];
|
|
163
|
+
try {
|
|
164
|
+
const run = await runWrapperRules({
|
|
165
|
+
typescriptDir,
|
|
166
|
+
configFile: config,
|
|
167
|
+
appDir,
|
|
168
|
+
diagnosticFile: opts.diagnosticFile,
|
|
169
|
+
readFile: (f) => fs.readFile(f, "utf8"),
|
|
170
|
+
relative: path.relative,
|
|
171
|
+
join: path.join,
|
|
172
|
+
dirname: path.dirname,
|
|
173
|
+
});
|
|
174
|
+
if (run.status !== "ok") {
|
|
175
|
+
console.warn(`[typecheck] wrapper rules ${run.status}: ${run.detail ?? "no detail"}`);
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
return run.diagnostics;
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
console.warn(`[typecheck] wrapper rules threw: ${err?.message ?? err}`);
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -154,6 +154,8 @@ export declare function runWrapperRules(opts: {
|
|
|
154
154
|
configFile: string;
|
|
155
155
|
/** Only files under here produce diagnostics. */
|
|
156
156
|
appDir: string;
|
|
157
|
+
/** Restrict eval findings to its submitted module. */
|
|
158
|
+
diagnosticFile?: string;
|
|
157
159
|
readFile: (p: string) => Promise<string>;
|
|
158
160
|
relative: (from: string, to: string) => string;
|
|
159
161
|
join: (...parts: string[]) => string;
|
|
@@ -266,10 +266,6 @@ export function equalityMessage(expr, typeText, negated) {
|
|
|
266
266
|
`comparison is ${verdict}. Call \`.unwrap()\` first, or assert with ` +
|
|
267
267
|
`\`expect(...)\`, which unwraps for you.`);
|
|
268
268
|
}
|
|
269
|
-
/** `TypeFlags.Object | TypeFlags.Union` — the only shapes a carrier can wear.
|
|
270
|
-
* Filtering on the flags the batch call already returned keeps the printed
|
|
271
|
-
* name (one round trip each) off every ordinary `boolean` condition. */
|
|
272
|
-
const OBJECT_OR_UNION = (1 << 19) | (1 << 20);
|
|
273
269
|
const SYNTAX = {
|
|
274
270
|
If: "IfStatement",
|
|
275
271
|
While: "WhileStatement",
|
|
@@ -343,6 +339,11 @@ export async function runWrapperRules(opts) {
|
|
|
343
339
|
const base = opts.join(opts.typescriptDir, "dist");
|
|
344
340
|
const apiMod = (await import(opts.join(base, "api", "async", "api.js")));
|
|
345
341
|
const utils = (await import(opts.join(base, "ast", "utils.js")));
|
|
342
|
+
// Native TypeScript's flag values differ from the JavaScript compiler's.
|
|
343
|
+
// Read them from the compiler serving this API; hard-coded TS 5 flags
|
|
344
|
+
// silently miss wrapped boolean unions in TS 7.
|
|
345
|
+
const { TypeFlags } = await import(opts.join(base, "enums", "typeFlags.js"));
|
|
346
|
+
const objectOrUnion = TypeFlags.Object | TypeFlags.Union;
|
|
346
347
|
const visitor = (await import(opts.join(base, "ast", "visitor.js")));
|
|
347
348
|
const inst = new apiMod.API({ cwd: opts.dirname(opts.configFile) });
|
|
348
349
|
api = inst;
|
|
@@ -379,7 +380,8 @@ export async function runWrapperRules(opts) {
|
|
|
379
380
|
}
|
|
380
381
|
};
|
|
381
382
|
const names = await project.program.getSourceFileNames();
|
|
382
|
-
const files = names.filter((f) => f.startsWith(`${opts.appDir}/`) && !f.includes("/node_modules/") && !f.endsWith(".d.ts")
|
|
383
|
+
const files = names.filter((f) => f.startsWith(`${opts.appDir}/`) && !f.includes("/node_modules/") && !f.endsWith(".d.ts") &&
|
|
384
|
+
(!opts.diagnosticFile || f === opts.diagnosticFile));
|
|
383
385
|
const diagnostics = [];
|
|
384
386
|
for (const file of files) {
|
|
385
387
|
const sf = await project.program.getSourceFile(file);
|
|
@@ -395,7 +397,7 @@ export async function runWrapperRules(opts) {
|
|
|
395
397
|
const printed = new Map();
|
|
396
398
|
for (let i = 0; i < nodes.length; i += 1) {
|
|
397
399
|
const t = types[i];
|
|
398
|
-
if (t && (t.flags &
|
|
400
|
+
if (t && (t.flags & objectOrUnion) !== 0) {
|
|
399
401
|
printed.set(i, await project.checker.typeToString(t));
|
|
400
402
|
}
|
|
401
403
|
}
|