@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/dist/browser.js
CHANGED
|
@@ -1406,7 +1406,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1406
1406
|
}, { wrap: true });
|
|
1407
1407
|
},
|
|
1408
1408
|
async waitForFunction(description, fn, arg, options = {}) {
|
|
1409
|
-
const timeoutMs = options.timeout ??
|
|
1409
|
+
const timeoutMs = options.timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
|
|
1410
1410
|
const intervalMs = options.polling ?? 100;
|
|
1411
1411
|
const src = typeof fn === "string" ? fn : fn.toString();
|
|
1412
1412
|
const t = truncateUtf8(src);
|
package/dist/daemon.js
CHANGED
|
@@ -39,6 +39,7 @@ import { summarizeBuildKit } from "./harness/buildkit-progress.js";
|
|
|
39
39
|
import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
|
|
40
40
|
import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
|
|
41
41
|
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
42
|
+
import { runWrapperRules } from "./harness/wrapper-rules.js";
|
|
42
43
|
import { APP_DIR, WORKSPACE, resolveProjectPath } from "./project-files.js";
|
|
43
44
|
import { isTextualContentType, looksBinary, omittedBody, parseContentLength, } from "./harness/http-body.js";
|
|
44
45
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
@@ -4133,7 +4134,16 @@ async function pollCall(description, fn, opts) {
|
|
|
4133
4134
|
lastIterStartIdx = recorderEventCount();
|
|
4134
4135
|
try {
|
|
4135
4136
|
const v = await fn();
|
|
4136
|
-
|
|
4137
|
+
// Decide on the RAW value. A predicate that hands back a wrapped leaf
|
|
4138
|
+
// (`rows[0].written` off an instrumented query) returns a `Carrier`,
|
|
4139
|
+
// and a carrier around `false` is an object — truthy, and `!== false`.
|
|
4140
|
+
// Without this a poll accepted a condition that was never met and the
|
|
4141
|
+
// test ran on against stale data, which is worse than a timeout: there
|
|
4142
|
+
// is no failure to read (reported 2026-08-30, run_0cjtx0dbzjs5yq6vwefkk,
|
|
4143
|
+
// where `written: false` passed the wait on attempt 1 in 3 ms).
|
|
4144
|
+
// `value` keeps the WRAPPED form so the return still carries provenance.
|
|
4145
|
+
const ready = readRaw(v);
|
|
4146
|
+
if (ready !== null && ready !== undefined && ready !== false) {
|
|
4137
4147
|
value = v;
|
|
4138
4148
|
success = true;
|
|
4139
4149
|
break;
|
|
@@ -5061,7 +5071,11 @@ async function evalCode(code, secrets) {
|
|
|
5061
5071
|
// its own `typescript` in spectest/package.json wins. A project tsconfig.json
|
|
5062
5072
|
// wins over the generated one the same way.
|
|
5063
5073
|
// ────────────────────────────────────────────────────────────────────────
|
|
5064
|
-
|
|
5074
|
+
/** Where the baked compiler lives. Overridable so the typecheck — and the
|
|
5075
|
+
* blocking rules, which always use THIS copy rather than the project's own —
|
|
5076
|
+
* can be driven outside a VM (same convention as
|
|
5077
|
+
* `SPECTEST_COVERAGE_TOOLS_DIR`). */
|
|
5078
|
+
const TYPECHECK_DIR = process.env.SPECTEST_TYPECHECK_DIR ?? "/opt/spectest/typecheck";
|
|
5065
5079
|
/** Cap on errors shipped in the report; `totalErrors` carries the true count. */
|
|
5066
5080
|
const TYPECHECK_ERROR_CAP = 50;
|
|
5067
5081
|
let TYPECHECK = null;
|
|
@@ -5176,7 +5190,17 @@ async function runTypecheck() {
|
|
|
5176
5190
|
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
|
|
5177
5191
|
}
|
|
5178
5192
|
const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
|
|
5179
|
-
|
|
5193
|
+
const wrapper = await runWrapperRulesReport(config);
|
|
5194
|
+
const strip = ({ file, line, column, code, message }) => ({
|
|
5195
|
+
file,
|
|
5196
|
+
line,
|
|
5197
|
+
column,
|
|
5198
|
+
code,
|
|
5199
|
+
message,
|
|
5200
|
+
});
|
|
5201
|
+
const blocking = wrapper.filter((d) => d.blocking).map(strip);
|
|
5202
|
+
const advisory = wrapper.filter((d) => !d.blocking).map(strip);
|
|
5203
|
+
if (errors.length === 0 && wrapper.length === 0) {
|
|
5180
5204
|
// Exit 0 → clean. Non-zero with no *user-file* diagnostics is either
|
|
5181
5205
|
// all-suppressed (still ok from the user's perspective) or a compiler
|
|
5182
5206
|
// crash (config not found, OOM) — surface the latter.
|
|
@@ -5189,7 +5213,51 @@ async function runTypecheck() {
|
|
|
5189
5213
|
}
|
|
5190
5214
|
return { status: "ok", errors: [], totalErrors: 0, durationMs };
|
|
5191
5215
|
}
|
|
5192
|
-
|
|
5216
|
+
// The wrapper findings ride the same list the CLI and the dashboard already
|
|
5217
|
+
// render; `blocking` is a second view of the ones that also fail the run, so
|
|
5218
|
+
// nothing has to learn a new shape to show them. Blocking leads, because
|
|
5219
|
+
// `errors` is capped and the entries that stopped the run must never be the
|
|
5220
|
+
// ones the cap drops.
|
|
5221
|
+
const all = [...blocking, ...advisory, ...errors];
|
|
5222
|
+
return {
|
|
5223
|
+
status: "errors",
|
|
5224
|
+
errors: all.slice(0, TYPECHECK_ERROR_CAP),
|
|
5225
|
+
totalErrors: total + wrapper.length,
|
|
5226
|
+
durationMs,
|
|
5227
|
+
...(blocking.length > 0 ? { blocking } : {}),
|
|
5228
|
+
};
|
|
5229
|
+
}
|
|
5230
|
+
/**
|
|
5231
|
+
* The blocking rules, run against the BAKED compiler whatever the project
|
|
5232
|
+
* pins. Best-effort in every direction: an install that predates the API, a
|
|
5233
|
+
* config the rules cannot open, or a throw from `typescript/unstable/*` all
|
|
5234
|
+
* yield no findings, so a run proceeds exactly as it does today. Only a
|
|
5235
|
+
* definite finding can stop one.
|
|
5236
|
+
*/
|
|
5237
|
+
async function runWrapperRulesReport(config) {
|
|
5238
|
+
const typescriptDir = path.join(TYPECHECK_DIR, "node_modules", "typescript");
|
|
5239
|
+
if (!existsSync(path.join(typescriptDir, "dist", "api", "async", "api.js")))
|
|
5240
|
+
return [];
|
|
5241
|
+
try {
|
|
5242
|
+
const run = await runWrapperRules({
|
|
5243
|
+
typescriptDir,
|
|
5244
|
+
configFile: config,
|
|
5245
|
+
appDir: APP_DIR,
|
|
5246
|
+
readFile: (f) => fs.readFile(f, "utf8"),
|
|
5247
|
+
relative: path.relative,
|
|
5248
|
+
join: path.join,
|
|
5249
|
+
dirname: path.dirname,
|
|
5250
|
+
});
|
|
5251
|
+
if (run.status !== "ok") {
|
|
5252
|
+
console.warn(`[typecheck] wrapper rules ${run.status}: ${run.detail ?? "no detail"}`);
|
|
5253
|
+
return [];
|
|
5254
|
+
}
|
|
5255
|
+
return run.diagnostics;
|
|
5256
|
+
}
|
|
5257
|
+
catch (err) {
|
|
5258
|
+
console.warn(`[typecheck] wrapper rules threw: ${err?.message ?? err}`);
|
|
5259
|
+
return [];
|
|
5260
|
+
}
|
|
5193
5261
|
}
|
|
5194
5262
|
/** Run `job` under a mutual-exclusion slot, refusing if one is held. */
|
|
5195
5263
|
async function exclusive(state, slot, busy, job) {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** A finding, shaped like the `TypecheckError` the report already carries. */
|
|
2
|
+
export interface WrapperDiagnostic {
|
|
3
|
+
/** Path relative to the app dir, matching the advisory diagnostics. */
|
|
4
|
+
file: string;
|
|
5
|
+
line: number;
|
|
6
|
+
column: number;
|
|
7
|
+
code: string;
|
|
8
|
+
message: string;
|
|
9
|
+
/**
|
|
10
|
+
* Whether this finding may fail the run. Only a rule that is true of the
|
|
11
|
+
* RUN, not merely of the declared types, may block — see the header and the
|
|
12
|
+
* note on {@link CODE_TRUTHY}.
|
|
13
|
+
*/
|
|
14
|
+
blocking: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A condition on a wrapped value — always true. **Blocking**, but on different
|
|
18
|
+
* grounds from {@link CODE_EQUALITY}, and the difference is worth knowing.
|
|
19
|
+
*
|
|
20
|
+
* This rule is NOT a proof. Two things can hide a runtime nullish from the
|
|
21
|
+
* compiler: a row generic that overstates a column
|
|
22
|
+
* (`client<{ names: string }>` over a `string_agg` that returns NULL) and
|
|
23
|
+
* TypeScript's deliberately unsound array indexing (`rows[0]` is typed
|
|
24
|
+
* non-optional even when the array is empty). A nullish leaf comes back RAW
|
|
25
|
+
* from `wrapChild`, so in those shapes `if (rows[0]?.names)` really does tell
|
|
26
|
+
* present from absent, and "always true" is false about it — measured against
|
|
27
|
+
* a real suite on 2026-08-30 (`journal-note.ts:200`).
|
|
28
|
+
*
|
|
29
|
+
* It blocks anyway, as a CONVENTION rather than a verdict: *unwrap before
|
|
30
|
+
* branching on a wrapped value*, the same discipline the docs already teach
|
|
31
|
+
* for `===`. What makes that acceptable is that the fix is safe in every case
|
|
32
|
+
* — `?.unwrap()` short-circuits, so it never throws and never changes code
|
|
33
|
+
* that was already correct; it only removes the accident. And the accident is
|
|
34
|
+
* the worst failure this SDK has: a `ctx.poll` predicate that collapses a
|
|
35
|
+
* wrapped `false` to `true` passes on attempt 1, the suite runs on against
|
|
36
|
+
* data that never arrived, and neither the compiler nor the runtime says a
|
|
37
|
+
* word (reported 2026-08-30).
|
|
38
|
+
*
|
|
39
|
+
* The line this does not cross: a rule may demand more of the user's own code,
|
|
40
|
+
* but it may never fail a run over OUR stale types (the patched global
|
|
41
|
+
* `fetch`) or over the compiler's own bad inference (TS2367 across a
|
|
42
|
+
* callback). Those cost the user a fix they cannot make.
|
|
43
|
+
*/
|
|
44
|
+
export declare const CODE_TRUTHY = "SPECTEST2001";
|
|
45
|
+
/**
|
|
46
|
+
* `===`/`!==` between a wrapped value and a plain one — always false/true.
|
|
47
|
+
* **Blocking.** Sound even when a row generic understates nullability: if the
|
|
48
|
+
* leaf is a carrier the comparison is false because an object never equals a
|
|
49
|
+
* primitive, and if it is raw nullish it is false because nullish does not
|
|
50
|
+
* equal the literal either. A nullish literal on the other side is excluded —
|
|
51
|
+
* see {@link equalityIsConstant}.
|
|
52
|
+
*/
|
|
53
|
+
export declare const CODE_EQUALITY = "SPECTEST2002";
|
|
54
|
+
/**
|
|
55
|
+
* Split a printed type on its TOP-LEVEL `|`, leaving nested unions alone
|
|
56
|
+
* (`Carrier<A | B> | undefined` → [`Carrier<A | B>`, `undefined`]). Depth is
|
|
57
|
+
* tracked across every bracket kind because a printed type can hold object
|
|
58
|
+
* literals (`{ a: 1 | 2 }`), tuples and parenthesised function types.
|
|
59
|
+
*/
|
|
60
|
+
export declare function splitUnion(text: string): string[];
|
|
61
|
+
/** One union member that is the primitive carrier. Object/array/response
|
|
62
|
+
* wrappers are deliberately NOT included: they are objects whether or not we
|
|
63
|
+
* wrap them, so a condition on one is not made constant by the wrapper. */
|
|
64
|
+
export declare function isCarrierMember(member: string): boolean;
|
|
65
|
+
export type TypeVerdict =
|
|
66
|
+
/** Every member is a carrier and none is nullish — constant at runtime. */
|
|
67
|
+
"carrier"
|
|
68
|
+
/** A carrier that may also be absent — a real presence test, leave alone. */
|
|
69
|
+
| "nullable-carrier"
|
|
70
|
+
/** Not a wrapper. */
|
|
71
|
+
| "plain";
|
|
72
|
+
/**
|
|
73
|
+
* Classify a printed type for the rules. `unknown`/`any`/an error type read as
|
|
74
|
+
* `plain`: the checker could not say what the value is, and a rule that blocks
|
|
75
|
+
* a run must not guess.
|
|
76
|
+
*/
|
|
77
|
+
export declare function classifyType(text: string | undefined): TypeVerdict;
|
|
78
|
+
/** Source text that denotes a nullish literal. */
|
|
79
|
+
export declare function isNullishLiteral(exprText: string): boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Verdict for a strict `===`/`!==`. Blocking only when exactly one side is a
|
|
82
|
+
* definite carrier: two carriers compare object identity, which is a different
|
|
83
|
+
* mistake and not one this rule can prove constant.
|
|
84
|
+
*
|
|
85
|
+
* A comparison against a NULLISH LITERAL is never constant, whatever the type
|
|
86
|
+
* says. `wrapChild` hands a `null`/`undefined` leaf back RAW, so
|
|
87
|
+
* `row.setting === null` is the correct way to ask whether a column is null —
|
|
88
|
+
* and it answers correctly in both directions. The declared type cannot show
|
|
89
|
+
* this, because a row generic is the caller's own assertion
|
|
90
|
+
* (`client<{ setting: unknown }>`) and routinely understates nullability.
|
|
91
|
+
* Found in a real suite on 2026-08-30 (`document-export-settings.ts:64`),
|
|
92
|
+
* where flagging it would have blocked a passing test.
|
|
93
|
+
*/
|
|
94
|
+
export declare function equalityIsConstant(left: string | undefined, right: string | undefined, leftExpr?: string, rightExpr?: string): boolean;
|
|
95
|
+
/**
|
|
96
|
+
* The real start of a node. A TypeScript node's `pos` is the end of the
|
|
97
|
+
* PREVIOUS node, so it includes the leading whitespace and comments; `tsc`
|
|
98
|
+
* reports the first meaningful character. Without this every column is a few
|
|
99
|
+
* places to the left and a finding above a comment points at the comment.
|
|
100
|
+
*/
|
|
101
|
+
export declare function startOfNode(text: string, pos: number): number;
|
|
102
|
+
/** 1-indexed line/column for a character offset, matching `tsc` output. */
|
|
103
|
+
export declare function lineColumnAt(text: string, pos: number): {
|
|
104
|
+
line: number;
|
|
105
|
+
column: number;
|
|
106
|
+
};
|
|
107
|
+
/** Trim a source snippet for a message: one line, bounded. */
|
|
108
|
+
export declare function snippet(text: string, pos: number, end: number): string;
|
|
109
|
+
export declare function truthyMessage(expr: string, typeText: string): string;
|
|
110
|
+
export declare function equalityMessage(expr: string, typeText: string, negated: boolean): string;
|
|
111
|
+
/** Minimal shape of the bits of the TS 7 API this uses. */
|
|
112
|
+
interface TsNode {
|
|
113
|
+
kind: number;
|
|
114
|
+
pos: number;
|
|
115
|
+
end: number;
|
|
116
|
+
[k: string]: unknown;
|
|
117
|
+
}
|
|
118
|
+
export interface WrapperRuleRun {
|
|
119
|
+
status: "ok" | "skipped" | "failed";
|
|
120
|
+
diagnostics: WrapperDiagnostic[];
|
|
121
|
+
detail?: string;
|
|
122
|
+
durationMs: number;
|
|
123
|
+
}
|
|
124
|
+
/** Candidate positions, and what each one means if the operand is a carrier. */
|
|
125
|
+
interface Candidate {
|
|
126
|
+
node: TsNode;
|
|
127
|
+
kind: "truthy" | "equality";
|
|
128
|
+
/** For an equality, the other side, whose type decides with this one. */
|
|
129
|
+
other?: TsNode;
|
|
130
|
+
negated?: boolean;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Collect the positions worth asking about in one file. Kept separate from the
|
|
134
|
+
* checker so the walk can be reasoned about (and extended) on its own.
|
|
135
|
+
*/
|
|
136
|
+
export declare function collectCandidates(statements: readonly TsNode[], kindName: (n: TsNode) => string, walk: (n: TsNode, visit: (c: TsNode) => void) => void): Candidate[];
|
|
137
|
+
export declare function runWrapperRules(opts: {
|
|
138
|
+
/** Directory of the baked `typescript` package. */
|
|
139
|
+
typescriptDir: string;
|
|
140
|
+
/** Absolute path of the tsconfig to open. */
|
|
141
|
+
configFile: string;
|
|
142
|
+
/** Only files under here produce diagnostics. */
|
|
143
|
+
appDir: string;
|
|
144
|
+
readFile: (p: string) => Promise<string>;
|
|
145
|
+
relative: (from: string, to: string) => string;
|
|
146
|
+
join: (...parts: string[]) => string;
|
|
147
|
+
dirname: (p: string) => string;
|
|
148
|
+
}): Promise<WrapperRuleRun>;
|
|
149
|
+
export {};
|