@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/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,
@@ -100,6 +102,7 @@ import {
100
102
  import { encodeRegistry } from "./harness/names-registry.js";
101
103
  import {
102
104
  InterceptRegistry,
105
+ parseTarget,
103
106
  runChain,
104
107
  type Interceptor,
105
108
  } from "./harness/intercept.js";
@@ -174,6 +177,7 @@ import {
174
177
  startRecording,
175
178
  stopRecording,
176
179
  truncateUtf8,
180
+ type StepBlock,
177
181
  type TestEvent,
178
182
  type OmittedBody,
179
183
  } from "./recorder.js";
@@ -2796,16 +2800,22 @@ function ingressClaimsHostname(hostname: string): boolean {
2796
2800
  return false;
2797
2801
  }
2798
2802
 
2799
- /** Record one request an interceptor saw, nested under its `intercept` step. */
2803
+ /** Record one request an interceptor saw, nested under its `intercept` step.
2804
+ *
2805
+ * A forced 503 is the step doing exactly what its description says, so it is
2806
+ * **not** marked failed — reddening the outcome the test asked for trains the
2807
+ * reader to ignore the colour. Only a handler that threw is marked, because
2808
+ * that 500 is a bug in the middleware rather than the outage it stands for. */
2800
2809
  function recordInterceptedRequest(
2801
2810
  it: Interceptor,
2802
- rec: { method: string; path: string; status: number; answeredBy: string },
2811
+ rec: { method: string; path: string; status: number; answeredBy: string; threw?: boolean },
2803
2812
  durationMs: number,
2804
2813
  ): void {
2805
2814
  const parentSeq = INTERCEPT_STEP_SEQ.get(it.id);
2806
2815
  if (parentSeq === undefined || !isRecording()) return;
2807
- const by =
2808
- rec.answeredBy === "handler"
2816
+ const by = rec.threw
2817
+ ? "the interceptor threw — 500 from spectest, not from your handler"
2818
+ : rec.answeredBy === "handler"
2809
2819
  ? "answered by the interceptor"
2810
2820
  : rec.answeredBy === "modified"
2811
2821
  ? "upstream answer replaced by the interceptor"
@@ -2814,22 +2824,42 @@ function recordInterceptedRequest(
2814
2824
  kind: "intercept-request",
2815
2825
  parentSeq,
2816
2826
  title: `${rec.method} ${rec.path} → ${rec.status}`,
2817
- status: rec.status >= 500 && rec.answeredBy !== "upstream" ? "failed" : "passed",
2827
+ status: rec.threw ? "failed" : "passed",
2828
+ // Method, path and status are already the title; the one thing the row
2829
+ // cannot say for itself is who produced that status.
2818
2830
  blocks: [
2819
- {
2820
- type: "kv",
2821
- rows: [
2822
- { label: "Host", value: it.hostname },
2823
- { label: "Request", value: `${rec.method} ${rec.path}` },
2824
- { label: "Status", value: String(rec.status) },
2825
- { label: "Answered", value: by },
2826
- ],
2827
- },
2831
+ { type: "kv", rows: [{ label: "Answered", value: by, error: rec.threw === true }] },
2828
2832
  ],
2829
2833
  durationMs,
2830
2834
  });
2831
2835
  }
2832
2836
 
2837
+ /** The handler's own source, which is what the interceptor actually does.
2838
+ *
2839
+ * Free and impossible to let rot: Bun runs the project's TypeScript
2840
+ * directly, so `toString()` is the text the author wrote. It is the step's
2841
+ * body, under the description — a mechanism the reader can check against
2842
+ * the intent the description claims. A handler long enough to be a wall of
2843
+ * code folds into a disclosure instead of pushing the requests off screen.
2844
+ */
2845
+ function handlerBlocks(handler: InterceptHandler): StepBlock[] {
2846
+ let src: string;
2847
+ try {
2848
+ src = String(handler);
2849
+ } catch {
2850
+ return [];
2851
+ }
2852
+ const code: StepBlock = { type: "code", lang: "ts", label: "Handler", code: src };
2853
+ return src.split("\n").length > HANDLER_FOLD_LINES
2854
+ ? [{ type: "details", summary: "Handler", blocks: [code] }]
2855
+ : [code];
2856
+ }
2857
+
2858
+ /** Past this many lines the handler source is folded away. Chosen so the
2859
+ * shapes this feature is for — a one-line forced status, a short
2860
+ * fail-twice-then-pass — always show, and only a real program hides. */
2861
+ const HANDLER_FOLD_LINES = 15;
2862
+
2833
2863
  /**
2834
2864
  * Put middleware in front of a hostname the ingress serves — the
2835
2865
  * implementation behind `ctx.intercept`.
@@ -2838,19 +2868,25 @@ function recordInterceptedRequest(
2838
2868
  * the daemon (DNS does not point here), so the interceptor could only be
2839
2869
  * silent — and silence is the failure mode this whole layer is designed
2840
2870
  * against. The message names the two ways to get a route.
2871
+ *
2872
+ * The `description` is required because it is the step's whole title in the
2873
+ * timeline and in the CLI's failure detail: it is what tells a reader why
2874
+ * the UI under test went to its error state. The handler source says how;
2875
+ * only the author can say what it means.
2841
2876
  */
2842
2877
  function registerInterceptor(
2843
- hostname: string,
2844
- pathOrHandler: string | InterceptHandler,
2845
- maybeHandler?: InterceptHandler,
2878
+ target: string,
2879
+ description: string,
2880
+ handler: InterceptHandler,
2846
2881
  ): Interception {
2847
- const path = typeof pathOrHandler === "string" ? pathOrHandler : undefined;
2848
- const handler = typeof pathOrHandler === "function" ? pathOrHandler : maybeHandler;
2849
- if (typeof hostname !== "string" || hostname.length === 0) {
2850
- throw new Error("ctx.intercept: a hostname is required");
2882
+ const { hostname: host, path } = parseTarget(target);
2883
+ if (typeof description !== "string" || description.trim() === "") {
2884
+ throw new Error(
2885
+ `ctx.intercept(${JSON.stringify(target)}): a description is required — what the ` +
2886
+ `environment now does, as the timeline reads it, e.g. "the sync endpoint returns 503".`,
2887
+ );
2851
2888
  }
2852
- const host = hostname.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
2853
- if (!handler) {
2889
+ if (typeof handler !== "function") {
2854
2890
  throw new Error("ctx.intercept: a handler (req, next) => Response is required");
2855
2891
  }
2856
2892
  if (!ingressClaimsHostname(host)) {
@@ -2863,20 +2899,42 @@ function registerInterceptor(
2863
2899
  }
2864
2900
  const resv = reserveEvent();
2865
2901
  const it = INTERCEPTORS.register(host, path, handler);
2902
+ const where = `${host}${it.path === "/" ? "" : it.path}`;
2866
2903
  const seq = recordStep(
2867
2904
  {
2868
2905
  kind: "intercept",
2869
- title: `intercept ${host}${it.path === "/" ? "" : it.path}`,
2906
+ // The description alone. The pill already says INTERCEPT, and the
2907
+ // target is in the panel — a row that repeats both spends its width
2908
+ // on what the reader can already see and none of it on the one thing
2909
+ // only the author knows.
2910
+ title: description.trim(),
2870
2911
  blocks: [
2912
+ ...handlerBlocks(handler),
2871
2913
  {
2872
- type: "kv",
2873
- rows: [
2874
- { label: "Host", value: host },
2875
- { label: "Path", value: it.path === "/" ? "/ (every path)" : `${it.path} and below` },
2914
+ // The target IS the summary line, so the closed disclosure still
2915
+ // says where the interceptor sits — a generic "Target" label
2916
+ // would spend the one visible line saying nothing.
2917
+ type: "details",
2918
+ summary: where,
2919
+ blocks: [
2920
+ {
2921
+ type: "kv",
2922
+ rows: [
2923
+ {
2924
+ label: "Matches",
2925
+ value: it.path === "/" ? "every path on this host" : `${it.path} and below`,
2926
+ },
2927
+ {
2928
+ label: "Until",
2929
+ value: it.scope === undefined ? "remove()" : "the end of this test",
2930
+ },
2931
+ ],
2932
+ },
2876
2933
  ],
2877
2934
  },
2878
2935
  ],
2879
- durationMs: 0,
2936
+ // No duration: this is a marker for the moment the interceptor went
2937
+ // up, and "(0 ms)" reads as a step that did nothing.
2880
2938
  },
2881
2939
  resv,
2882
2940
  );
@@ -4982,7 +5040,16 @@ async function pollCall<T>(
4982
5040
  lastIterStartIdx = recorderEventCount();
4983
5041
  try {
4984
5042
  const v = await fn();
4985
- if (v !== null && v !== undefined && v !== false) {
5043
+ // Decide on the RAW value. A predicate that hands back a wrapped leaf
5044
+ // (`rows[0].written` off an instrumented query) returns a `Carrier`,
5045
+ // and a carrier around `false` is an object — truthy, and `!== false`.
5046
+ // Without this a poll accepted a condition that was never met and the
5047
+ // test ran on against stale data, which is worse than a timeout: there
5048
+ // is no failure to read (reported 2026-08-30, run_0cjtx0dbzjs5yq6vwefkk,
5049
+ // where `written: false` passed the wait on attempt 1 in 3 ms).
5050
+ // `value` keeps the WRAPPED form so the return still carries provenance.
5051
+ const ready = readRaw(v);
5052
+ if (ready !== null && ready !== undefined && ready !== false) {
4986
5053
  value = v as T;
4987
5054
  success = true;
4988
5055
  break;
@@ -6047,7 +6114,11 @@ async function evalCode(
6047
6114
  // wins over the generated one the same way.
6048
6115
  // ────────────────────────────────────────────────────────────────────────
6049
6116
 
6050
- const TYPECHECK_DIR = "/opt/spectest/typecheck";
6117
+ /** Where the baked compiler lives. Overridable so the typecheck — and the
6118
+ * blocking rules, which always use THIS copy rather than the project's own —
6119
+ * can be driven outside a VM (same convention as
6120
+ * `SPECTEST_COVERAGE_TOOLS_DIR`). */
6121
+ const TYPECHECK_DIR = process.env.SPECTEST_TYPECHECK_DIR ?? "/opt/spectest/typecheck";
6051
6122
  /** Cap on errors shipped in the report; `totalErrors` carries the true count. */
6052
6123
  const TYPECHECK_ERROR_CAP = 50;
6053
6124
 
@@ -6056,7 +6127,7 @@ interface TypecheckError {
6056
6127
  file: string;
6057
6128
  line: number;
6058
6129
  column: number;
6059
- /** `TS2345` etc. */
6130
+ /** `TS2345` from the compiler, or a `SPECTEST….` rule code. */
6060
6131
  code: string;
6061
6132
  message: string;
6062
6133
  }
@@ -6068,6 +6139,19 @@ interface TypecheckReport {
6068
6139
  durationMs: number;
6069
6140
  /** Why the check was skipped / how it failed. */
6070
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[];
6071
6155
  }
6072
6156
 
6073
6157
  let TYPECHECK: Promise<TypecheckReport> | null = null;
@@ -6192,7 +6276,17 @@ async function runTypecheck(): Promise<TypecheckReport> {
6192
6276
  return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
6193
6277
  }
6194
6278
  const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
6195
- if (errors.length === 0) {
6279
+ const wrapper = await runWrapperRulesReport(config);
6280
+ const strip = ({ file, line, column, code, message }: WrapperDiagnostic): TypecheckError => ({
6281
+ file,
6282
+ line,
6283
+ column,
6284
+ code,
6285
+ message,
6286
+ });
6287
+ const blocking = wrapper.filter((d) => d.blocking).map(strip);
6288
+ const advisory = wrapper.filter((d) => !d.blocking).map(strip);
6289
+ if (errors.length === 0 && wrapper.length === 0) {
6196
6290
  // Exit 0 → clean. Non-zero with no *user-file* diagnostics is either
6197
6291
  // all-suppressed (still ok from the user's perspective) or a compiler
6198
6292
  // crash (config not found, OOM) — surface the latter.
@@ -6205,7 +6299,50 @@ async function runTypecheck(): Promise<TypecheckReport> {
6205
6299
  }
6206
6300
  return { status: "ok", errors: [], totalErrors: 0, durationMs };
6207
6301
  }
6208
- return { status: "errors", errors, totalErrors: total, durationMs };
6302
+ // The wrapper findings ride the same list the CLI and the dashboard already
6303
+ // render; `blocking` is a second view of the ones that also fail the run, so
6304
+ // nothing has to learn a new shape to show them. Blocking leads, because
6305
+ // `errors` is capped and the entries that stopped the run must never be the
6306
+ // ones the cap drops.
6307
+ const all = [...blocking, ...advisory, ...errors];
6308
+ return {
6309
+ status: "errors",
6310
+ errors: all.slice(0, TYPECHECK_ERROR_CAP),
6311
+ totalErrors: total + wrapper.length,
6312
+ durationMs,
6313
+ ...(blocking.length > 0 ? { blocking } : {}),
6314
+ };
6315
+ }
6316
+
6317
+ /**
6318
+ * The blocking rules, run against the BAKED compiler whatever the project
6319
+ * pins. Best-effort in every direction: an install that predates the API, a
6320
+ * config the rules cannot open, or a throw from `typescript/unstable/*` all
6321
+ * yield no findings, so a run proceeds exactly as it does today. Only a
6322
+ * definite finding can stop one.
6323
+ */
6324
+ async function runWrapperRulesReport(config: string): Promise<WrapperDiagnostic[]> {
6325
+ const typescriptDir = path.join(TYPECHECK_DIR, "node_modules", "typescript");
6326
+ if (!existsSync(path.join(typescriptDir, "dist", "api", "async", "api.js"))) return [];
6327
+ try {
6328
+ const run = await runWrapperRules({
6329
+ typescriptDir,
6330
+ configFile: config,
6331
+ appDir: APP_DIR,
6332
+ readFile: (f) => fs.readFile(f, "utf8"),
6333
+ relative: path.relative,
6334
+ join: path.join,
6335
+ dirname: path.dirname,
6336
+ });
6337
+ if (run.status !== "ok") {
6338
+ console.warn(`[typecheck] wrapper rules ${run.status}: ${run.detail ?? "no detail"}`);
6339
+ return [];
6340
+ }
6341
+ return run.diagnostics;
6342
+ } catch (err) {
6343
+ console.warn(`[typecheck] wrapper rules threw: ${(err as Error)?.message ?? err}`);
6344
+ return [];
6345
+ }
6209
6346
  }
6210
6347
 
6211
6348
  // ────────────────────────────────────────────────────────────────────────
@@ -3,6 +3,7 @@ import {
3
3
  InterceptRegistry,
4
4
  hostnameMatches,
5
5
  normalizeMount,
6
+ parseTarget,
6
7
  pathMounts,
7
8
  runChain,
8
9
  type Interceptor,
@@ -39,6 +40,41 @@ describe("normalizeMount", () => {
39
40
  });
40
41
  });
41
42
 
43
+ describe("parseTarget", () => {
44
+ test("a bare hostname mounts at the root", () => {
45
+ expect(parseTarget("app.test")).toEqual({ hostname: "app.test", path: "/" });
46
+ });
47
+ test("a path rides the target", () => {
48
+ expect(parseTarget("app.test/api/sync")).toEqual({
49
+ hostname: "app.test",
50
+ path: "/api/sync",
51
+ });
52
+ expect(parseTarget("app.test/api/")).toEqual({ hostname: "app.test", path: "/api" });
53
+ expect(parseTarget("app.test/")).toEqual({ hostname: "app.test", path: "/" });
54
+ });
55
+ test("a scheme and case are tolerated", () => {
56
+ expect(parseTarget("https://App.Test/API")).toEqual({
57
+ hostname: "app.test",
58
+ path: "/api",
59
+ });
60
+ });
61
+ test("a wildcard host keeps its pattern", () => {
62
+ expect(parseTarget("*.example.com/v1")).toEqual({
63
+ hostname: "*.example.com",
64
+ path: "/v1",
65
+ });
66
+ });
67
+ test("rejects a target that is only a path, or none at all", () => {
68
+ expect(() => parseTarget("/api")).toThrow(/names no hostname/);
69
+ expect(() => parseTarget("")).toThrow(/target hostname is required/);
70
+ expect(() => parseTarget(" ")).toThrow(/target hostname is required/);
71
+ });
72
+ test("rejects a query or fragment", () => {
73
+ expect(() => parseTarget("app.test/api?x=1")).toThrow(/mount prefix/);
74
+ expect(() => parseTarget("app.test?x=1")).toThrow(/query or fragment/);
75
+ });
76
+ });
77
+
42
78
  describe("hostnameMatches", () => {
43
79
  test("exact and wildcard", () => {
44
80
  expect(hostnameMatches("api.test", "api.test")).toBe(true);
@@ -56,6 +56,12 @@ export interface InterceptedRequest {
56
56
  * upstream via `next()` untouched (`upstream`), or the upstream's answer
57
57
  * replaced by the interceptor after `next()` (`modified`). */
58
58
  answeredBy: "handler" | "upstream" | "modified";
59
+ /** The handler threw (or returned something that is not a `Response`), so
60
+ * the 500 below is a bug in the test's own middleware rather than the
61
+ * outage it was asked to produce. The distinction cannot be recovered
62
+ * from the status: a deliberate 500 and a crashed handler are the same
63
+ * number, and only this tells the timeline which one to mark as wrong. */
64
+ threw?: boolean;
59
65
  }
60
66
 
61
67
  export interface Interceptor {
@@ -71,6 +77,39 @@ export interface Interceptor {
71
77
  requests: InterceptedRequest[];
72
78
  }
73
79
 
80
+ /**
81
+ * Split an intercept target — `app.test`, `app.test/api/sync`,
82
+ * `*.example.com/v1` — into the hostname and the mount path.
83
+ *
84
+ * The path rides the target rather than a second argument because that is
85
+ * what it is: part of what the interceptor claims, not a separate knob. It
86
+ * also keeps the signature down to one string, so the description that
87
+ * follows can never be mistaken for a path.
88
+ *
89
+ * A scheme is tolerated (`https://app.test/api`) since that is how the same
90
+ * address is written everywhere else.
91
+ */
92
+ export function parseTarget(target: string): { hostname: string; path: string } {
93
+ if (typeof target !== "string" || target.trim() === "") {
94
+ throw new Error("intercept: a target hostname is required, e.g. \"app.test\" or \"app.test/api\"");
95
+ }
96
+ const bare = target.trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
97
+ const slash = bare.indexOf("/");
98
+ const hostname = slash === -1 ? bare : bare.slice(0, slash);
99
+ const rest = slash === -1 ? undefined : bare.slice(slash);
100
+ if (hostname === "") {
101
+ throw new Error(
102
+ `intercept: target ${JSON.stringify(target)} names no hostname — write it as "app.test/api", not "/api"`,
103
+ );
104
+ }
105
+ if (hostname.includes("?") || hostname.includes("#")) {
106
+ throw new Error(
107
+ `intercept: target ${JSON.stringify(target)} cannot carry a query or fragment`,
108
+ );
109
+ }
110
+ return { hostname, path: normalizeMount(rest) };
111
+ }
112
+
74
113
  /** Mount semantics (`app.use(path, fn)`): `/api` matches `/api`, `/api/`, `/api/x`, and
75
114
  * never `/apix`. `/` matches everything. Query strings do not take part. */
76
115
  export function pathMounts(mount: string, pathname: string): boolean {
@@ -222,6 +261,7 @@ export async function runChain(
222
261
  );
223
262
  record.answeredBy = "handler";
224
263
  record.status = 500;
264
+ record.threw = true;
225
265
  it.calls++;
226
266
  it.requests.push(record);
227
267
  observe?.(it, record, Date.now() - started);
@@ -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
+ });