@specific.dev/spectest 0.67.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.
package/dist/daemon.js CHANGED
@@ -39,6 +39,7 @@ import { summarizeBuildKit } from "./harness/buildkit-progress.js";
39
39
  import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
40
40
  import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
41
41
  import { pollUntilReady } from "./harness/ready-poll.js";
42
+ import { runWrapperRules } from "./harness/wrapper-rules.js";
42
43
  import { APP_DIR, WORKSPACE, resolveProjectPath } from "./project-files.js";
43
44
  import { isTextualContentType, looksBinary, omittedBody, parseContentLength, } from "./harness/http-body.js";
44
45
  import { encodeRegistry } from "./harness/names-registry.js";
@@ -4133,7 +4134,16 @@ async function pollCall(description, fn, opts) {
4133
4134
  lastIterStartIdx = recorderEventCount();
4134
4135
  try {
4135
4136
  const v = await fn();
4136
- 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) {
4137
4147
  value = v;
4138
4148
  success = true;
4139
4149
  break;
@@ -5061,7 +5071,11 @@ async function evalCode(code, secrets) {
5061
5071
  // its own `typescript` in spectest/package.json wins. A project tsconfig.json
5062
5072
  // wins over the generated one the same way.
5063
5073
  // ────────────────────────────────────────────────────────────────────────
5064
- 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";
5065
5079
  /** Cap on errors shipped in the report; `totalErrors` carries the true count. */
5066
5080
  const TYPECHECK_ERROR_CAP = 50;
5067
5081
  let TYPECHECK = null;
@@ -5176,7 +5190,17 @@ async function runTypecheck() {
5176
5190
  return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
5177
5191
  }
5178
5192
  const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
5179
- 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) {
5180
5204
  // Exit 0 → clean. Non-zero with no *user-file* diagnostics is either
5181
5205
  // all-suppressed (still ok from the user's perspective) or a compiler
5182
5206
  // crash (config not found, OOM) — surface the latter.
@@ -5189,7 +5213,51 @@ async function runTypecheck() {
5189
5213
  }
5190
5214
  return { status: "ok", errors: [], totalErrors: 0, durationMs };
5191
5215
  }
5192
- 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
+ }
5193
5261
  }
5194
5262
  /** Run `job` under a mutual-exclusion slot, refusing if one is held. */
5195
5263
  async function exclusive(state, slot, busy, job) {
@@ -0,0 +1,149 @@
1
+ /** A finding, shaped like the `TypecheckError` the report already carries. */
2
+ export interface WrapperDiagnostic {
3
+ /** Path relative to the app dir, matching the advisory diagnostics. */
4
+ file: string;
5
+ line: number;
6
+ column: number;
7
+ code: string;
8
+ message: string;
9
+ /**
10
+ * Whether this finding may fail the run. Only a rule that is true of the
11
+ * RUN, not merely of the declared types, may block — see the header and the
12
+ * note on {@link CODE_TRUTHY}.
13
+ */
14
+ blocking: boolean;
15
+ }
16
+ /**
17
+ * A condition on a wrapped value — always true. **Blocking**, but on different
18
+ * grounds from {@link CODE_EQUALITY}, and the difference is worth knowing.
19
+ *
20
+ * This rule is NOT a proof. Two things can hide a runtime nullish from the
21
+ * compiler: a row generic that overstates a column
22
+ * (`client<{ names: string }>` over a `string_agg` that returns NULL) and
23
+ * TypeScript's deliberately unsound array indexing (`rows[0]` is typed
24
+ * non-optional even when the array is empty). A nullish leaf comes back RAW
25
+ * from `wrapChild`, so in those shapes `if (rows[0]?.names)` really does tell
26
+ * present from absent, and "always true" is false about it — measured against
27
+ * a real suite on 2026-08-30 (`journal-note.ts:200`).
28
+ *
29
+ * It blocks anyway, as a CONVENTION rather than a verdict: *unwrap before
30
+ * branching on a wrapped value*, the same discipline the docs already teach
31
+ * for `===`. What makes that acceptable is that the fix is safe in every case
32
+ * — `?.unwrap()` short-circuits, so it never throws and never changes code
33
+ * that was already correct; it only removes the accident. And the accident is
34
+ * the worst failure this SDK has: a `ctx.poll` predicate that collapses a
35
+ * wrapped `false` to `true` passes on attempt 1, the suite runs on against
36
+ * data that never arrived, and neither the compiler nor the runtime says a
37
+ * word (reported 2026-08-30).
38
+ *
39
+ * The line this does not cross: a rule may demand more of the user's own code,
40
+ * but it may never fail a run over OUR stale types (the patched global
41
+ * `fetch`) or over the compiler's own bad inference (TS2367 across a
42
+ * callback). Those cost the user a fix they cannot make.
43
+ */
44
+ export declare const CODE_TRUTHY = "SPECTEST2001";
45
+ /**
46
+ * `===`/`!==` between a wrapped value and a plain one — always false/true.
47
+ * **Blocking.** Sound even when a row generic understates nullability: if the
48
+ * leaf is a carrier the comparison is false because an object never equals a
49
+ * primitive, and if it is raw nullish it is false because nullish does not
50
+ * equal the literal either. A nullish literal on the other side is excluded —
51
+ * see {@link equalityIsConstant}.
52
+ */
53
+ export declare const CODE_EQUALITY = "SPECTEST2002";
54
+ /**
55
+ * Split a printed type on its TOP-LEVEL `|`, leaving nested unions alone
56
+ * (`Carrier<A | B> | undefined` → [`Carrier<A | B>`, `undefined`]). Depth is
57
+ * tracked across every bracket kind because a printed type can hold object
58
+ * literals (`{ a: 1 | 2 }`), tuples and parenthesised function types.
59
+ */
60
+ export declare function splitUnion(text: string): string[];
61
+ /** One union member that is the primitive carrier. Object/array/response
62
+ * wrappers are deliberately NOT included: they are objects whether or not we
63
+ * wrap them, so a condition on one is not made constant by the wrapper. */
64
+ export declare function isCarrierMember(member: string): boolean;
65
+ export type TypeVerdict =
66
+ /** Every member is a carrier and none is nullish — constant at runtime. */
67
+ "carrier"
68
+ /** A carrier that may also be absent — a real presence test, leave alone. */
69
+ | "nullable-carrier"
70
+ /** Not a wrapper. */
71
+ | "plain";
72
+ /**
73
+ * Classify a printed type for the rules. `unknown`/`any`/an error type read as
74
+ * `plain`: the checker could not say what the value is, and a rule that blocks
75
+ * a run must not guess.
76
+ */
77
+ export declare function classifyType(text: string | undefined): TypeVerdict;
78
+ /** Source text that denotes a nullish literal. */
79
+ export declare function isNullishLiteral(exprText: string): boolean;
80
+ /**
81
+ * Verdict for a strict `===`/`!==`. Blocking only when exactly one side is a
82
+ * definite carrier: two carriers compare object identity, which is a different
83
+ * mistake and not one this rule can prove constant.
84
+ *
85
+ * A comparison against a NULLISH LITERAL is never constant, whatever the type
86
+ * says. `wrapChild` hands a `null`/`undefined` leaf back RAW, so
87
+ * `row.setting === null` is the correct way to ask whether a column is null —
88
+ * and it answers correctly in both directions. The declared type cannot show
89
+ * this, because a row generic is the caller's own assertion
90
+ * (`client<{ setting: unknown }>`) and routinely understates nullability.
91
+ * Found in a real suite on 2026-08-30 (`document-export-settings.ts:64`),
92
+ * where flagging it would have blocked a passing test.
93
+ */
94
+ export declare function equalityIsConstant(left: string | undefined, right: string | undefined, leftExpr?: string, rightExpr?: string): boolean;
95
+ /**
96
+ * The real start of a node. A TypeScript node's `pos` is the end of the
97
+ * PREVIOUS node, so it includes the leading whitespace and comments; `tsc`
98
+ * reports the first meaningful character. Without this every column is a few
99
+ * places to the left and a finding above a comment points at the comment.
100
+ */
101
+ export declare function startOfNode(text: string, pos: number): number;
102
+ /** 1-indexed line/column for a character offset, matching `tsc` output. */
103
+ export declare function lineColumnAt(text: string, pos: number): {
104
+ line: number;
105
+ column: number;
106
+ };
107
+ /** Trim a source snippet for a message: one line, bounded. */
108
+ export declare function snippet(text: string, pos: number, end: number): string;
109
+ export declare function truthyMessage(expr: string, typeText: string): string;
110
+ export declare function equalityMessage(expr: string, typeText: string, negated: boolean): string;
111
+ /** Minimal shape of the bits of the TS 7 API this uses. */
112
+ interface TsNode {
113
+ kind: number;
114
+ pos: number;
115
+ end: number;
116
+ [k: string]: unknown;
117
+ }
118
+ export interface WrapperRuleRun {
119
+ status: "ok" | "skipped" | "failed";
120
+ diagnostics: WrapperDiagnostic[];
121
+ detail?: string;
122
+ durationMs: number;
123
+ }
124
+ /** Candidate positions, and what each one means if the operand is a carrier. */
125
+ interface Candidate {
126
+ node: TsNode;
127
+ kind: "truthy" | "equality";
128
+ /** For an equality, the other side, whose type decides with this one. */
129
+ other?: TsNode;
130
+ negated?: boolean;
131
+ }
132
+ /**
133
+ * Collect the positions worth asking about in one file. Kept separate from the
134
+ * checker so the walk can be reasoned about (and extended) on its own.
135
+ */
136
+ export declare function collectCandidates(statements: readonly TsNode[], kindName: (n: TsNode) => string, walk: (n: TsNode, visit: (c: TsNode) => void) => void): Candidate[];
137
+ export declare function runWrapperRules(opts: {
138
+ /** Directory of the baked `typescript` package. */
139
+ typescriptDir: string;
140
+ /** Absolute path of the tsconfig to open. */
141
+ configFile: string;
142
+ /** Only files under here produce diagnostics. */
143
+ appDir: string;
144
+ readFile: (p: string) => Promise<string>;
145
+ relative: (from: string, to: string) => string;
146
+ join: (...parts: string[]) => string;
147
+ dirname: (p: string) => string;
148
+ }): Promise<WrapperRuleRun>;
149
+ export {};
@@ -0,0 +1,422 @@
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 condition on a wrapped value — always true. **Blocking**, but on different
60
+ * grounds from {@link CODE_EQUALITY}, and the difference is worth knowing.
61
+ *
62
+ * This rule is NOT a proof. Two things can hide a runtime nullish from the
63
+ * compiler: a row generic that overstates a column
64
+ * (`client<{ names: string }>` over a `string_agg` that returns NULL) and
65
+ * TypeScript's deliberately unsound array indexing (`rows[0]` is typed
66
+ * non-optional even when the array is empty). A nullish leaf comes back RAW
67
+ * from `wrapChild`, so in those shapes `if (rows[0]?.names)` really does tell
68
+ * present from absent, and "always true" is false about it — measured against
69
+ * a real suite on 2026-08-30 (`journal-note.ts:200`).
70
+ *
71
+ * It blocks anyway, as a CONVENTION rather than a verdict: *unwrap before
72
+ * branching on a wrapped value*, the same discipline the docs already teach
73
+ * for `===`. What makes that acceptable is that the fix is safe in every case
74
+ * — `?.unwrap()` short-circuits, so it never throws and never changes code
75
+ * that was already correct; it only removes the accident. And the accident is
76
+ * the worst failure this SDK has: a `ctx.poll` predicate that collapses a
77
+ * wrapped `false` to `true` passes on attempt 1, the suite runs on against
78
+ * data that never arrived, and neither the compiler nor the runtime says a
79
+ * word (reported 2026-08-30).
80
+ *
81
+ * The line this does not cross: a rule may demand more of the user's own code,
82
+ * but it may never fail a run over OUR stale types (the patched global
83
+ * `fetch`) or over the compiler's own bad inference (TS2367 across a
84
+ * callback). Those cost the user a fix they cannot make.
85
+ */
86
+ export const CODE_TRUTHY = "SPECTEST2001";
87
+ /**
88
+ * `===`/`!==` between a wrapped value and a plain one — always false/true.
89
+ * **Blocking.** Sound even when a row generic understates nullability: if the
90
+ * leaf is a carrier the comparison is false because an object never equals a
91
+ * primitive, and if it is raw nullish it is false because nullish does not
92
+ * equal the literal either. A nullish literal on the other side is excluded —
93
+ * see {@link equalityIsConstant}.
94
+ */
95
+ export const CODE_EQUALITY = "SPECTEST2002";
96
+ /**
97
+ * Split a printed type on its TOP-LEVEL `|`, leaving nested unions alone
98
+ * (`Carrier<A | B> | undefined` → [`Carrier<A | B>`, `undefined`]). Depth is
99
+ * tracked across every bracket kind because a printed type can hold object
100
+ * literals (`{ a: 1 | 2 }`), tuples and parenthesised function types.
101
+ */
102
+ export function splitUnion(text) {
103
+ const parts = [];
104
+ let depth = 0;
105
+ let start = 0;
106
+ for (let i = 0; i < text.length; i += 1) {
107
+ const c = text[i];
108
+ if (c === "<" || c === "(" || c === "[" || c === "{")
109
+ depth += 1;
110
+ else if (c === ">" || c === ")" || c === "]" || c === "}")
111
+ depth -= 1;
112
+ else if (c === "|" && depth === 0) {
113
+ parts.push(text.slice(start, i).trim());
114
+ start = i + 1;
115
+ }
116
+ }
117
+ parts.push(text.slice(start).trim());
118
+ return parts.filter((p) => p.length > 0);
119
+ }
120
+ /** One union member that is the primitive carrier. Object/array/response
121
+ * wrappers are deliberately NOT included: they are objects whether or not we
122
+ * wrap them, so a condition on one is not made constant by the wrapper. */
123
+ export function isCarrierMember(member) {
124
+ return /^Carrier<[\s\S]*>$/.test(member.trim());
125
+ }
126
+ /**
127
+ * Classify a printed type for the rules. `unknown`/`any`/an error type read as
128
+ * `plain`: the checker could not say what the value is, and a rule that blocks
129
+ * a run must not guess.
130
+ */
131
+ export function classifyType(text) {
132
+ if (!text)
133
+ return "plain";
134
+ const members = splitUnion(text);
135
+ const nullish = members.filter((m) => m === "null" || m === "undefined");
136
+ const rest = members.filter((m) => m !== "null" && m !== "undefined");
137
+ if (rest.length === 0)
138
+ return "plain";
139
+ // A union mixing a carrier with a non-nullish plain type is not a shape the
140
+ // SDK produces. Blocking a run needs certainty, so an unrecognised shape
141
+ // reads as plain rather than as a finding.
142
+ if (!rest.every((m) => isCarrierMember(m)))
143
+ return "plain";
144
+ return nullish.length > 0 ? "nullable-carrier" : "carrier";
145
+ }
146
+ /** Source text that denotes a nullish literal. */
147
+ export function isNullishLiteral(exprText) {
148
+ const t = exprText.trim();
149
+ return t === "null" || t === "undefined" || /^void\s+0$/.test(t);
150
+ }
151
+ /**
152
+ * Verdict for a strict `===`/`!==`. Blocking only when exactly one side is a
153
+ * definite carrier: two carriers compare object identity, which is a different
154
+ * mistake and not one this rule can prove constant.
155
+ *
156
+ * A comparison against a NULLISH LITERAL is never constant, whatever the type
157
+ * says. `wrapChild` hands a `null`/`undefined` leaf back RAW, so
158
+ * `row.setting === null` is the correct way to ask whether a column is null —
159
+ * and it answers correctly in both directions. The declared type cannot show
160
+ * this, because a row generic is the caller's own assertion
161
+ * (`client<{ setting: unknown }>`) and routinely understates nullability.
162
+ * Found in a real suite on 2026-08-30 (`document-export-settings.ts:64`),
163
+ * where flagging it would have blocked a passing test.
164
+ */
165
+ export function equalityIsConstant(left, right, leftExpr = "", rightExpr = "") {
166
+ if (isNullishLiteral(leftExpr) || isNullishLiteral(rightExpr))
167
+ return false;
168
+ const l = classifyType(left);
169
+ const r = classifyType(right);
170
+ return (l === "carrier") !== (r === "carrier");
171
+ }
172
+ /**
173
+ * The real start of a node. A TypeScript node's `pos` is the end of the
174
+ * PREVIOUS node, so it includes the leading whitespace and comments; `tsc`
175
+ * reports the first meaningful character. Without this every column is a few
176
+ * places to the left and a finding above a comment points at the comment.
177
+ */
178
+ export function startOfNode(text, pos) {
179
+ let i = Math.max(0, Math.min(pos, text.length));
180
+ while (i < text.length) {
181
+ const c = text[i];
182
+ if (c === " " || c === "\t" || c === "\r" || c === "\n") {
183
+ i += 1;
184
+ }
185
+ else if (c === "/" && text[i + 1] === "/") {
186
+ const nl = text.indexOf("\n", i);
187
+ i = nl === -1 ? text.length : nl + 1;
188
+ }
189
+ else if (c === "/" && text[i + 1] === "*") {
190
+ const close = text.indexOf("*/", i + 2);
191
+ i = close === -1 ? text.length : close + 2;
192
+ }
193
+ else {
194
+ break;
195
+ }
196
+ }
197
+ return i;
198
+ }
199
+ /** 1-indexed line/column for a character offset, matching `tsc` output. */
200
+ export function lineColumnAt(text, pos) {
201
+ const clamped = Math.max(0, Math.min(pos, text.length));
202
+ let line = 1;
203
+ let lineStart = 0;
204
+ for (let i = 0; i < clamped; i += 1) {
205
+ if (text.charCodeAt(i) === 10) {
206
+ line += 1;
207
+ lineStart = i + 1;
208
+ }
209
+ }
210
+ return { line, column: clamped - lineStart + 1 };
211
+ }
212
+ /** Trim a source snippet for a message: one line, bounded. */
213
+ export function snippet(text, pos, end) {
214
+ const raw = text.slice(Math.max(0, pos), Math.max(0, end)).trim();
215
+ const oneLine = raw.split("\n")[0]?.trim() ?? "";
216
+ return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine;
217
+ }
218
+ export function truthyMessage(expr, typeText) {
219
+ const subject = expr ? `\`${expr}\`` : "This value";
220
+ return (`${subject} is \`${typeText}\` — a provenance wrapper, which is an object at ` +
221
+ `runtime, so this condition is always true. A wrapped \`false\`, \`0\` or ` +
222
+ `\`""\` reads as truthy. Call \`.unwrap()\` before testing it.`);
223
+ }
224
+ export function equalityMessage(expr, typeText, negated) {
225
+ const subject = expr ? `\`${expr}\`` : "This value";
226
+ const verdict = negated ? "always true" : "always false";
227
+ return (`${subject} is \`${typeText}\` — a provenance wrapper, which is an object at ` +
228
+ `runtime and can never be strictly equal to a plain value, so this ` +
229
+ `comparison is ${verdict}. Call \`.unwrap()\` first, or assert with ` +
230
+ `\`expect(...)\`, which unwraps for you.`);
231
+ }
232
+ /** `TypeFlags.Object | TypeFlags.Union` — the only shapes a carrier can wear.
233
+ * Filtering on the flags the batch call already returned keeps the printed
234
+ * name (one round trip each) off every ordinary `boolean` condition. */
235
+ const OBJECT_OR_UNION = (1 << 19) | (1 << 20);
236
+ const SYNTAX = {
237
+ If: "IfStatement",
238
+ While: "WhileStatement",
239
+ DoWhile: "DoStatement",
240
+ For: "ForStatement",
241
+ Conditional: "ConditionalExpression",
242
+ Prefix: "PrefixUnaryExpression",
243
+ Binary: "BinaryExpression",
244
+ };
245
+ /**
246
+ * Collect the positions worth asking about in one file. Kept separate from the
247
+ * checker so the walk can be reasoned about (and extended) on its own.
248
+ */
249
+ export function collectCandidates(statements, kindName, walk) {
250
+ const all = [];
251
+ const push = (n) => {
252
+ all.push(n);
253
+ walk(n, push);
254
+ };
255
+ for (const s of statements)
256
+ push(s);
257
+ const out = [];
258
+ for (const n of all) {
259
+ const k = kindName(n);
260
+ if (k === SYNTAX.If || k === SYNTAX.While || k === SYNTAX.DoWhile) {
261
+ const e = n.expression;
262
+ if (e)
263
+ out.push({ node: e, kind: "truthy" });
264
+ }
265
+ else if (k === SYNTAX.For || k === SYNTAX.Conditional) {
266
+ const e = n.condition;
267
+ if (e)
268
+ out.push({ node: e, kind: "truthy" });
269
+ }
270
+ else if (k === SYNTAX.Prefix) {
271
+ // `!x` — TypeScript's own always-truthy check misses this shape, which
272
+ // is why the rule cannot lean on TS2774.
273
+ if (kindName({ kind: n.operator }) === "ExclamationToken") {
274
+ const e = n.operand;
275
+ if (e)
276
+ out.push({ node: e, kind: "truthy" });
277
+ }
278
+ }
279
+ else if (k === SYNTAX.Binary) {
280
+ const op = kindName({ kind: n.operatorToken?.kind });
281
+ const left = n.left;
282
+ const right = n.right;
283
+ if (!left || !right)
284
+ continue;
285
+ if (op === "AmpersandAmpersandToken" || op === "BarBarToken") {
286
+ out.push({ node: left, kind: "truthy" });
287
+ }
288
+ else if (op === "EqualsEqualsEqualsToken") {
289
+ out.push({ node: left, kind: "equality", other: right, negated: false });
290
+ }
291
+ else if (op === "ExclamationEqualsEqualsToken") {
292
+ out.push({ node: left, kind: "equality", other: right, negated: true });
293
+ }
294
+ }
295
+ }
296
+ return out;
297
+ }
298
+ export async function runWrapperRules(opts) {
299
+ const started = Date.now();
300
+ const done = (r) => ({
301
+ ...r,
302
+ durationMs: Date.now() - started,
303
+ });
304
+ let api;
305
+ try {
306
+ const base = opts.join(opts.typescriptDir, "dist");
307
+ const apiMod = (await import(opts.join(base, "api", "async", "api.js")));
308
+ const utils = (await import(opts.join(base, "ast", "utils.js")));
309
+ const visitor = (await import(opts.join(base, "ast", "visitor.js")));
310
+ const inst = new apiMod.API({ cwd: opts.dirname(opts.configFile) });
311
+ api = inst;
312
+ const snapshot = await inst.updateSnapshot({ openProjects: [opts.configFile] });
313
+ const project = (await snapshot.getProjects())[0];
314
+ if (!project)
315
+ return done({ status: "failed", diagnostics: [], detail: "no project" });
316
+ // Soundness condition 1 — see the header. Without strictNullChecks an
317
+ // optional carrier loses its `| undefined` and every presence test in the
318
+ // suite becomes a finding, so refuse rather than guess.
319
+ // `compilerOptions` is the file's raw options, not the resolved ones, so
320
+ // the `strict` family has to be folded by hand: an explicit
321
+ // `strictNullChecks` wins, otherwise `strict` supplies it.
322
+ const strictNullChecks = project.compilerOptions.strictNullChecks ??
323
+ project.compilerOptions.strict ??
324
+ false;
325
+ if (strictNullChecks !== true) {
326
+ return done({
327
+ status: "skipped",
328
+ diagnostics: [],
329
+ detail: "strictNullChecks is off; a wrapped optional cannot be told from a wrapped value",
330
+ });
331
+ }
332
+ const kindName = (n) => typeof n?.kind === "number" ? utils.formatSyntaxKind(n.kind) : "";
333
+ const walk = (n, visit) => {
334
+ try {
335
+ visitor.visitEachChild(n, (c) => {
336
+ visit(c);
337
+ return c;
338
+ }, undefined);
339
+ }
340
+ catch {
341
+ /* a node shape the walker cannot descend — its children are skipped */
342
+ }
343
+ };
344
+ const names = await project.program.getSourceFileNames();
345
+ const files = names.filter((f) => f.startsWith(`${opts.appDir}/`) && !f.includes("/node_modules/") && !f.endsWith(".d.ts"));
346
+ const diagnostics = [];
347
+ for (const file of files) {
348
+ const sf = await project.program.getSourceFile(file);
349
+ if (!sf)
350
+ continue;
351
+ const candidates = collectCandidates(sf.statements, kindName, walk);
352
+ if (candidates.length === 0)
353
+ continue;
354
+ // One batched checker call per file, then a printed name only for the
355
+ // types that could be a wrapper at all.
356
+ const nodes = candidates.flatMap((c) => (c.other ? [c.node, c.other] : [c.node]));
357
+ const types = await project.checker.getTypeAtLocation(nodes);
358
+ const printed = new Map();
359
+ for (let i = 0; i < nodes.length; i += 1) {
360
+ const t = types[i];
361
+ if (t && (t.flags & OBJECT_OR_UNION) !== 0) {
362
+ printed.set(i, await project.checker.typeToString(t));
363
+ }
364
+ }
365
+ const text = await opts.readFile(file);
366
+ const rel = opts.relative(opts.appDir, file);
367
+ let idx = 0;
368
+ for (const c of candidates) {
369
+ const leftText = printed.get(idx);
370
+ idx += 1;
371
+ const rightText = c.other ? printed.get(idx) : undefined;
372
+ if (c.other)
373
+ idx += 1;
374
+ const start = startOfNode(text, c.node.pos);
375
+ const where = lineColumnAt(text, start);
376
+ const expr = snippet(text, start, c.node.end);
377
+ if (c.kind === "truthy") {
378
+ if (classifyType(leftText) !== "carrier")
379
+ continue;
380
+ diagnostics.push({
381
+ file: rel,
382
+ ...where,
383
+ code: CODE_TRUTHY,
384
+ message: truthyMessage(expr, leftText),
385
+ blocking: true,
386
+ });
387
+ continue;
388
+ }
389
+ const otherStart = startOfNode(text, c.other.pos);
390
+ const otherExpr = snippet(text, otherStart, c.other.end);
391
+ if (!equalityIsConstant(leftText, rightText, expr, otherExpr))
392
+ continue;
393
+ const carrierIsLeft = classifyType(leftText) === "carrier";
394
+ const node = carrierIsLeft ? c.node : c.other;
395
+ const at = carrierIsLeft ? start : otherStart;
396
+ diagnostics.push({
397
+ file: rel,
398
+ ...lineColumnAt(text, at),
399
+ code: CODE_EQUALITY,
400
+ message: equalityMessage(snippet(text, at, node.end), (carrierIsLeft ? leftText : rightText), c.negated === true),
401
+ blocking: true,
402
+ });
403
+ }
404
+ }
405
+ return done({ status: "ok", diagnostics });
406
+ }
407
+ catch (err) {
408
+ return done({
409
+ status: "failed",
410
+ diagnostics: [],
411
+ detail: err?.message ?? String(err),
412
+ });
413
+ }
414
+ finally {
415
+ try {
416
+ api?.close();
417
+ }
418
+ catch {
419
+ /* the session is already gone */
420
+ }
421
+ }
422
+ }