@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/dist/browser.js +1 -1
- package/dist/daemon.js +72 -4
- package/dist/harness/wrapper-rules.d.ts +149 -0
- package/dist/harness/wrapper-rules.js +422 -0
- package/dist/index.js +3 -3
- package/dist/locator-errors.js +1 -1
- package/dist/locator.d.ts +10 -5
- package/dist/locator.js +11 -6
- package/dist/terminal.js +4 -1
- package/package.json +1 -1
- package/src/browser.ts +1 -1
- package/src/daemon.ts +86 -5
- package/src/harness/wrapper-rules.test.ts +170 -0
- package/src/harness/wrapper-rules.ts +547 -0
- package/src/index.ts +3 -3
- package/src/locator-errors.ts +1 -1
- package/src/locator.ts +12 -7
- package/src/terminal.ts +4 -1
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
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|