@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.
@@ -0,0 +1,547 @@
1
+ // Blocking typecheck rules: control flow that a provenance wrapper makes
2
+ // constant.
3
+ //
4
+ // `sdk/src/inspect.ts` returns every primitive leaf of a recorded op as a
5
+ // `Carrier` — an object holding the value, with coercion sinks so template
6
+ // interpolation, arithmetic, `==` and `JSON.stringify` all behave. Two things
7
+ // a carrier cannot rescue, because JavaScript exposes no hook for either:
8
+ //
9
+ // if (row.written) // an object is ALWAYS truthy, even around `false`
10
+ // measured.rows === 2 // an object is NEVER === a primitive
11
+ //
12
+ // Both are constant, and both are silent. The first is the more expensive: a
13
+ // `ctx.poll` predicate that collapses a wrapped `false` to `true` reports
14
+ // success on attempt 1 and the suite runs on against data that never arrived
15
+ // (reported 2026-08-30). `tsc` catches the second as TS2367 and says nothing
16
+ // about the first — a truthiness test on an object type is legal TypeScript.
17
+ //
18
+ // WHY THESE MAY BLOCK A RUN WHEN `tsc`'s OWN DIAGNOSTICS MAY NOT
19
+ // ---------------------------------------------------------------
20
+ // The typecheck report is advisory because a `tsc` verdict is about the
21
+ // DECLARED TYPES, and those can disagree with the program that actually runs,
22
+ // in both directions:
23
+ //
24
+ // - the types go stale: the global `fetch` is patched at runtime to record
25
+ // but keeps its raw `Response` type, so correct code is flagged;
26
+ // - narrowing is unsound: TypeScript does not reset a narrowed `let` across
27
+ // a callback, so `phase === "end"` after `[1,2].forEach(() => phase = "end")`
28
+ // is reported as having no overlap when at runtime it matches.
29
+ //
30
+ // The second is why TS2367 cannot gate a run even though it is exactly the
31
+ // diagnostic that would have caught the reported bug.
32
+ //
33
+ // These rules ask a different question — *is this value a `Carrier`* — which
34
+ // is a fact about the runtime representation, not an inference. Wrapping is
35
+ // unconditional (see inspect.ts), so a value typed `Carrier<T>` IS an object
36
+ // when the line executes. Narrowing cannot turn a carrier into a number, so
37
+ // there is no unsoundness to inherit.
38
+ //
39
+ // {@link CODE_EQUALITY} is true of the run outright. {@link CODE_TRUTHY} is a
40
+ // convention on top of it — read its note for why that still earns a gate, and
41
+ // for the line neither rule crosses.
42
+ //
43
+ // SOUNDNESS CONDITIONS, both load-bearing:
44
+ //
45
+ // 1. `strictNullChecks` must be on. With it off, an optional leaf
46
+ // (`row.opt?: Carrier<string>`) reports as `Carrier<string>` rather than
47
+ // `Carrier<string> | undefined`, and `if (row.opt)` — a legitimate
48
+ // presence test, since `wrapChild` returns nullish RAW — would be flagged
49
+ // as a bug. The driver refuses to run rather than guess.
50
+ // 2. A union carrying `null`/`undefined` is never flagged, for the same
51
+ // reason: that condition distinguishes present from absent and is correct.
52
+ //
53
+ // The type is identified from its printed form rather than from its symbol:
54
+ // one round trip instead of several, and the whole decision stays a pure
55
+ // function over a string, which is what the tests below drive. A user type
56
+ // that happens to be called `Carrier<T>` would also be flagged — and would
57
+ // also be an object at runtime, so the finding stays true.
58
+
59
+ /** A finding, shaped like the `TypecheckError` the report already carries. */
60
+ export interface WrapperDiagnostic {
61
+ /** Path relative to the app dir, matching the advisory diagnostics. */
62
+ file: string;
63
+ line: number;
64
+ column: number;
65
+ code: string;
66
+ message: string;
67
+ /**
68
+ * Whether this finding may fail the run. Only a rule that is true of the
69
+ * RUN, not merely of the declared types, may block — see the header and the
70
+ * note on {@link CODE_TRUTHY}.
71
+ */
72
+ blocking: boolean;
73
+ }
74
+
75
+ /**
76
+ * A condition on a wrapped value — always true. **Blocking**, but on different
77
+ * grounds from {@link CODE_EQUALITY}, and the difference is worth knowing.
78
+ *
79
+ * This rule is NOT a proof. Two things can hide a runtime nullish from the
80
+ * compiler: a row generic that overstates a column
81
+ * (`client<{ names: string }>` over a `string_agg` that returns NULL) and
82
+ * TypeScript's deliberately unsound array indexing (`rows[0]` is typed
83
+ * non-optional even when the array is empty). A nullish leaf comes back RAW
84
+ * from `wrapChild`, so in those shapes `if (rows[0]?.names)` really does tell
85
+ * present from absent, and "always true" is false about it — measured against
86
+ * a real suite on 2026-08-30 (`journal-note.ts:200`).
87
+ *
88
+ * It blocks anyway, as a CONVENTION rather than a verdict: *unwrap before
89
+ * branching on a wrapped value*, the same discipline the docs already teach
90
+ * for `===`. What makes that acceptable is that the fix is safe in every case
91
+ * — `?.unwrap()` short-circuits, so it never throws and never changes code
92
+ * that was already correct; it only removes the accident. And the accident is
93
+ * the worst failure this SDK has: a `ctx.poll` predicate that collapses a
94
+ * wrapped `false` to `true` passes on attempt 1, the suite runs on against
95
+ * data that never arrived, and neither the compiler nor the runtime says a
96
+ * word (reported 2026-08-30).
97
+ *
98
+ * The line this does not cross: a rule may demand more of the user's own code,
99
+ * but it may never fail a run over OUR stale types (the patched global
100
+ * `fetch`) or over the compiler's own bad inference (TS2367 across a
101
+ * callback). Those cost the user a fix they cannot make.
102
+ */
103
+ export const CODE_TRUTHY = "SPECTEST2001";
104
+ /**
105
+ * `===`/`!==` between a wrapped value and a plain one — always false/true.
106
+ * **Blocking.** Sound even when a row generic understates nullability: if the
107
+ * leaf is a carrier the comparison is false because an object never equals a
108
+ * primitive, and if it is raw nullish it is false because nullish does not
109
+ * equal the literal either. A nullish literal on the other side is excluded —
110
+ * see {@link equalityIsConstant}.
111
+ */
112
+ export const CODE_EQUALITY = "SPECTEST2002";
113
+
114
+ /**
115
+ * Split a printed type on its TOP-LEVEL `|`, leaving nested unions alone
116
+ * (`Carrier<A | B> | undefined` → [`Carrier<A | B>`, `undefined`]). Depth is
117
+ * tracked across every bracket kind because a printed type can hold object
118
+ * literals (`{ a: 1 | 2 }`), tuples and parenthesised function types.
119
+ */
120
+ export function splitUnion(text: string): string[] {
121
+ const parts: string[] = [];
122
+ let depth = 0;
123
+ let start = 0;
124
+ for (let i = 0; i < text.length; i += 1) {
125
+ const c = text[i];
126
+ if (c === "<" || c === "(" || c === "[" || c === "{") depth += 1;
127
+ else if (c === ">" || c === ")" || c === "]" || c === "}") depth -= 1;
128
+ else if (c === "|" && depth === 0) {
129
+ parts.push(text.slice(start, i).trim());
130
+ start = i + 1;
131
+ }
132
+ }
133
+ parts.push(text.slice(start).trim());
134
+ return parts.filter((p) => p.length > 0);
135
+ }
136
+
137
+ /** One union member that is the primitive carrier. Object/array/response
138
+ * wrappers are deliberately NOT included: they are objects whether or not we
139
+ * wrap them, so a condition on one is not made constant by the wrapper. */
140
+ export function isCarrierMember(member: string): boolean {
141
+ return /^Carrier<[\s\S]*>$/.test(member.trim());
142
+ }
143
+
144
+ export type TypeVerdict =
145
+ /** Every member is a carrier and none is nullish — constant at runtime. */
146
+ | "carrier"
147
+ /** A carrier that may also be absent — a real presence test, leave alone. */
148
+ | "nullable-carrier"
149
+ /** Not a wrapper. */
150
+ | "plain";
151
+
152
+ /**
153
+ * Classify a printed type for the rules. `unknown`/`any`/an error type read as
154
+ * `plain`: the checker could not say what the value is, and a rule that blocks
155
+ * a run must not guess.
156
+ */
157
+ export function classifyType(text: string | undefined): TypeVerdict {
158
+ if (!text) return "plain";
159
+ const members = splitUnion(text);
160
+ const nullish = members.filter((m) => m === "null" || m === "undefined");
161
+ const rest = members.filter((m) => m !== "null" && m !== "undefined");
162
+ if (rest.length === 0) return "plain";
163
+ // A union mixing a carrier with a non-nullish plain type is not a shape the
164
+ // SDK produces. Blocking a run needs certainty, so an unrecognised shape
165
+ // reads as plain rather than as a finding.
166
+ if (!rest.every((m) => isCarrierMember(m))) return "plain";
167
+ return nullish.length > 0 ? "nullable-carrier" : "carrier";
168
+ }
169
+
170
+ /** Source text that denotes a nullish literal. */
171
+ export function isNullishLiteral(exprText: string): boolean {
172
+ const t = exprText.trim();
173
+ return t === "null" || t === "undefined" || /^void\s+0$/.test(t);
174
+ }
175
+
176
+ /**
177
+ * Verdict for a strict `===`/`!==`. Blocking only when exactly one side is a
178
+ * definite carrier: two carriers compare object identity, which is a different
179
+ * mistake and not one this rule can prove constant.
180
+ *
181
+ * A comparison against a NULLISH LITERAL is never constant, whatever the type
182
+ * says. `wrapChild` hands a `null`/`undefined` leaf back RAW, so
183
+ * `row.setting === null` is the correct way to ask whether a column is null —
184
+ * and it answers correctly in both directions. The declared type cannot show
185
+ * this, because a row generic is the caller's own assertion
186
+ * (`client<{ setting: unknown }>`) and routinely understates nullability.
187
+ * Found in a real suite on 2026-08-30 (`document-export-settings.ts:64`),
188
+ * where flagging it would have blocked a passing test.
189
+ */
190
+ export function equalityIsConstant(
191
+ left: string | undefined,
192
+ right: string | undefined,
193
+ leftExpr = "",
194
+ rightExpr = "",
195
+ ): boolean {
196
+ if (isNullishLiteral(leftExpr) || isNullishLiteral(rightExpr)) return false;
197
+ const l = classifyType(left);
198
+ const r = classifyType(right);
199
+ return (l === "carrier") !== (r === "carrier");
200
+ }
201
+
202
+ /**
203
+ * The real start of a node. A TypeScript node's `pos` is the end of the
204
+ * PREVIOUS node, so it includes the leading whitespace and comments; `tsc`
205
+ * reports the first meaningful character. Without this every column is a few
206
+ * places to the left and a finding above a comment points at the comment.
207
+ */
208
+ export function startOfNode(text: string, pos: number): number {
209
+ let i = Math.max(0, Math.min(pos, text.length));
210
+ while (i < text.length) {
211
+ const c = text[i];
212
+ if (c === " " || c === "\t" || c === "\r" || c === "\n") {
213
+ i += 1;
214
+ } else if (c === "/" && text[i + 1] === "/") {
215
+ const nl = text.indexOf("\n", i);
216
+ i = nl === -1 ? text.length : nl + 1;
217
+ } else if (c === "/" && text[i + 1] === "*") {
218
+ const close = text.indexOf("*/", i + 2);
219
+ i = close === -1 ? text.length : close + 2;
220
+ } else {
221
+ break;
222
+ }
223
+ }
224
+ return i;
225
+ }
226
+
227
+ /** 1-indexed line/column for a character offset, matching `tsc` output. */
228
+ export function lineColumnAt(text: string, pos: number): { line: number; column: number } {
229
+ const clamped = Math.max(0, Math.min(pos, text.length));
230
+ let line = 1;
231
+ let lineStart = 0;
232
+ for (let i = 0; i < clamped; i += 1) {
233
+ if (text.charCodeAt(i) === 10) {
234
+ line += 1;
235
+ lineStart = i + 1;
236
+ }
237
+ }
238
+ return { line, column: clamped - lineStart + 1 };
239
+ }
240
+
241
+ /** Trim a source snippet for a message: one line, bounded. */
242
+ export function snippet(text: string, pos: number, end: number): string {
243
+ const raw = text.slice(Math.max(0, pos), Math.max(0, end)).trim();
244
+ const oneLine = raw.split("\n")[0]?.trim() ?? "";
245
+ return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine;
246
+ }
247
+
248
+ export function truthyMessage(expr: string, typeText: string): string {
249
+ const subject = expr ? `\`${expr}\`` : "This value";
250
+ return (
251
+ `${subject} is \`${typeText}\` — a provenance wrapper, which is an object at ` +
252
+ `runtime, so this condition is always true. A wrapped \`false\`, \`0\` or ` +
253
+ `\`""\` reads as truthy. Call \`.unwrap()\` before testing it.`
254
+ );
255
+ }
256
+
257
+ export function equalityMessage(expr: string, typeText: string, negated: boolean): string {
258
+ const subject = expr ? `\`${expr}\`` : "This value";
259
+ const verdict = negated ? "always true" : "always false";
260
+ return (
261
+ `${subject} is \`${typeText}\` — a provenance wrapper, which is an object at ` +
262
+ `runtime and can never be strictly equal to a plain value, so this ` +
263
+ `comparison is ${verdict}. Call \`.unwrap()\` first, or assert with ` +
264
+ `\`expect(...)\`, which unwraps for you.`
265
+ );
266
+ }
267
+
268
+ // ───────────────────────────────────────────────────────────────────────────
269
+ // Driver
270
+ //
271
+ // Runs against the BAKED TypeScript, never the project's own. The advisory
272
+ // pass deliberately prefers an app-local `typescript` (the project's choice of
273
+ // compiler is its own), but the gate cannot: 7 of the 12 projects measured on
274
+ // 2026-08-30 pin TypeScript 5.x, which has no checker API at all, so keying
275
+ // the rules off the project's compiler would silently switch the gate off for
276
+ // exactly the projects most likely to need it — including the one that
277
+ // reported the bug. The baked compiler is ours and is always present.
278
+ //
279
+ // Everything here is best-effort. The API is `typescript/unstable/*` and can
280
+ // break on a version bump; a throw, a timeout or a missing install yields
281
+ // `skipped`/`failed` and NO diagnostics, so a run proceeds exactly as it does
282
+ // today. Only a definite finding can block, which is what makes an unstable
283
+ // dependency acceptable underneath a gate.
284
+ // ───────────────────────────────────────────────────────────────────────────
285
+
286
+ /** Minimal shape of the bits of the TS 7 API this uses. */
287
+ interface TsNode {
288
+ kind: number;
289
+ pos: number;
290
+ end: number;
291
+ [k: string]: unknown;
292
+ }
293
+ interface TsType {
294
+ flags: number;
295
+ }
296
+ interface TsProject {
297
+ compilerOptions: Record<string, unknown>;
298
+ program: {
299
+ getSourceFileNames(): Promise<readonly string[]> | readonly string[];
300
+ getSourceFile(f: string): Promise<{ statements: TsNode[] } | undefined>;
301
+ };
302
+ checker: {
303
+ getTypeAtLocation(nodes: readonly TsNode[]): Promise<(TsType | undefined)[]>;
304
+ typeToString(t: TsType): Promise<string>;
305
+ };
306
+ }
307
+
308
+ export interface WrapperRuleRun {
309
+ status: "ok" | "skipped" | "failed";
310
+ diagnostics: WrapperDiagnostic[];
311
+ detail?: string;
312
+ durationMs: number;
313
+ }
314
+
315
+ /** `TypeFlags.Object | TypeFlags.Union` — the only shapes a carrier can wear.
316
+ * Filtering on the flags the batch call already returned keeps the printed
317
+ * name (one round trip each) off every ordinary `boolean` condition. */
318
+ const OBJECT_OR_UNION = (1 << 19) | (1 << 20);
319
+
320
+ /** Candidate positions, and what each one means if the operand is a carrier. */
321
+ interface Candidate {
322
+ node: TsNode;
323
+ kind: "truthy" | "equality";
324
+ /** For an equality, the other side, whose type decides with this one. */
325
+ other?: TsNode;
326
+ negated?: boolean;
327
+ }
328
+
329
+ const SYNTAX = {
330
+ If: "IfStatement",
331
+ While: "WhileStatement",
332
+ DoWhile: "DoStatement",
333
+ For: "ForStatement",
334
+ Conditional: "ConditionalExpression",
335
+ Prefix: "PrefixUnaryExpression",
336
+ Binary: "BinaryExpression",
337
+ } as const;
338
+
339
+ /**
340
+ * Collect the positions worth asking about in one file. Kept separate from the
341
+ * checker so the walk can be reasoned about (and extended) on its own.
342
+ */
343
+ export function collectCandidates(
344
+ statements: readonly TsNode[],
345
+ kindName: (n: TsNode) => string,
346
+ walk: (n: TsNode, visit: (c: TsNode) => void) => void,
347
+ ): Candidate[] {
348
+ const all: TsNode[] = [];
349
+ const push = (n: TsNode): void => {
350
+ all.push(n);
351
+ walk(n, push);
352
+ };
353
+ for (const s of statements) push(s);
354
+
355
+ const out: Candidate[] = [];
356
+ for (const n of all) {
357
+ const k = kindName(n);
358
+ if (k === SYNTAX.If || k === SYNTAX.While || k === SYNTAX.DoWhile) {
359
+ const e = n.expression as TsNode | undefined;
360
+ if (e) out.push({ node: e, kind: "truthy" });
361
+ } else if (k === SYNTAX.For || k === SYNTAX.Conditional) {
362
+ const e = n.condition as TsNode | undefined;
363
+ if (e) out.push({ node: e, kind: "truthy" });
364
+ } else if (k === SYNTAX.Prefix) {
365
+ // `!x` — TypeScript's own always-truthy check misses this shape, which
366
+ // is why the rule cannot lean on TS2774.
367
+ if (kindName({ kind: n.operator as number } as TsNode) === "ExclamationToken") {
368
+ const e = n.operand as TsNode | undefined;
369
+ if (e) out.push({ node: e, kind: "truthy" });
370
+ }
371
+ } else if (k === SYNTAX.Binary) {
372
+ const op = kindName({ kind: (n.operatorToken as TsNode)?.kind } as TsNode);
373
+ const left = n.left as TsNode | undefined;
374
+ const right = n.right as TsNode | undefined;
375
+ if (!left || !right) continue;
376
+ if (op === "AmpersandAmpersandToken" || op === "BarBarToken") {
377
+ out.push({ node: left, kind: "truthy" });
378
+ } else if (op === "EqualsEqualsEqualsToken") {
379
+ out.push({ node: left, kind: "equality", other: right, negated: false });
380
+ } else if (op === "ExclamationEqualsEqualsToken") {
381
+ out.push({ node: left, kind: "equality", other: right, negated: true });
382
+ }
383
+ }
384
+ }
385
+ return out;
386
+ }
387
+
388
+ export async function runWrapperRules(opts: {
389
+ /** Directory of the baked `typescript` package. */
390
+ typescriptDir: string;
391
+ /** Absolute path of the tsconfig to open. */
392
+ configFile: string;
393
+ /** Only files under here produce diagnostics. */
394
+ appDir: string;
395
+ readFile: (p: string) => Promise<string>;
396
+ relative: (from: string, to: string) => string;
397
+ join: (...parts: string[]) => string;
398
+ dirname: (p: string) => string;
399
+ }): Promise<WrapperRuleRun> {
400
+ const started = Date.now();
401
+ const done = (r: Omit<WrapperRuleRun, "durationMs">): WrapperRuleRun => ({
402
+ ...r,
403
+ durationMs: Date.now() - started,
404
+ });
405
+
406
+ let api: { close(): void } | undefined;
407
+ try {
408
+ const base = opts.join(opts.typescriptDir, "dist");
409
+ const apiMod = (await import(opts.join(base, "api", "async", "api.js"))) as {
410
+ API: new (o: { cwd: string }) => {
411
+ close(): void;
412
+ updateSnapshot(p: {
413
+ openProjects: string[];
414
+ }): Promise<{ getProjects(): Promise<readonly TsProject[]> }>;
415
+ };
416
+ };
417
+ const utils = (await import(opts.join(base, "ast", "utils.js"))) as {
418
+ formatSyntaxKind(k: number): string;
419
+ };
420
+ const visitor = (await import(opts.join(base, "ast", "visitor.js"))) as {
421
+ visitEachChild(n: unknown, cb: (c: unknown) => unknown, ctx: unknown): unknown;
422
+ };
423
+
424
+ const inst = new apiMod.API({ cwd: opts.dirname(opts.configFile) });
425
+ api = inst;
426
+ const snapshot = await inst.updateSnapshot({ openProjects: [opts.configFile] });
427
+ const project = (await snapshot.getProjects())[0];
428
+ if (!project) return done({ status: "failed", diagnostics: [], detail: "no project" });
429
+
430
+ // Soundness condition 1 — see the header. Without strictNullChecks an
431
+ // optional carrier loses its `| undefined` and every presence test in the
432
+ // suite becomes a finding, so refuse rather than guess.
433
+ // `compilerOptions` is the file's raw options, not the resolved ones, so
434
+ // the `strict` family has to be folded by hand: an explicit
435
+ // `strictNullChecks` wins, otherwise `strict` supplies it.
436
+ const strictNullChecks =
437
+ (project.compilerOptions.strictNullChecks as boolean | undefined) ??
438
+ (project.compilerOptions.strict as boolean | undefined) ??
439
+ false;
440
+ if (strictNullChecks !== true) {
441
+ return done({
442
+ status: "skipped",
443
+ diagnostics: [],
444
+ detail:
445
+ "strictNullChecks is off; a wrapped optional cannot be told from a wrapped value",
446
+ });
447
+ }
448
+
449
+ const kindName = (n: TsNode): string =>
450
+ typeof n?.kind === "number" ? utils.formatSyntaxKind(n.kind) : "";
451
+ const walk = (n: TsNode, visit: (c: TsNode) => void): void => {
452
+ try {
453
+ visitor.visitEachChild(
454
+ n,
455
+ (c) => {
456
+ visit(c as TsNode);
457
+ return c;
458
+ },
459
+ undefined,
460
+ );
461
+ } catch {
462
+ /* a node shape the walker cannot descend — its children are skipped */
463
+ }
464
+ };
465
+
466
+ const names = await project.program.getSourceFileNames();
467
+ const files = names.filter(
468
+ (f) =>
469
+ f.startsWith(`${opts.appDir}/`) && !f.includes("/node_modules/") && !f.endsWith(".d.ts"),
470
+ );
471
+
472
+ const diagnostics: WrapperDiagnostic[] = [];
473
+ for (const file of files) {
474
+ const sf = await project.program.getSourceFile(file);
475
+ if (!sf) continue;
476
+ const candidates = collectCandidates(sf.statements, kindName, walk);
477
+ if (candidates.length === 0) continue;
478
+
479
+ // One batched checker call per file, then a printed name only for the
480
+ // types that could be a wrapper at all.
481
+ const nodes = candidates.flatMap((c) => (c.other ? [c.node, c.other] : [c.node]));
482
+ const types = await project.checker.getTypeAtLocation(nodes);
483
+ const printed = new Map<number, string>();
484
+ for (let i = 0; i < nodes.length; i += 1) {
485
+ const t = types[i];
486
+ if (t && (t.flags & OBJECT_OR_UNION) !== 0) {
487
+ printed.set(i, await project.checker.typeToString(t));
488
+ }
489
+ }
490
+
491
+ const text = await opts.readFile(file);
492
+ const rel = opts.relative(opts.appDir, file);
493
+ let idx = 0;
494
+ for (const c of candidates) {
495
+ const leftText = printed.get(idx);
496
+ idx += 1;
497
+ const rightText = c.other ? printed.get(idx) : undefined;
498
+ if (c.other) idx += 1;
499
+
500
+ const start = startOfNode(text, c.node.pos);
501
+ const where = lineColumnAt(text, start);
502
+ const expr = snippet(text, start, c.node.end);
503
+ if (c.kind === "truthy") {
504
+ if (classifyType(leftText) !== "carrier") continue;
505
+ diagnostics.push({
506
+ file: rel,
507
+ ...where,
508
+ code: CODE_TRUTHY,
509
+ message: truthyMessage(expr, leftText as string),
510
+ blocking: true,
511
+ });
512
+ continue;
513
+ }
514
+ const otherStart = startOfNode(text, (c.other as TsNode).pos);
515
+ const otherExpr = snippet(text, otherStart, (c.other as TsNode).end);
516
+ if (!equalityIsConstant(leftText, rightText, expr, otherExpr)) continue;
517
+ const carrierIsLeft = classifyType(leftText) === "carrier";
518
+ const node = carrierIsLeft ? c.node : (c.other as TsNode);
519
+ const at = carrierIsLeft ? start : otherStart;
520
+ diagnostics.push({
521
+ file: rel,
522
+ ...lineColumnAt(text, at),
523
+ code: CODE_EQUALITY,
524
+ message: equalityMessage(
525
+ snippet(text, at, node.end),
526
+ (carrierIsLeft ? leftText : rightText) as string,
527
+ c.negated === true,
528
+ ),
529
+ blocking: true,
530
+ });
531
+ }
532
+ }
533
+ return done({ status: "ok", diagnostics });
534
+ } catch (err) {
535
+ return done({
536
+ status: "failed",
537
+ diagnostics: [],
538
+ detail: (err as Error)?.message ?? String(err),
539
+ });
540
+ } finally {
541
+ try {
542
+ api?.close();
543
+ } catch {
544
+ /* the session is already gone */
545
+ }
546
+ }
547
+ }
package/src/index.ts CHANGED
@@ -2900,7 +2900,7 @@ function buildLocatorMatchers(
2900
2900
  // The last error a probe threw, kept for the failure message: a matcher
2901
2901
  // whose read waits for the element (textContent, inputValue, isEnabled …)
2902
2902
  // reports a missing element as a playwright timeout, and that — not
2903
- // "got <error: Timeout 5000ms exceeded …>" — is the sentence to fail with.
2903
+ // "got <error: Timeout 10000ms exceeded …>" — is the sentence to fail with.
2904
2904
  let probeError: unknown;
2905
2905
  for (;;) {
2906
2906
  let satisfied: boolean;
@@ -2939,8 +2939,8 @@ function buildLocatorMatchers(
2939
2939
  }
2940
2940
  if (Date.now() >= deadline) {
2941
2941
  // The deadline, not the stopwatch: every one of these polled until it
2942
- // ran out, and "(waited 5s)" is both what the author set and the same
2943
- // number twice in a row — a measured 4_987ms is neither.
2942
+ // ran out, and "(waited 10s)" is both what the author set and the same
2943
+ // number twice in a row — a measured 9_987ms is neither.
2944
2944
  const msg = (await elementFailure(probeError)) ??
2945
2945
  `${describe(actual)} (waited ${formatWaited(budget)})`;
2946
2946
  const sourceSeq = await probe.settle(matcher, Date.now() - started, msg, settleOpts);
@@ -1,7 +1,7 @@
1
1
  // Readable failures for locator steps.
2
2
  //
3
3
  // Playwright reports every unmet actionability wait the same way: a
4
- // `TimeoutError` whose message is "Timeout 5000ms exceeded." with the real
4
+ // `TimeoutError` whose message is "Timeout 10000ms exceeded." with the real
5
5
  // story — did the element exist at all? was it disabled? did something cover
6
6
  // it? — buried in a call log below it. A test that clicks a button that is not
7
7
  // on the page therefore fails with a sentence about OUR deadline, which reads
package/src/locator.ts CHANGED
@@ -32,11 +32,16 @@ import { resolveExistingProjectPath } from "./project-files.js";
32
32
  import { truncateUtf8 } from "./recorder.js";
33
33
 
34
34
  /** Default deadline for a locator action/read's target to become actionable.
35
- * Playwright's own default is 30s — far too slow-failing for tests; 5s
36
- * matches the pre-Playwright behavior. A per-call `{ timeout }` overrides it;
37
- * `undefined` falls through to the context default (also set to this in
38
- * browser.ts's `newViewContext`). Navigations keep a longer deadline. */
39
- export const DEFAULT_ACTION_TIMEOUT_MS = 5_000;
35
+ * Playwright's own default is 30s — far too slow-failing for tests. This was
36
+ * 5s (the pre-Playwright behavior) until 2026-09-03, and 5s turned out to be
37
+ * about one page load: a real app under a full VM fan-out took 1.8-2.0s for a
38
+ * full navigation plus its client data chain on a quiet box, and 4x that on
39
+ * the tail, so a step that was correct and merely late failed. 10s still
40
+ * fails fast enough to be useful and leaves room for the tail. A per-call
41
+ * `{ timeout }` overrides it; `undefined` falls through to the context
42
+ * default (also set to this in browser.ts's `newViewContext`). Navigations
43
+ * keep a longer deadline. */
44
+ export const DEFAULT_ACTION_TIMEOUT_MS = 10_000;
40
45
 
41
46
  // Brand + chain carrier. Both are `Symbol.for` keys so `JSON.stringify` drops
42
47
  // them (locators are never serialized) while runtime code can still detect a
@@ -656,7 +661,7 @@ export interface Locator {
656
661
  /** Explicit strict-mode opt-out: the i-th resolved element (0-based). */
657
662
  nth(index: number): Locator;
658
663
 
659
- // ── Actions (auto-wait; default 5s, `{ timeout }` overrides) ──────────
664
+ // ── Actions (auto-wait; default 10s, `{ timeout }` overrides) ─────────
660
665
  click(opts?: ClickOptions): Promise<void>;
661
666
  dblclick(opts?: ClickOptions): Promise<void>;
662
667
  /** Touch-tap (mobile sessions only; throws on desktop). `duration`
@@ -761,7 +766,7 @@ export function makeLocator(
761
766
  };
762
767
 
763
768
  // Every terminal op runs through this: playwright's actionability timeouts
764
- // say "Timeout 5000ms exceeded" and hide what actually went wrong in a call
769
+ // say "Timeout 10000ms exceeded" and hide what actually went wrong in a call
765
770
  // log, so they are rewritten into a sentence naming the element and its
766
771
  // state (see locator-errors.ts), and a failure that found NO element asks
767
772
  // the page what it does hold. Applied INSIDE `pageOp`, so the recorded step
package/src/terminal.ts CHANGED
@@ -32,6 +32,9 @@ import { recordTerminalStep, reserveEvent, truncateUtf8 } from "./recorder.js";
32
32
  import type { EventReservation } from "./recorder.js";
33
33
  import type { TerminalStepAction } from "./recorder.js";
34
34
  import { wrap } from "./inspect.js";
35
+ // One default deadline for "wait until the app shows it", whichever surface
36
+ // the author is watching — see the constant for why it is what it is.
37
+ import { DEFAULT_ACTION_TIMEOUT_MS } from "./locator.js";
35
38
  import type { Wrapped } from "./inspect.js";
36
39
 
37
40
  export interface TerminalOpts {
@@ -614,7 +617,7 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
614
617
  matcher: string | RegExp | ((screen: string, output: string) => unknown),
615
618
  waitOpts?: { timeoutMs?: number; intervalMs?: number },
616
619
  ) {
617
- const timeoutMs = waitOpts?.timeoutMs ?? 5_000;
620
+ const timeoutMs = waitOpts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS;
618
621
  const intervalMs = waitOpts?.intervalMs ?? 100;
619
622
  const t = Date.now();
620
623
  const resv = reserveEvent();