@specific.dev/spectest 0.67.0 → 0.69.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
@@ -90,6 +90,8 @@ import {
90
90
  sanitizeSegment,
91
91
  } from "./harness/volume-paths.js";
92
92
  import { pollUntilReady } from "./harness/ready-poll.js";
93
+ import { runWrapperRules } from "./harness/wrapper-rules.js";
94
+ import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
93
95
  import { APP_DIR, WORKSPACE, resolveProjectPath } from "./project-files.js";
94
96
  import {
95
97
  isTextualContentType,
@@ -5038,7 +5040,16 @@ async function pollCall<T>(
5038
5040
  lastIterStartIdx = recorderEventCount();
5039
5041
  try {
5040
5042
  const v = await fn();
5041
- if (v !== null && v !== undefined && v !== false) {
5043
+ // Decide on the RAW value. A predicate that hands back a wrapped leaf
5044
+ // (`rows[0].written` off an instrumented query) returns a `Carrier`,
5045
+ // and a carrier around `false` is an object — truthy, and `!== false`.
5046
+ // Without this a poll accepted a condition that was never met and the
5047
+ // test ran on against stale data, which is worse than a timeout: there
5048
+ // is no failure to read (reported 2026-08-30, run_0cjtx0dbzjs5yq6vwefkk,
5049
+ // where `written: false` passed the wait on attempt 1 in 3 ms).
5050
+ // `value` keeps the WRAPPED form so the return still carries provenance.
5051
+ const ready = readRaw(v);
5052
+ if (ready !== null && ready !== undefined && ready !== false) {
5042
5053
  value = v as T;
5043
5054
  success = true;
5044
5055
  break;
@@ -6103,7 +6114,11 @@ async function evalCode(
6103
6114
  // wins over the generated one the same way.
6104
6115
  // ────────────────────────────────────────────────────────────────────────
6105
6116
 
6106
- const TYPECHECK_DIR = "/opt/spectest/typecheck";
6117
+ /** Where the baked compiler lives. Overridable so the typecheck — and the
6118
+ * blocking rules, which always use THIS copy rather than the project's own —
6119
+ * can be driven outside a VM (same convention as
6120
+ * `SPECTEST_COVERAGE_TOOLS_DIR`). */
6121
+ const TYPECHECK_DIR = process.env.SPECTEST_TYPECHECK_DIR ?? "/opt/spectest/typecheck";
6107
6122
  /** Cap on errors shipped in the report; `totalErrors` carries the true count. */
6108
6123
  const TYPECHECK_ERROR_CAP = 50;
6109
6124
 
@@ -6112,7 +6127,7 @@ interface TypecheckError {
6112
6127
  file: string;
6113
6128
  line: number;
6114
6129
  column: number;
6115
- /** `TS2345` etc. */
6130
+ /** `TS2345` from the compiler, or a `SPECTEST….` rule code. */
6116
6131
  code: string;
6117
6132
  message: string;
6118
6133
  }
@@ -6124,6 +6139,19 @@ interface TypecheckReport {
6124
6139
  durationMs: number;
6125
6140
  /** Why the check was skipped / how it failed. */
6126
6141
  detail?: string;
6142
+ /**
6143
+ * Findings that FAIL the run, as opposed to `errors`, which only advise.
6144
+ * A diagnostic qualifies only when it is true of the program that runs
6145
+ * rather than of the declared types alone — see `harness/wrapper-rules.ts`
6146
+ * for why no `tsc` code can be in here and why these can.
6147
+ *
6148
+ * The list is the rollout mechanism as well as the payload: an older SDK
6149
+ * never sends the field, `#[serde(default)]` leaves it empty on the server,
6150
+ * and the gate is inert for every project that has not moved to a version
6151
+ * carrying it. The version a project runs is already its own choice
6152
+ * (`sdk.rs` honours the declared lower bound), so the bump is the consent.
6153
+ */
6154
+ blocking?: TypecheckError[];
6127
6155
  }
6128
6156
 
6129
6157
  let TYPECHECK: Promise<TypecheckReport> | null = null;
@@ -6248,7 +6276,17 @@ async function runTypecheck(): Promise<TypecheckReport> {
6248
6276
  return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
6249
6277
  }
6250
6278
  const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
6251
- if (errors.length === 0) {
6279
+ const wrapper = await runWrapperRulesReport(config);
6280
+ const strip = ({ file, line, column, code, message }: WrapperDiagnostic): TypecheckError => ({
6281
+ file,
6282
+ line,
6283
+ column,
6284
+ code,
6285
+ message,
6286
+ });
6287
+ const blocking = wrapper.filter((d) => d.blocking).map(strip);
6288
+ const advisory = wrapper.filter((d) => !d.blocking).map(strip);
6289
+ if (errors.length === 0 && wrapper.length === 0) {
6252
6290
  // Exit 0 → clean. Non-zero with no *user-file* diagnostics is either
6253
6291
  // all-suppressed (still ok from the user's perspective) or a compiler
6254
6292
  // crash (config not found, OOM) — surface the latter.
@@ -6261,7 +6299,50 @@ async function runTypecheck(): Promise<TypecheckReport> {
6261
6299
  }
6262
6300
  return { status: "ok", errors: [], totalErrors: 0, durationMs };
6263
6301
  }
6264
- return { status: "errors", errors, totalErrors: total, durationMs };
6302
+ // The wrapper findings ride the same list the CLI and the dashboard already
6303
+ // render; `blocking` is a second view of the ones that also fail the run, so
6304
+ // nothing has to learn a new shape to show them. Blocking leads, because
6305
+ // `errors` is capped and the entries that stopped the run must never be the
6306
+ // ones the cap drops.
6307
+ const all = [...blocking, ...advisory, ...errors];
6308
+ return {
6309
+ status: "errors",
6310
+ errors: all.slice(0, TYPECHECK_ERROR_CAP),
6311
+ totalErrors: total + wrapper.length,
6312
+ durationMs,
6313
+ ...(blocking.length > 0 ? { blocking } : {}),
6314
+ };
6315
+ }
6316
+
6317
+ /**
6318
+ * The blocking rules, run against the BAKED compiler whatever the project
6319
+ * pins. Best-effort in every direction: an install that predates the API, a
6320
+ * config the rules cannot open, or a throw from `typescript/unstable/*` all
6321
+ * yield no findings, so a run proceeds exactly as it does today. Only a
6322
+ * definite finding can stop one.
6323
+ */
6324
+ async function runWrapperRulesReport(config: string): Promise<WrapperDiagnostic[]> {
6325
+ const typescriptDir = path.join(TYPECHECK_DIR, "node_modules", "typescript");
6326
+ if (!existsSync(path.join(typescriptDir, "dist", "api", "async", "api.js"))) return [];
6327
+ try {
6328
+ const run = await runWrapperRules({
6329
+ typescriptDir,
6330
+ configFile: config,
6331
+ appDir: APP_DIR,
6332
+ readFile: (f) => fs.readFile(f, "utf8"),
6333
+ relative: path.relative,
6334
+ join: path.join,
6335
+ dirname: path.dirname,
6336
+ });
6337
+ if (run.status !== "ok") {
6338
+ console.warn(`[typecheck] wrapper rules ${run.status}: ${run.detail ?? "no detail"}`);
6339
+ return [];
6340
+ }
6341
+ return run.diagnostics;
6342
+ } catch (err) {
6343
+ console.warn(`[typecheck] wrapper rules threw: ${(err as Error)?.message ?? err}`);
6344
+ return [];
6345
+ }
6265
6346
  }
6266
6347
 
6267
6348
  // ────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,170 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ classifyType,
4
+ equalityIsConstant,
5
+ equalityMessage,
6
+ isCarrierMember,
7
+ isNullishLiteral,
8
+ lineColumnAt,
9
+ snippet,
10
+ splitUnion,
11
+ startOfNode,
12
+ truthyMessage,
13
+ } from "./wrapper-rules.js";
14
+
15
+ describe("splitUnion", () => {
16
+ test("splits only at the top level", () => {
17
+ expect(splitUnion("Carrier<string> | undefined")).toEqual([
18
+ "Carrier<string>",
19
+ "undefined",
20
+ ]);
21
+ // A nested union must survive whole, or its member reads as a bare `A`.
22
+ expect(splitUnion("Carrier<A | B>")).toEqual(["Carrier<A | B>"]);
23
+ expect(splitUnion("Carrier<{ a: 1 | 2 }> | null")).toEqual([
24
+ "Carrier<{ a: 1 | 2 }>",
25
+ "null",
26
+ ]);
27
+ expect(splitUnion("Carrier<(a: 1 | 2) => void>")).toEqual([
28
+ "Carrier<(a: 1 | 2) => void>",
29
+ ]);
30
+ expect(splitUnion("number")).toEqual(["number"]);
31
+ });
32
+ });
33
+
34
+ describe("isCarrierMember", () => {
35
+ test("only the primitive carrier", () => {
36
+ expect(isCarrierMember("Carrier<boolean>")).toBe(true);
37
+ expect(isCarrierMember("Carrier<string>")).toBe(true);
38
+ // Object-shaped wrappers are objects with or without us, so a condition on
39
+ // one is not made constant by the wrapper.
40
+ expect(isCarrierMember("WrappedObject<Row>")).toBe(false);
41
+ expect(isCarrierMember("WrappedArray<Row>")).toBe(false);
42
+ expect(isCarrierMember("WrappedResponse")).toBe(false);
43
+ expect(isCarrierMember("boolean")).toBe(false);
44
+ // Not a generic reference — a value merely *named* Carrier is not one.
45
+ expect(isCarrierMember("Carrier")).toBe(false);
46
+ });
47
+ });
48
+
49
+ describe("classifyType", () => {
50
+ test("a bare carrier is constant", () => {
51
+ expect(classifyType("Carrier<boolean>")).toBe("carrier");
52
+ expect(classifyType("Carrier<number>")).toBe("carrier");
53
+ });
54
+
55
+ test("a nullish union is a presence test and must never be flagged", () => {
56
+ // `wrapChild` returns null/undefined RAW, so `if (row.opt)` correctly
57
+ // tells present from absent. This is the case that would break the rule.
58
+ expect(classifyType("Carrier<string> | undefined")).toBe("nullable-carrier");
59
+ expect(classifyType("Carrier<number> | null")).toBe("nullable-carrier");
60
+ expect(classifyType("Carrier<number> | null | undefined")).toBe("nullable-carrier");
61
+ });
62
+
63
+ test("anything the checker could not pin down reads as plain", () => {
64
+ for (const t of ["any", "unknown", "boolean", "number", "error", undefined, ""]) {
65
+ expect(classifyType(t)).toBe("plain");
66
+ }
67
+ });
68
+
69
+ test("an unrecognised mixed union does not block", () => {
70
+ expect(classifyType("Carrier<string> | number")).toBe("plain");
71
+ expect(classifyType("null | undefined")).toBe("plain");
72
+ });
73
+ });
74
+
75
+ describe("equalityIsConstant", () => {
76
+ test("exactly one carrier side is constant", () => {
77
+ expect(equalityIsConstant("Carrier<number>", "2")).toBe(true);
78
+ expect(equalityIsConstant("number", "Carrier<number>")).toBe(true);
79
+ });
80
+
81
+ test("two carriers compare identity — a different mistake, not proven", () => {
82
+ expect(equalityIsConstant("Carrier<number>", "Carrier<number>")).toBe(false);
83
+ });
84
+
85
+ test("a maybe-absent carrier is not proven either", () => {
86
+ expect(equalityIsConstant("Carrier<string> | undefined", "\"a\"")).toBe(false);
87
+ });
88
+
89
+ test("no carrier at all is not our business", () => {
90
+ expect(equalityIsConstant("number", "2")).toBe(false);
91
+ });
92
+
93
+ test("a nullish literal is never constant — that is the correct null check", () => {
94
+ // `wrapChild` returns a null/undefined leaf RAW, so `row.setting === null`
95
+ // answers correctly in both directions however the generic was declared.
96
+ expect(equalityIsConstant("Carrier<unknown>", "null", "row.setting", "null")).toBe(false);
97
+ expect(equalityIsConstant("Carrier<string>", "undefined", "row.x", "undefined")).toBe(false);
98
+ expect(equalityIsConstant("null", "Carrier<string>", "null", "row.x")).toBe(false);
99
+ // and a real literal on the other side still is
100
+ expect(equalityIsConstant("Carrier<number>", "2", "row.n", "2")).toBe(true);
101
+ });
102
+ });
103
+
104
+ describe("isNullishLiteral", () => {
105
+ test("the three spellings", () => {
106
+ expect(isNullishLiteral("null")).toBe(true);
107
+ expect(isNullishLiteral(" undefined ")).toBe(true);
108
+ expect(isNullishLiteral("void 0")).toBe(true);
109
+ expect(isNullishLiteral("nullish")).toBe(false);
110
+ expect(isNullishLiteral("row.nullable")).toBe(false);
111
+ });
112
+ });
113
+
114
+ describe("lineColumnAt", () => {
115
+ test("1-indexed, matching tsc", () => {
116
+ const text = "a\nbb\nccc";
117
+ expect(lineColumnAt(text, 0)).toEqual({ line: 1, column: 1 });
118
+ expect(lineColumnAt(text, 2)).toEqual({ line: 2, column: 1 });
119
+ expect(lineColumnAt(text, 3)).toEqual({ line: 2, column: 2 });
120
+ expect(lineColumnAt(text, 5)).toEqual({ line: 3, column: 1 });
121
+ });
122
+
123
+ test("out-of-range offsets clamp instead of throwing", () => {
124
+ expect(lineColumnAt("ab", -5)).toEqual({ line: 1, column: 1 });
125
+ expect(lineColumnAt("ab", 99)).toEqual({ line: 1, column: 3 });
126
+ });
127
+ });
128
+
129
+ describe("snippet", () => {
130
+ test("one line, bounded", () => {
131
+ expect(snippet(" row.written ", 0, 15)).toBe("row.written");
132
+ expect(snippet("a\nb", 0, 3)).toBe("a");
133
+ const long = "x".repeat(200);
134
+ expect(snippet(long, 0, 200).length).toBe(58);
135
+ });
136
+ });
137
+
138
+ describe("messages", () => {
139
+ test("name the value, the type and the fix", () => {
140
+ const m = truthyMessage("row.written", "Carrier<boolean>");
141
+ expect(m).toContain("row.written");
142
+ expect(m).toContain("Carrier<boolean>");
143
+ expect(m).toContain(".unwrap()");
144
+ expect(m).toContain("always true");
145
+ });
146
+
147
+ test("a negated comparison is always true, not always false", () => {
148
+ expect(equalityMessage("m.rows", "Carrier<number>", false)).toContain("always false");
149
+ expect(equalityMessage("m.rows", "Carrier<number>", true)).toContain("always true");
150
+ });
151
+ });
152
+
153
+ describe("startOfNode", () => {
154
+ test("skips the leading trivia a node's pos includes", () => {
155
+ // `pos` is the end of the previous node, so it points at the whitespace.
156
+ expect(startOfNode("a; b", 2)).toBe(5);
157
+ expect(startOfNode("a;\n b", 2)).toBe(5);
158
+ });
159
+
160
+ test("skips comments, so a finding never points at the comment above it", () => {
161
+ const text = "a;\n// note\nb";
162
+ expect(startOfNode(text, 2)).toBe(text.indexOf("b"));
163
+ const block = "a;\n/* note */ b";
164
+ expect(startOfNode(block, 2)).toBe(block.indexOf("b"));
165
+ });
166
+
167
+ test("an unterminated comment stops at the end instead of looping", () => {
168
+ expect(startOfNode("a;\n/* open", 2)).toBe(10);
169
+ });
170
+ });