@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/src/daemon.ts CHANGED
@@ -103,7 +103,9 @@ import {
103
103
  sanitizeSegment,
104
104
  } from "./harness/volume-paths.js";
105
105
  import { pollUntilReady } from "./harness/ready-poll.js";
106
- import { fsUsageDiagnostics, runWrapperRules } from "./harness/wrapper-rules.js";
106
+ import { fsUsageDiagnostics } from "./harness/wrapper-rules.js";
107
+ import { runTypecheck, type TypecheckReport } from "./harness/typecheck.js";
108
+ import { evaluateChecked } from "./harness/eval-typecheck.js";
107
109
  import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
108
110
  import { cpus, tmpdir } from "node:os";
109
111
  import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
@@ -6246,7 +6248,12 @@ async function evalCode(code: string): Promise<EvalResult> {
6246
6248
  filePath = path.join(EVAL_DIR, `${randomUUID()}.ts`);
6247
6249
  await fs.writeFile(filePath, prepared.code);
6248
6250
  const moduleUrl = pathToFileURL(filePath).href;
6249
- const mod = (await runInstrumented(fetchScope, () => import(moduleUrl))) as { default?: unknown };
6251
+ const mod = await evaluateChecked(
6252
+ { file: filePath, appDir: APP_DIR, typecheckDir: TYPECHECK_DIR, run: shx,
6253
+ projectEntry: loaded ? resolveEntry() : undefined },
6254
+ (text) => { chunks.push(text); },
6255
+ () => runInstrumented(fetchScope, () => import(moduleUrl)) as Promise<{ default?: unknown }>,
6256
+ );
6250
6257
  outcome = { ok: true, result: safeSerialize(mod.default) };
6251
6258
  } catch (err) {
6252
6259
  const e = err as Error;
@@ -6327,46 +6334,11 @@ async function evalCode(code: string): Promise<EvalResult> {
6327
6334
  * blocking rules, which always use THIS copy rather than the project's own —
6328
6335
  * can be driven outside a VM. */
6329
6336
  const TYPECHECK_DIR = process.env.SPECTEST_TYPECHECK_DIR ?? "/opt/spectest/typecheck";
6330
- /** Cap on errors shipped in the report; `totalErrors` carries the true count. */
6331
- const TYPECHECK_ERROR_CAP = 50;
6332
-
6333
- interface TypecheckError {
6334
- /** Path relative to the project's spectest/ dir (e.g. `tests/checkout.ts`). */
6335
- file: string;
6336
- line: number;
6337
- column: number;
6338
- /** `TS2345` from the compiler, or a `SPECTEST….` rule code. */
6339
- code: string;
6340
- message: string;
6341
- }
6342
-
6343
- interface TypecheckReport {
6344
- status: "ok" | "errors" | "skipped" | "failed";
6345
- errors: TypecheckError[];
6346
- totalErrors: number;
6347
- durationMs: number;
6348
- /** Why the check was skipped / how it failed. */
6349
- detail?: string;
6350
- /**
6351
- * Findings that FAIL the run, as opposed to `errors`, which only advise.
6352
- * A diagnostic qualifies only when it is true of the program that runs
6353
- * rather than of the declared types alone — see `harness/wrapper-rules.ts`
6354
- * for why no `tsc` code can be in here and why these can.
6355
- *
6356
- * The list is the rollout mechanism as well as the payload: an older SDK
6357
- * never sends the field, `#[serde(default)]` leaves it empty on the server,
6358
- * and the gate is inert for every project that has not moved to a version
6359
- * carrying it. The version a project runs is already its own choice
6360
- * (`sdk.rs` honours the declared lower bound), so the bump is the consent.
6361
- */
6362
- blocking?: TypecheckError[];
6363
- }
6364
-
6365
6337
  let TYPECHECK: Promise<TypecheckReport> | null = null;
6366
6338
 
6367
6339
  /** Fire-and-forget kickoff; the promise is parked for POST /typecheck. */
6368
6340
  function startTypecheck(): void {
6369
- TYPECHECK = runTypecheck().catch((err) => ({
6341
+ TYPECHECK = runProjectTypecheck().catch((err) => ({
6370
6342
  status: "failed" as const,
6371
6343
  errors: [],
6372
6344
  totalErrors: 0,
@@ -6377,149 +6349,9 @@ function startTypecheck(): void {
6377
6349
  TYPECHECK.catch(() => {});
6378
6350
  }
6379
6351
 
6380
- /** The tsconfig used when the project doesn't ship its own. `lib` is left to
6381
- * the target default (which includes DOM — browser.evaluate callbacks
6382
- * reference `document`); `types` pulls Bun's ambient globals from the baked
6383
- * install via `typeRoots` (the walk-up from APP_DIR never reaches it). */
6384
- function generatedTsconfig(): string {
6385
- return JSON.stringify(
6386
- {
6387
- compilerOptions: {
6388
- target: "esnext",
6389
- module: "esnext",
6390
- moduleResolution: "bundler",
6391
- strict: true,
6392
- noEmit: true,
6393
- skipLibCheck: true,
6394
- esModuleInterop: true,
6395
- resolveJsonModule: true,
6396
- jsx: "react-jsx",
6397
- types: ["bun"],
6398
- typeRoots: [
6399
- path.join(APP_DIR, "node_modules", "@types"),
6400
- path.join(TYPECHECK_DIR, "node_modules", "@types"),
6401
- ],
6402
- },
6403
- include: ["**/*.ts", "**/*.tsx"],
6404
- exclude: ["node_modules"],
6405
- },
6406
- null,
6407
- 2,
6408
- );
6409
- }
6410
-
6411
- /** Parse `--pretty false` tsc output: `file(line,col): error TScode: msg`,
6412
- * with indented elaboration lines folded into the preceding error. */
6413
- function parseTscOutput(output: string): {
6414
- errors: TypecheckError[];
6415
- total: number;
6416
- suppressed: number;
6417
- } {
6418
- const errors: TypecheckError[] = [];
6419
- let total = 0;
6420
- let suppressed = 0;
6421
- let last: TypecheckError | null = null;
6422
- for (const line of output.split("\n")) {
6423
- const m = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(line);
6424
- if (!m) {
6425
- // Elaboration lines are indented; fold them into the last kept error.
6426
- if (last && /^\s+\S/.test(line)) last.message += `\n${line.trimEnd()}`;
6427
- continue;
6428
- }
6429
- const [, rawFile, ln, col, code, message] = m;
6430
- // Keep only diagnostics in the user's files. tsc runs with cwd=APP_DIR
6431
- // (the daemon's cwd), so project files come out relative ("tests/x.ts")
6432
- // and anything outside — the SDK at /opt/spectest/sdk, node_modules —
6433
- // is `../…` or absolute. Errors in our own SDK are ours to fix, not the
6434
- // user's to read.
6435
- const abs = path.resolve(APP_DIR, rawFile!);
6436
- const inApp = abs.startsWith(APP_DIR + path.sep) && !abs.includes(`${path.sep}node_modules${path.sep}`);
6437
- if (!inApp) {
6438
- suppressed += 1;
6439
- last = null;
6440
- continue;
6441
- }
6442
- total += 1;
6443
- const err: TypecheckError = {
6444
- file: path.relative(APP_DIR, abs),
6445
- line: Number(ln),
6446
- column: Number(col),
6447
- code: code!,
6448
- message: message!,
6449
- };
6450
- if (errors.length < TYPECHECK_ERROR_CAP) {
6451
- errors.push(err);
6452
- last = err;
6453
- } else {
6454
- last = null;
6455
- }
6456
- }
6457
- return { errors, total, suppressed };
6458
- }
6459
-
6460
- async function runTypecheck(): Promise<TypecheckReport> {
6461
- const started = Date.now();
6462
- // The project's own typescript wins over the baked copy.
6463
- const tsc = [
6464
- path.join(APP_DIR, "node_modules", "typescript", "bin", "tsc"),
6465
- path.join(TYPECHECK_DIR, "node_modules", "typescript", "bin", "tsc"),
6466
- ].find((p) => existsSync(p));
6467
- if (!tsc) {
6468
- return {
6469
- status: "skipped",
6470
- errors: [],
6471
- totalErrors: 0,
6472
- durationMs: 0,
6473
- detail: "typescript is not installed (base image predates the baked typechecker)",
6474
- };
6475
- }
6476
- let config = path.join(APP_DIR, "tsconfig.json");
6477
- if (!existsSync(config)) {
6478
- config = path.join(APP_DIR, ".spectest-tsconfig.json");
6479
- await fs.writeFile(config, generatedTsconfig());
6480
- }
6481
- const res = await shx(process.execPath, [tsc, "--noEmit", "--pretty", "false", "-p", config], 120_000);
6482
- const durationMs = Date.now() - started;
6483
- if (res.code === 124) {
6484
- return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
6485
- }
6486
- const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
6487
- const wrapper = [...(await runWrapperRulesReport(config)), ...(await rawFsReport())];
6488
- const strip = ({ file, line, column, code, message }: WrapperDiagnostic): TypecheckError => ({
6489
- file,
6490
- line,
6491
- column,
6492
- code,
6493
- message,
6494
- });
6495
- const blocking = wrapper.filter((d) => d.blocking).map(strip);
6496
- const advisory = wrapper.filter((d) => !d.blocking).map(strip);
6497
- if (errors.length === 0 && wrapper.length === 0) {
6498
- // Exit 0 → clean. Non-zero with no *user-file* diagnostics is either
6499
- // all-suppressed (still ok from the user's perspective) or a compiler
6500
- // crash (config not found, OOM) — surface the latter.
6501
- if (res.code !== 0 && suppressed === 0) {
6502
- const tail = (res.stderr || res.stdout).trim().split("\n").slice(-5).join("\n");
6503
- return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: tail || `tsc exited ${res.code}` };
6504
- }
6505
- if (suppressed > 0) {
6506
- console.warn(`[typecheck] ${suppressed} diagnostic(s) outside the project suppressed`);
6507
- }
6508
- return { status: "ok", errors: [], totalErrors: 0, durationMs };
6509
- }
6510
- // The wrapper findings ride the same list the CLI and the dashboard already
6511
- // render; `blocking` is a second view of the ones that also fail the run, so
6512
- // nothing has to learn a new shape to show them. Blocking leads, because
6513
- // `errors` is capped and the entries that stopped the run must never be the
6514
- // ones the cap drops.
6515
- const all = [...blocking, ...advisory, ...errors];
6516
- return {
6517
- status: "errors",
6518
- errors: all.slice(0, TYPECHECK_ERROR_CAP),
6519
- totalErrors: total + wrapper.length,
6520
- durationMs,
6521
- ...(blocking.length > 0 ? { blocking } : {}),
6522
- };
6352
+ async function runProjectTypecheck(): Promise<TypecheckReport> {
6353
+ return runTypecheck({ appDir: APP_DIR, typecheckDir: TYPECHECK_DIR, run: shx,
6354
+ projectEntry: loaded ? resolveEntry() : undefined, extraDiagnostics: rawFsReport });
6523
6355
  }
6524
6356
 
6525
6357
  /**
@@ -6545,37 +6377,6 @@ async function rawFsReport(): Promise<WrapperDiagnostic[]> {
6545
6377
  }
6546
6378
  }
6547
6379
 
6548
- /**
6549
- * The blocking rules, run against the BAKED compiler whatever the project
6550
- * pins. Best-effort in every direction: an install that predates the API, a
6551
- * config the rules cannot open, or a throw from `typescript/unstable/*` all
6552
- * yield no findings, so a run proceeds exactly as it does today. Only a
6553
- * definite finding can stop one.
6554
- */
6555
- async function runWrapperRulesReport(config: string): Promise<WrapperDiagnostic[]> {
6556
- const typescriptDir = path.join(TYPECHECK_DIR, "node_modules", "typescript");
6557
- if (!existsSync(path.join(typescriptDir, "dist", "api", "async", "api.js"))) return [];
6558
- try {
6559
- const run = await runWrapperRules({
6560
- typescriptDir,
6561
- configFile: config,
6562
- appDir: APP_DIR,
6563
- readFile: (f) => fs.readFile(f, "utf8"),
6564
- relative: path.relative,
6565
- join: path.join,
6566
- dirname: path.dirname,
6567
- });
6568
- if (run.status !== "ok") {
6569
- console.warn(`[typecheck] wrapper rules ${run.status}: ${run.detail ?? "no detail"}`);
6570
- return [];
6571
- }
6572
- return run.diagnostics;
6573
- } catch (err) {
6574
- console.warn(`[typecheck] wrapper rules threw: ${(err as Error)?.message ?? err}`);
6575
- return [];
6576
- }
6577
- }
6578
-
6579
6380
  // ────────────────────────────────────────────────────────────────────────
6580
6381
  // HTTP server
6581
6382
  // ────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,163 @@
1
+ import { afterEach, beforeEach, expect, test } from "bun:test";
2
+ import fs from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { execFile } from "node:child_process";
7
+ import { checkEval, evaluateChecked } from "./eval-typecheck.js";
8
+ import { runTypecheck } from "./typecheck.js";
9
+
10
+ const sdkDir = path.resolve(import.meta.dir, "../..");
11
+ // CI/dev can select the same native TypeScript installation as the VM.
12
+ const typecheckDir = process.env.SPECTEST_TYPECHECK_DIR ?? sdkDir;
13
+ const hasWrapperChecker = existsSync(path.join(typecheckDir, "node_modules/typescript/dist/api/async/api.js"));
14
+ let appDir: string;
15
+ let file: string;
16
+ let projectEntry: string;
17
+
18
+ beforeEach(async () => {
19
+ appDir = await fs.mkdtemp(path.join(tmpdir(), "spectest-eval-types-"));
20
+ await fs.mkdir(path.join(appDir, "node_modules/@specific.dev"), { recursive: true });
21
+ await fs.symlink(sdkDir, path.join(appDir, "node_modules/@specific.dev/spectest"));
22
+ await fs.symlink(path.join(sdkDir, "node_modules/@types"), path.join(appDir, "node_modules/@types"));
23
+ await fs.mkdir(path.join(appDir, "spectest"));
24
+ await fs.mkdir(path.join(appDir, ".spectest-eval"));
25
+ file = path.join(appDir, ".spectest-eval/snippet.ts");
26
+ projectEntry = path.join(appDir, "spectest/index.ts");
27
+ await fs.writeFile(projectEntry, `
28
+ import { defineEnvironment, defineFake } from "@specific.dev/spectest";
29
+ import { supabase } from "@specific.dev/spectest/components";
30
+ export const env = defineEnvironment({
31
+ name: "eval-typecheck",
32
+ services: { supabase: supabase().group },
33
+ fakes: { sample: defineFake({ hostnames: ["example.test"], state: () => ({}),
34
+ handler: () => new Response(), helpers: () => ({ count: () => 3 }) }) },
35
+ });
36
+ export default env.project([]);
37
+ `);
38
+ });
39
+
40
+ afterEach(async () => { await fs.rm(appDir, { recursive: true, force: true }); });
41
+
42
+ function run(command: string, args: string[], timeout: number): Promise<{ code: number; stdout: string; stderr: string }> {
43
+ return new Promise((resolve) => {
44
+ execFile(command, args, { cwd: appDir, timeout, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
45
+ resolve({ code: error ? (typeof error.code === "number" ? error.code : 1) : 0, stdout, stderr });
46
+ });
47
+ });
48
+ }
49
+
50
+ const opts = () => ({ appDir, file, projectEntry, typecheckDir, run });
51
+ const query = 'const rows = await ctx.svc.supabase.sql<{ analyzed: boolean }>`SELECT true AS analyzed`;';
52
+
53
+ test("eval receives the default-exported project's service/fake types and globals", async () => {
54
+ await fs.writeFile(file, `${query}
55
+ const ready: boolean = rows[0].analyzed.unwrap();
56
+ const count: number = ctx.fakes.sample.count().unwrap();
57
+ expect(rows[0].analyzed).toBe(true);
58
+ expectRaw(count, "count").toBe(3);
59
+ assert(ready);
60
+ state.saved = ready;
61
+ export default ready;
62
+ `);
63
+ const report = await checkEval(opts());
64
+ expect(report).toMatchObject({ status: "ok", errors: [], totalErrors: 0 });
65
+ expect(await fs.readdir(path.dirname(file))).toEqual(["snippet.ts"]);
66
+ }, 30_000);
67
+
68
+ test("compiler catches the Listen comparison and reports original snippet lines", async () => {
69
+ await fs.writeFile(file, `${query}\nconst done = rows[0].analyzed === true;\nexport default done;`);
70
+ const report = await checkEval(opts());
71
+ expect(report.errors).toContainEqual(expect.objectContaining({ file: "eval.ts", line: 2, code: "TS2367" }));
72
+ }, 30_000);
73
+
74
+ test("an untyped SQL row and nonexistent service remain diagnostics, not any", async () => {
75
+ await fs.writeFile(file, 'const rows = await ctx.svc.supabase.sql`SELECT true AS analyzed`;\nexport default rows[0].analyzed;\nctx.svc.missing;');
76
+ const report = await checkEval(opts());
77
+ expect(report.errors.filter((e) => e.code === "TS2339").map((e) => e.line)).toEqual([2, 3]);
78
+ }, 30_000);
79
+
80
+ test.skipIf(!hasWrapperChecker)("blocking wrapper finding prevents all snippet side effects", async () => {
81
+ await fs.writeFile(file, `${query}\nexport default rows[0].analyzed === true;`);
82
+ let executed = false;
83
+ const logs: string[] = [];
84
+ await expect(evaluateChecked(opts(), (s) => logs.push(s), async () => { executed = true; })).rejects.toThrow("snippet did not run");
85
+ expect(executed).toBe(false);
86
+ expect(logs.join("")).toContain("SPECTEST2002");
87
+ expect(logs.join("")).toContain(".unwrap()");
88
+ }, 30_000);
89
+
90
+ test("ordinary errors are advisory and unrelated project errors do not enter eval output", async () => {
91
+ await fs.appendFile(projectEntry, '\nconst unrelated: number = "wrong";');
92
+ await fs.writeFile(file, 'const x: number = "wrong";\nexport default x;');
93
+ const logs: string[] = [];
94
+ expect(await evaluateChecked(opts(), (s) => logs.push(s), async () => "executed")).toBe("executed");
95
+ expect(logs.join("")).toContain("(advisory)");
96
+ expect(logs.join("")).toContain("eval.ts:1:");
97
+ expect(logs.join("")).not.toContain("index.ts");
98
+ }, 30_000);
99
+
100
+ test.skipIf(!hasWrapperChecker)("unwrapped comparisons execute and nullable presence checks remain allowed", async () => {
101
+ await fs.writeFile(file, `${query}\nexport default rows[0].analyzed.unwrap() === true;`);
102
+ expect(await evaluateChecked(opts(), () => {}, async () => "executed")).toBe("executed");
103
+ await fs.writeFile(file, 'const rows = await ctx.svc.supabase.sql<{ label: string | null }>`SELECT null AS label`;\nexport default rows[0].label === null;');
104
+ expect((await checkEval(opts())).blocking).toBeUndefined();
105
+ }, 30_000);
106
+
107
+ test("eval without a loaded project still checks basic globals", async () => {
108
+ await fs.writeFile(file, 'assert(true); state.value = 1; export default ctx.testName;');
109
+ expect(await checkEval({ ...opts(), projectEntry: undefined })).toMatchObject({ status: "ok", totalErrors: 0 });
110
+ }, 30_000);
111
+
112
+ test("project paths are respected even when its include excludes the eval directory", async () => {
113
+ await fs.writeFile(path.join(appDir, "spectest/tsconfig.json"), JSON.stringify({
114
+ compilerOptions: { paths: { "@fixture": ["./fixture.ts"] } }, include: ["tests/**/*.ts"],
115
+ }));
116
+ await fs.writeFile(path.join(appDir, "spectest/fixture.ts"), 'export const count = 3;');
117
+ await fs.writeFile(file, 'import { count } from "@fixture";\nconst x: number = count;\nexport default x;');
118
+ expect(await checkEval(opts())).toMatchObject({ status: "ok", totalErrors: 0 });
119
+ }, 30_000);
120
+
121
+ test("unavailable compiler is reported, evaluation continues, and scratch files are removed", async () => {
122
+ await fs.writeFile(file, "export default 1;");
123
+ const logs: string[] = [];
124
+ expect(await evaluateChecked({ ...opts(), typecheckDir: path.join(appDir, "missing") }, (s) => logs.push(s), async () => 1)).toBe(1);
125
+ expect(logs.join("")).toContain("[typecheck] skipped:");
126
+ expect(await fs.readdir(path.dirname(file))).toEqual(["snippet.ts"]);
127
+ });
128
+
129
+ test("shared managed-test checker still reports project diagnostics", async () => {
130
+ await fs.appendFile(projectEntry, '\nconst wrong: number = "bad";');
131
+ const report = await runTypecheck({ appDir, typecheckDir, run });
132
+ expect(report.errors).toContainEqual(expect.objectContaining({ file: "spectest/index.ts", code: "TS2322" }));
133
+ }, 30_000);
134
+
135
+ test.skipIf(!hasWrapperChecker)("managed tests and evals classify the same comparison identically", async () => {
136
+ const body = `${query}\nreturn rows[0].analyzed === true;`;
137
+ await fs.writeFile(file, `export default await (async () => {\n${body}\n})();`);
138
+ const evalReport = await checkEval(opts());
139
+ await fs.mkdir(path.join(appDir, "spectest/tests"));
140
+ await fs.writeFile(path.join(appDir, "spectest/tests/comparison.ts"),
141
+ `import { env } from "../index";\nenv.test("comparison", async (ctx) => {\n${body}\n});`);
142
+ const suiteReport = await runTypecheck({ appDir, projectEntry, typecheckDir, run });
143
+ const findings = (report: typeof evalReport) => report.errors
144
+ .filter((e) => e.code === "TS2367" || e.code === "SPECTEST2002")
145
+ .map(({ code, message }) => ({ code, message }));
146
+ expect(findings(evalReport)).toEqual(findings(suiteReport));
147
+ expect(evalReport.blocking?.map((e) => e.code)).toEqual(["SPECTEST2002"]);
148
+ expect(suiteReport.blocking?.map((e) => e.code)).toEqual(["SPECTEST2002"]);
149
+ }, 60_000);
150
+
151
+ test("eval and tests honor the same project strictness", async () => {
152
+ await fs.writeFile(path.join(appDir, "spectest/tsconfig.json"), JSON.stringify({
153
+ compilerOptions: { strict: false, skipLibCheck: true, target: "esnext", module: "esnext", moduleResolution: "bundler" },
154
+ }));
155
+ await fs.writeFile(file, 'const value: number = null; export default value;');
156
+ await fs.writeFile(path.join(appDir, "spectest/null.ts"), 'const value: number = null; export default value;');
157
+ const evalReport = await checkEval(opts());
158
+ const suiteReport = await runTypecheck({ appDir, projectEntry, typecheckDir, run });
159
+ expect(evalReport.errors.some((d) => d.code === "TS2322")).toBe(false);
160
+ expect(suiteReport.errors.some((d) => d.code === "TS2322")).toBe(false);
161
+ expect(evalReport.blocking).toBeUndefined();
162
+ expect(suiteReport.blocking).toBeUndefined();
163
+ }, 30_000);
@@ -0,0 +1,100 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { generatedTsconfig, projectTypecheckConfig, runTypecheck, type TypecheckOptions, type TypecheckReport } from "./typecheck.js";
4
+
5
+ interface EvalTypecheckOptions extends Pick<TypecheckOptions, "appDir" | "typecheckDir" | "run"> {
6
+ file: string;
7
+ projectEntry?: string;
8
+ }
9
+
10
+ /** Describe the globals installed by evalCode, without importing/executing the project. */
11
+ function globals(projectEntry?: string): string {
12
+ return `import type * as SDK from "@specific.dev/spectest";
13
+ ${projectEntry ? `import type project from ${JSON.stringify(projectEntry)};` : ""}
14
+ type EvalContext = ${projectEntry
15
+ ? "typeof project extends SDK.Project<infer S, infer F> ? SDK.TestContext<undefined, S, F> : SDK.TestContext<undefined>"
16
+ : "SDK.TestContext<undefined>"};
17
+ declare global {
18
+ var ctx: EvalContext;
19
+ var expect: typeof SDK.expect;
20
+ var expectRaw: typeof SDK.expectRaw;
21
+ var assert: typeof SDK.assert;
22
+ var state: Record<string, unknown>;
23
+ }
24
+ `;
25
+ }
26
+
27
+ export async function checkEval(opts: EvalTypecheckOptions): Promise<TypecheckReport> {
28
+ const started = Date.now();
29
+ let scratch: string | undefined;
30
+ try {
31
+ scratch = await fs.mkdtemp(path.join(path.dirname(opts.file), "check-"));
32
+ const declarations = path.join(scratch, "globals.d.ts");
33
+ await fs.writeFile(declarations, globals(opts.projectEntry));
34
+ const projectConfig = projectTypecheckConfig(opts);
35
+ const defaults = JSON.parse(generatedTsconfig(opts.appDir, opts.typecheckDir));
36
+ const config = {
37
+ ...(projectConfig ? { extends: projectConfig } : defaults),
38
+ compilerOptions: {
39
+ ...(!projectConfig ? defaults.compilerOptions : {}),
40
+ // Bun runs eval as ESM TypeScript, regardless of the app's build output.
41
+ noEmit: true,
42
+ skipLibCheck: true,
43
+ target: "esnext",
44
+ module: "esnext",
45
+ moduleResolution: "bundler",
46
+ moduleDetection: "force",
47
+ allowImportingTsExtensions: true,
48
+ composite: false,
49
+ incremental: false,
50
+ rootDir: opts.appDir,
51
+ },
52
+ // Project includes/excludes may omit hidden files or include the entire
53
+ // application. Check this snippet, with imports resolved normally.
54
+ files: [opts.file, declarations],
55
+ include: [],
56
+ exclude: [],
57
+ };
58
+ const configFile = path.join(scratch, "tsconfig.json");
59
+ await fs.writeFile(configFile, JSON.stringify(config));
60
+ const report = await runTypecheck({ ...opts, configFile, diagnosticFile: opts.file });
61
+ // Stable, snippet-relative locations; don't expose random temporary paths.
62
+ const normalize = (d: TypecheckReport["errors"][number]) => ({ ...d, file: "eval.ts" });
63
+ return {
64
+ ...report,
65
+ errors: report.errors.map(normalize),
66
+ ...(report.blocking ? { blocking: report.blocking.map(normalize) } : {}),
67
+ };
68
+ } catch (error) {
69
+ // Match managed tests: unavailable/broken checkers report their failure,
70
+ // while only a definite blocking finding prevents execution.
71
+ return {
72
+ status: "failed", errors: [], totalErrors: 0, durationMs: Date.now() - started,
73
+ detail: error instanceof Error ? error.message : String(error),
74
+ };
75
+ } finally {
76
+ if (scratch) await fs.rm(scratch, { recursive: true, force: true });
77
+ }
78
+ }
79
+
80
+ /** The same advisory/blocking policy as a managed test, before module import. */
81
+ export async function evaluateChecked<T>(
82
+ opts: EvalTypecheckOptions,
83
+ log: (text: string) => void,
84
+ evaluate: () => Promise<T>,
85
+ ): Promise<T> {
86
+ const report = await checkEval(opts);
87
+ if (report.status === "failed" || report.status === "skipped") {
88
+ log(`[typecheck] ${report.status}: ${report.detail ?? "unavailable"}\n`);
89
+ } else if (report.totalErrors > 0) {
90
+ log(`[typecheck] ${report.totalErrors} diagnostic(s)${report.blocking?.length ? "" : " (advisory)"}\n`);
91
+ for (const d of report.errors) log(`${d.file}:${d.line}:${d.column}: ${d.code}: ${d.message}\n`);
92
+ if (report.totalErrors > report.errors.length) {
93
+ log(`[typecheck] ${report.totalErrors - report.errors.length} more diagnostic(s)\n`);
94
+ }
95
+ }
96
+ if (report.blocking?.length) {
97
+ throw new Error(`Typecheck: ${report.blocking.length} blocking error(s) in eval — the snippet did not run. Fix the diagnostics above before retrying.`);
98
+ }
99
+ return evaluate();
100
+ }