@specific.dev/spectest 0.66.0 → 0.68.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.
Files changed (40) hide show
  1. package/dist/browser.js +42 -1
  2. package/dist/components/supabase.d.ts +0 -14
  3. package/dist/components/supabase.js +2 -8
  4. package/dist/daemon.js +155 -35
  5. package/dist/harness/intercept.d.ts +22 -0
  6. package/dist/harness/intercept.js +29 -0
  7. package/dist/harness/wrapper-rules.d.ts +149 -0
  8. package/dist/harness/wrapper-rules.js +422 -0
  9. package/dist/index.d.ts +52 -16
  10. package/dist/index.js +76 -17
  11. package/dist/locator-errors.d.ts +19 -10
  12. package/dist/locator-errors.js +80 -20
  13. package/dist/locator-hints.d.ts +96 -0
  14. package/dist/locator-hints.js +403 -0
  15. package/dist/locator.d.ts +22 -0
  16. package/dist/locator.js +63 -9
  17. package/dist/page-snapshot.d.ts +42 -0
  18. package/dist/page-snapshot.js +149 -0
  19. package/dist/recorder.d.ts +16 -0
  20. package/dist/text-match.d.ts +39 -0
  21. package/dist/text-match.js +239 -0
  22. package/package.json +1 -1
  23. package/src/browser.ts +43 -1
  24. package/src/components/supabase.ts +2 -20
  25. package/src/daemon.ts +171 -34
  26. package/src/harness/intercept.test.ts +36 -0
  27. package/src/harness/intercept.ts +40 -0
  28. package/src/harness/wrapper-rules.test.ts +170 -0
  29. package/src/harness/wrapper-rules.ts +547 -0
  30. package/src/index.ts +159 -32
  31. package/src/locator-errors.test.ts +99 -11
  32. package/src/locator-errors.ts +98 -19
  33. package/src/locator-hints.test.ts +188 -0
  34. package/src/locator-hints.ts +514 -0
  35. package/src/locator.ts +72 -9
  36. package/src/page-snapshot.test.ts +100 -0
  37. package/src/page-snapshot.ts +180 -0
  38. package/src/recorder.ts +16 -0
  39. package/src/text-match.test.ts +132 -0
  40. package/src/text-match.ts +285 -0
package/dist/browser.js CHANGED
@@ -34,6 +34,7 @@ import { fileURLToPath } from "node:url";
34
34
  import { generateId } from "./ids.js";
35
35
  import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
36
36
  import { wrap } from "./inspect.js";
37
+ import { capturePageStructure } from "./page-snapshot.js";
37
38
  import { describeUrlPattern, matchesUrl } from "./url-match.js";
38
39
  import { attachBrowserCoverage, browserCoverageActive, harvestBrowserCoverage, } from "./browser-coverage.js";
39
40
  import { attachBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, desktopStrategy, makeLocator, mobileStrategy, } from "./locator.js";
@@ -1122,6 +1123,37 @@ function buildBackend(holder, recorder, buildOpts) {
1122
1123
  // when rrweb's load-deferred full snapshot is most likely to be
1123
1124
  // missing from the chunk we're about to drain (see `drainExpr`).
1124
1125
  let lastDrainUrl = null;
1126
+ // Failed steps that captured their page. Bounded per session per case: a
1127
+ // test that catches its own locator errors in a loop must not pay for a
1128
+ // capture on each one, and three pages is already more than a reader will
1129
+ // open. `ctx.poll` needs no bookkeeping here — it truncates the events of
1130
+ // every superseded attempt, and each capture rides its own event.
1131
+ let pageCaptures = 0;
1132
+ const PAGE_CAPTURE_LIMIT = 3;
1133
+ /** The page behind a failed step, for its event (see page-snapshot.ts).
1134
+ * Best-effort and quiet: this runs while an error is already on its way
1135
+ * up, and must never become the failure the author sees. */
1136
+ async function failedPageStructure(action, error) {
1137
+ // No recorder means nothing is recording this op (an eval, a library
1138
+ // caller), so there is no event for the capture to ride out on.
1139
+ if (!recorder || recordingEnded || viewClosed)
1140
+ return undefined;
1141
+ if (pageCaptures >= PAGE_CAPTURE_LIMIT)
1142
+ return undefined;
1143
+ pageCaptures += 1;
1144
+ try {
1145
+ return await capturePageStructure(holder.page, {
1146
+ session: recorder.sessionName ?? "",
1147
+ // A desktop view has no device preset; a phone-emulated one does.
1148
+ kind: holder.device ? "mobile" : "browser",
1149
+ action,
1150
+ error,
1151
+ });
1152
+ }
1153
+ catch {
1154
+ return undefined;
1155
+ }
1156
+ }
1125
1157
  /** Session provenance stamped on every recorded op: which replay player
1126
1158
  * the step belongs to, which named browser it acted on, and where in
1127
1159
  * the player to seek (`endT`, in rrweb's clock). */
@@ -1203,12 +1235,17 @@ function buildBackend(holder, recorder, buildOpts) {
1203
1235
  catch (err) {
1204
1236
  const e = err;
1205
1237
  const endT = Date.now();
1238
+ const error = e?.message ?? String(err);
1239
+ // Captured after the clock stops, so the diagnostic is not billed to
1240
+ // the step, and before the event is recorded, so it rides it.
1241
+ const pageStructure = await failedPageStructure(action, error);
1206
1242
  recordBrowser({
1207
1243
  action,
1208
1244
  ...fields,
1209
1245
  ...sessionFields(endT),
1210
1246
  durationMs: endT - t,
1211
- error: e?.message ?? String(err),
1247
+ error,
1248
+ ...(pageStructure ? { pageStructure } : {}),
1212
1249
  }, resv);
1213
1250
  // Still try to drain — the failure itself may have produced
1214
1251
  // useful rrweb events (mutations from a half-loaded page, etc.).
@@ -1536,12 +1573,16 @@ function buildBackend(holder, recorder, buildOpts) {
1536
1573
  // where the matcher started polling, since that's what `tOffsetMs` means
1537
1574
  // everywhere else (the ops that reserve up front stamp their start).
1538
1575
  const endT = Date.now();
1576
+ // A matcher that ran out of budget is the other half of a failed
1577
+ // browser step, and the page it settled on is the same evidence.
1578
+ const pageStructure = error ? await failedPageStructure(action, error) : undefined;
1539
1579
  const seq = recordBrowser({
1540
1580
  action,
1541
1581
  ...fields,
1542
1582
  ...sessionFields(endT),
1543
1583
  durationMs: waitedMs,
1544
1584
  ...(error ? { error } : {}),
1585
+ ...(pageStructure ? { pageStructure } : {}),
1545
1586
  }, reserveBackdated(waitedMs));
1546
1587
  // Drain the rrweb the page buffered while the matcher waited into this
1547
1588
  // step's chunk, so `settledTarget` has bounds to seek into.
@@ -33,20 +33,6 @@ export interface SupabaseOptions {
33
33
  storage?: boolean;
34
34
  /** Include Realtime (`<name>-realtime`). Default `true`. */
35
35
  realtime?: boolean;
36
- /**
37
- * Edge functions. `isolation` picks how they run on the edge runtime:
38
- * `"shared"` (default) loads every function into **one** isolate, so a
39
- * shared module graph (`_shared/`, npm packages) is loaded once rather than
40
- * once per function — measured on a 51-function project as 230 MB against
41
- * 3.6 GB. Hosted Supabase runs one isolate per function, and the one thing
42
- * that differs is module-level state: a singleton in `_shared` is one
43
- * object for all functions here, one per function there. `"per-function"`
44
- * runs it the hosted way. Either way the runtime, the request each
45
- * function sees and the per-function `verify_jwt` are the same.
46
- */
47
- functions?: {
48
- isolation?: "shared" | "per-function";
49
- };
50
36
  /**
51
37
  * Also serve the gateway over **HTTPS** at `https://<hostname>` via the
52
38
  * daemon's CA-trusted TLS reverse proxy (in addition to the plain
@@ -698,9 +698,6 @@ async function validJwt(token: string): Promise<boolean> {
698
698
  }
699
699
  }
700
700
 
701
- /** How the functions run: every function in one isolate (the default), or
702
- * one isolate per function as hosted Supabase does — see sharedWorker. */
703
- const ISOLATION = Deno.env.get("FUNCTIONS_ISOLATION") === "per-function" ? "per-function" : "shared";
704
701
  /** Where the generated entry of the shared isolate lives — outside the repo
705
702
  * mount, which is read-only and the user's checkout, and NOT under /tmp:
706
703
  * the runtime gives its workers an in-memory /tmp, so a file the main
@@ -780,7 +777,8 @@ async function stdServerUrls(dir: string, out: Set<string>): Promise<void> {
780
777
  // what differs from hosted Supabase is that module-level state is one
781
778
  // object for every function rather than one per function.
782
779
  //
783
- // It stands in only where it can be exact. A function with its own
780
+ // There is deliberately no opt-out: it stands in wherever it can be exact,
781
+ // and where it cannot the per-function isolates are used. A function with its own
784
782
  // deno.json / import map (per-function resolution the one config cannot
785
783
  // express), a functions/deno.jsonc (comments; not merged) or a config that
786
784
  // names an importMap keep the per-function isolates, and so does a shared
@@ -929,7 +927,6 @@ async function writeShared(names: string[]): Promise<string | null> {
929
927
  /** The shared isolate (pooled by the runtime under SHARED_DIR), or null when
930
928
  * the functions run one isolate each — decided once, with the reason logged. */
931
929
  async function sharedWorker(): Promise<any | null> {
932
- if (ISOLATION !== "shared") return null;
933
930
  if (!sharedSpec) {
934
931
  sharedSpec = (async () => {
935
932
  const names = await functionNames();
@@ -1729,9 +1726,6 @@ export function supabase(opts = {}) {
1729
1726
  JWT_SECRET: jwtSecret,
1730
1727
  // Where the router looks for `<name>/`, inside the repo mount.
1731
1728
  FUNCTIONS_DIR: fnServePath,
1732
- // One isolate for all functions (default) or one each — see the
1733
- // `functions` option and the router's shared-isolate notes.
1734
- FUNCTIONS_ISOLATION: opts.functions?.isolation ?? "shared",
1735
1729
  // The default a function inherits when the config says nothing:
1736
1730
  // reject an unauthenticated call, as hosted Supabase does.
1737
1731
  VERIFY_JWT: "true",
package/dist/daemon.js CHANGED
@@ -39,10 +39,11 @@ 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";
45
- import { InterceptRegistry, runChain, } from "./harness/intercept.js";
46
+ import { InterceptRegistry, parseTarget, runChain, } from "./harness/intercept.js";
46
47
  import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
47
48
  import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, wildcardSuffix, } from "./harness/hostmatch.js";
48
49
  import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, certEntries, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
@@ -2301,35 +2302,61 @@ function ingressClaimsHostname(hostname) {
2301
2302
  }
2302
2303
  return false;
2303
2304
  }
2304
- /** Record one request an interceptor saw, nested under its `intercept` step. */
2305
+ /** Record one request an interceptor saw, nested under its `intercept` step.
2306
+ *
2307
+ * A forced 503 is the step doing exactly what its description says, so it is
2308
+ * **not** marked failed — reddening the outcome the test asked for trains the
2309
+ * reader to ignore the colour. Only a handler that threw is marked, because
2310
+ * that 500 is a bug in the middleware rather than the outage it stands for. */
2305
2311
  function recordInterceptedRequest(it, rec, durationMs) {
2306
2312
  const parentSeq = INTERCEPT_STEP_SEQ.get(it.id);
2307
2313
  if (parentSeq === undefined || !isRecording())
2308
2314
  return;
2309
- const by = rec.answeredBy === "handler"
2310
- ? "answered by the interceptor"
2311
- : rec.answeredBy === "modified"
2312
- ? "upstream answer replaced by the interceptor"
2313
- : "passed through to the upstream";
2315
+ const by = rec.threw
2316
+ ? "the interceptor threw — 500 from spectest, not from your handler"
2317
+ : rec.answeredBy === "handler"
2318
+ ? "answered by the interceptor"
2319
+ : rec.answeredBy === "modified"
2320
+ ? "upstream answer replaced by the interceptor"
2321
+ : "passed through to the upstream";
2314
2322
  recordStep({
2315
2323
  kind: "intercept-request",
2316
2324
  parentSeq,
2317
2325
  title: `${rec.method} ${rec.path} → ${rec.status}`,
2318
- status: rec.status >= 500 && rec.answeredBy !== "upstream" ? "failed" : "passed",
2326
+ status: rec.threw ? "failed" : "passed",
2327
+ // Method, path and status are already the title; the one thing the row
2328
+ // cannot say for itself is who produced that status.
2319
2329
  blocks: [
2320
- {
2321
- type: "kv",
2322
- rows: [
2323
- { label: "Host", value: it.hostname },
2324
- { label: "Request", value: `${rec.method} ${rec.path}` },
2325
- { label: "Status", value: String(rec.status) },
2326
- { label: "Answered", value: by },
2327
- ],
2328
- },
2330
+ { type: "kv", rows: [{ label: "Answered", value: by, error: rec.threw === true }] },
2329
2331
  ],
2330
2332
  durationMs,
2331
2333
  });
2332
2334
  }
2335
+ /** The handler's own source, which is what the interceptor actually does.
2336
+ *
2337
+ * Free and impossible to let rot: Bun runs the project's TypeScript
2338
+ * directly, so `toString()` is the text the author wrote. It is the step's
2339
+ * body, under the description — a mechanism the reader can check against
2340
+ * the intent the description claims. A handler long enough to be a wall of
2341
+ * code folds into a disclosure instead of pushing the requests off screen.
2342
+ */
2343
+ function handlerBlocks(handler) {
2344
+ let src;
2345
+ try {
2346
+ src = String(handler);
2347
+ }
2348
+ catch {
2349
+ return [];
2350
+ }
2351
+ const code = { type: "code", lang: "ts", label: "Handler", code: src };
2352
+ return src.split("\n").length > HANDLER_FOLD_LINES
2353
+ ? [{ type: "details", summary: "Handler", blocks: [code] }]
2354
+ : [code];
2355
+ }
2356
+ /** Past this many lines the handler source is folded away. Chosen so the
2357
+ * shapes this feature is for — a one-line forced status, a short
2358
+ * fail-twice-then-pass — always show, and only a real program hides. */
2359
+ const HANDLER_FOLD_LINES = 15;
2333
2360
  /**
2334
2361
  * Put middleware in front of a hostname the ingress serves — the
2335
2362
  * implementation behind `ctx.intercept`.
@@ -2338,15 +2365,19 @@ function recordInterceptedRequest(it, rec, durationMs) {
2338
2365
  * the daemon (DNS does not point here), so the interceptor could only be
2339
2366
  * silent — and silence is the failure mode this whole layer is designed
2340
2367
  * against. The message names the two ways to get a route.
2368
+ *
2369
+ * The `description` is required because it is the step's whole title in the
2370
+ * timeline and in the CLI's failure detail: it is what tells a reader why
2371
+ * the UI under test went to its error state. The handler source says how;
2372
+ * only the author can say what it means.
2341
2373
  */
2342
- function registerInterceptor(hostname, pathOrHandler, maybeHandler) {
2343
- const path = typeof pathOrHandler === "string" ? pathOrHandler : undefined;
2344
- const handler = typeof pathOrHandler === "function" ? pathOrHandler : maybeHandler;
2345
- if (typeof hostname !== "string" || hostname.length === 0) {
2346
- throw new Error("ctx.intercept: a hostname is required");
2347
- }
2348
- const host = hostname.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
2349
- if (!handler) {
2374
+ function registerInterceptor(target, description, handler) {
2375
+ const { hostname: host, path } = parseTarget(target);
2376
+ if (typeof description !== "string" || description.trim() === "") {
2377
+ throw new Error(`ctx.intercept(${JSON.stringify(target)}): a description is required — what the ` +
2378
+ `environment now does, as the timeline reads it, e.g. "the sync endpoint returns 503".`);
2379
+ }
2380
+ if (typeof handler !== "function") {
2350
2381
  throw new Error("ctx.intercept: a handler (req, next) => Response is required");
2351
2382
  }
2352
2383
  if (!ingressClaimsHostname(host)) {
@@ -2357,19 +2388,41 @@ function registerInterceptor(hostname, pathOrHandler, maybeHandler) {
2357
2388
  }
2358
2389
  const resv = reserveEvent();
2359
2390
  const it = INTERCEPTORS.register(host, path, handler);
2391
+ const where = `${host}${it.path === "/" ? "" : it.path}`;
2360
2392
  const seq = recordStep({
2361
2393
  kind: "intercept",
2362
- title: `intercept ${host}${it.path === "/" ? "" : it.path}`,
2394
+ // The description alone. The pill already says INTERCEPT, and the
2395
+ // target is in the panel — a row that repeats both spends its width
2396
+ // on what the reader can already see and none of it on the one thing
2397
+ // only the author knows.
2398
+ title: description.trim(),
2363
2399
  blocks: [
2400
+ ...handlerBlocks(handler),
2364
2401
  {
2365
- type: "kv",
2366
- rows: [
2367
- { label: "Host", value: host },
2368
- { label: "Path", value: it.path === "/" ? "/ (every path)" : `${it.path} and below` },
2402
+ // The target IS the summary line, so the closed disclosure still
2403
+ // says where the interceptor sits — a generic "Target" label
2404
+ // would spend the one visible line saying nothing.
2405
+ type: "details",
2406
+ summary: where,
2407
+ blocks: [
2408
+ {
2409
+ type: "kv",
2410
+ rows: [
2411
+ {
2412
+ label: "Matches",
2413
+ value: it.path === "/" ? "every path on this host" : `${it.path} and below`,
2414
+ },
2415
+ {
2416
+ label: "Until",
2417
+ value: it.scope === undefined ? "remove()" : "the end of this test",
2418
+ },
2419
+ ],
2420
+ },
2369
2421
  ],
2370
2422
  },
2371
2423
  ],
2372
- durationMs: 0,
2424
+ // No duration: this is a marker for the moment the interceptor went
2425
+ // up, and "(0 ms)" reads as a step that did nothing.
2373
2426
  }, resv);
2374
2427
  if (seq !== undefined)
2375
2428
  INTERCEPT_STEP_SEQ.set(it.id, seq);
@@ -4081,7 +4134,16 @@ async function pollCall(description, fn, opts) {
4081
4134
  lastIterStartIdx = recorderEventCount();
4082
4135
  try {
4083
4136
  const v = await fn();
4084
- if (v !== null && v !== undefined && v !== false) {
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) {
4085
4147
  value = v;
4086
4148
  success = true;
4087
4149
  break;
@@ -5009,7 +5071,11 @@ async function evalCode(code, secrets) {
5009
5071
  // its own `typescript` in spectest/package.json wins. A project tsconfig.json
5010
5072
  // wins over the generated one the same way.
5011
5073
  // ────────────────────────────────────────────────────────────────────────
5012
- const TYPECHECK_DIR = "/opt/spectest/typecheck";
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";
5013
5079
  /** Cap on errors shipped in the report; `totalErrors` carries the true count. */
5014
5080
  const TYPECHECK_ERROR_CAP = 50;
5015
5081
  let TYPECHECK = null;
@@ -5124,7 +5190,17 @@ async function runTypecheck() {
5124
5190
  return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
5125
5191
  }
5126
5192
  const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
5127
- if (errors.length === 0) {
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) {
5128
5204
  // Exit 0 → clean. Non-zero with no *user-file* diagnostics is either
5129
5205
  // all-suppressed (still ok from the user's perspective) or a compiler
5130
5206
  // crash (config not found, OOM) — surface the latter.
@@ -5137,7 +5213,51 @@ async function runTypecheck() {
5137
5213
  }
5138
5214
  return { status: "ok", errors: [], totalErrors: 0, durationMs };
5139
5215
  }
5140
- return { status: "errors", errors, totalErrors: total, durationMs };
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
+ }
5141
5261
  }
5142
5262
  /** Run `job` under a mutual-exclusion slot, refusing if one is held. */
5143
5263
  async function exclusive(state, slot, busy, job) {
@@ -48,6 +48,12 @@ export interface InterceptedRequest {
48
48
  * upstream via `next()` untouched (`upstream`), or the upstream's answer
49
49
  * replaced by the interceptor after `next()` (`modified`). */
50
50
  answeredBy: "handler" | "upstream" | "modified";
51
+ /** The handler threw (or returned something that is not a `Response`), so
52
+ * the 500 below is a bug in the test's own middleware rather than the
53
+ * outage it was asked to produce. The distinction cannot be recovered
54
+ * from the status: a deliberate 500 and a crashed handler are the same
55
+ * number, and only this tells the timeline which one to mark as wrong. */
56
+ threw?: boolean;
51
57
  }
52
58
  export interface Interceptor {
53
59
  id: number;
@@ -61,6 +67,22 @@ export interface Interceptor {
61
67
  calls: number;
62
68
  requests: InterceptedRequest[];
63
69
  }
70
+ /**
71
+ * Split an intercept target — `app.test`, `app.test/api/sync`,
72
+ * `*.example.com/v1` — into the hostname and the mount path.
73
+ *
74
+ * The path rides the target rather than a second argument because that is
75
+ * what it is: part of what the interceptor claims, not a separate knob. It
76
+ * also keeps the signature down to one string, so the description that
77
+ * follows can never be mistaken for a path.
78
+ *
79
+ * A scheme is tolerated (`https://app.test/api`) since that is how the same
80
+ * address is written everywhere else.
81
+ */
82
+ export declare function parseTarget(target: string): {
83
+ hostname: string;
84
+ path: string;
85
+ };
64
86
  /** Mount semantics (`app.use(path, fn)`): `/api` matches `/api`, `/api/`, `/api/x`, and
65
87
  * never `/apix`. `/` matches everything. Query strings do not take part. */
66
88
  export declare function pathMounts(mount: string, pathname: string): boolean;
@@ -33,6 +33,34 @@
33
33
  * plain `Request`/`Response` objects.
34
34
  */
35
35
  import { isWildcard, wildcardSuffix } from "./hostmatch";
36
+ /**
37
+ * Split an intercept target — `app.test`, `app.test/api/sync`,
38
+ * `*.example.com/v1` — into the hostname and the mount path.
39
+ *
40
+ * The path rides the target rather than a second argument because that is
41
+ * what it is: part of what the interceptor claims, not a separate knob. It
42
+ * also keeps the signature down to one string, so the description that
43
+ * follows can never be mistaken for a path.
44
+ *
45
+ * A scheme is tolerated (`https://app.test/api`) since that is how the same
46
+ * address is written everywhere else.
47
+ */
48
+ export function parseTarget(target) {
49
+ if (typeof target !== "string" || target.trim() === "") {
50
+ throw new Error("intercept: a target hostname is required, e.g. \"app.test\" or \"app.test/api\"");
51
+ }
52
+ const bare = target.trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
53
+ const slash = bare.indexOf("/");
54
+ const hostname = slash === -1 ? bare : bare.slice(0, slash);
55
+ const rest = slash === -1 ? undefined : bare.slice(slash);
56
+ if (hostname === "") {
57
+ throw new Error(`intercept: target ${JSON.stringify(target)} names no hostname — write it as "app.test/api", not "/api"`);
58
+ }
59
+ if (hostname.includes("?") || hostname.includes("#")) {
60
+ throw new Error(`intercept: target ${JSON.stringify(target)} cannot carry a query or fragment`);
61
+ }
62
+ return { hostname, path: normalizeMount(rest) };
63
+ }
36
64
  /** Mount semantics (`app.use(path, fn)`): `/api` matches `/api`, `/api/`, `/api/x`, and
37
65
  * never `/apix`. `/` matches everything. Query strings do not take part. */
38
66
  export function pathMounts(mount, pathname) {
@@ -159,6 +187,7 @@ export async function runChain(chain, req, upstream, observe) {
159
187
  res = new Response(`spectest-daemon: interceptor for ${it.hostname} threw: ${e?.message ?? String(err)}\n`, { status: 500, headers: { "content-type": "text/plain" } });
160
188
  record.answeredBy = "handler";
161
189
  record.status = 500;
190
+ record.threw = true;
162
191
  it.calls++;
163
192
  it.requests.push(record);
164
193
  observe?.(it, record, Date.now() - started);
@@ -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 {};