@specific.dev/spectest 0.91.2 → 0.92.1

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.
@@ -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 & OBJECT_OR_UNION) !== 0) {
400
+ if (t && (t.flags & objectOrUnion) !== 0) {
399
401
  printed.set(i, await project.checker.typeToString(t));
400
402
  }
401
403
  }
package/dist/index.d.ts CHANGED
@@ -590,11 +590,10 @@ export interface ServiceDefinition<H extends Record<string, any> = Record<string
590
590
  * the typecheck on any service declaring a hook, while the same literal
591
591
  * written inline passed. */
592
592
  export type ServicesMap = Record<string, ServiceDefinition<any>>;
593
- /** Awaited return type of a service's `helpers` factory, or `never` if
594
- * the service doesn't ship one. */
595
- type HelpersOf<D> = D extends {
596
- helpers: (...args: any) => infer R;
597
- } ? Awaited<R> : never;
593
+ /** Awaited return type of a service's helpers factory. Check key presence
594
+ * before removing optionality: annotations keep `helpers?`, while plain
595
+ * service literals with no factory must stay out of ctx.svc. */
596
+ type HelpersOf<D> = "helpers" extends keyof D ? [NonNullable<D["helpers"]>] extends [never] ? never : NonNullable<D["helpers"]> extends (...args: any[]) => infer R ? Awaited<R> : never : never;
598
597
  /**
599
598
  * Per-service handles derived from a concrete services map. Only
600
599
  * services that ship a `helpers` factory appear here; `ctx.svc.<name>`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.91.2",
3
+ "version": "0.92.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",