Keep projected files valid for bare async arrows and unbraced bodies, and adopt tuple elements

- A bare arrow parameter after `async` (`async x => ...`) gets parentheses
  with its projected type, as `x => ...` does. Without them the rewritten
  file failed its check and lost its projection (SC6102).
- A cast that starts an unbraced loop or `if` body gets no `;` guard. Only
  a statement in a statement list can continue the previous line, and in
  a body the `;` became the body, which moved the statement after the
  loop.
- Adoption walks a tuple's elements through the live element capsules of
  the tuple's view, so references package code holds to them see the
  native values.
- scr_runtime.h declares scr_dyn_decl_adopt (in scr_dyn.h), which the
  backend emits, so llvm-runtime-abi.test.ts finds its prototype.
- npm.test.ts gives zod3-adoption and zod3-boundary the long per-case
  timeout of the other zod 3 cases; their sanitized builds compile zod's
  types module too.
This commit is contained in:
Chris Tate
2026-10-23 00:25:59 -05:00
parent ebefb8d208
commit cba3fa6a05
7 changed files with 117 additions and 8 deletions
@@ -605,11 +605,15 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection |
const name = jsNode.name;
const nameStart = ts.tokenStartOfNode(name);
const text = jsSf.text;
// `x => ...` or `async x => ...`: nothing but the modifiers comes
// before the parameter, which then needs parentheses for its type.
const head = ts.isArrowFunction(fn)
? ((ts.getModifiers(fn) ?? []).at(-1)?.end ?? ts.tokenStartOfNode(fn))
: nameStart;
const bareArrow =
ts.isArrowFunction(fn) &&
fn.parameters.nodes.length === 1 &&
text[ts.tokenStartOfNode(fn)] !== "(" &&
text.slice(ts.tokenStartOfNode(fn), nameStart).trim() === "";
text.slice(head, nameStart).trim() === "";
const afterName = jsNode.questionToken !== undefined ? jsNode.questionToken.end : name.end;
if (bareArrow) {
fileEdits.push({ start: nameStart, end: nameStart, text: "(" });
@@ -727,10 +731,20 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection |
// semicolon continues the previous expression: guard it.
let up: ts.Node | undefined = e;
while (up?.parent !== undefined && !ts.isStatement(up.parent)) up = up.parent;
// Only a statement in a statement list can follow such a line; an
// unbraced body (`for (...) S.parse(x).run()`) follows its header,
// and a `;` there would become the body.
const list = up?.parent?.parent;
if (
up?.parent !== undefined &&
ts.isExpressionStatement(up.parent) &&
ts.tokenStartOfNode(up.parent) === start
ts.tokenStartOfNode(up.parent) === start &&
list !== undefined &&
(ts.isBlock(list) ||
ts.isSourceFile(list) ||
ts.isModuleBlock(list) ||
ts.isCaseClause(list) ||
ts.isDefaultClause(list))
)
prefix = ";(";
void stmt;
+26 -1
View File
@@ -22,7 +22,6 @@
#include <string.h>
void scr_dyn_decl_become(ScrDyn *n, const ScrDyn *capsule);
void scr_dyn_decl_adopt(ScrDyn *source, ScrDyn *capsule);
typedef struct {
ScrDyn *node;
@@ -120,6 +119,32 @@ static bool scr_decl_adopt_walk(ScrDeclAdoptList *list, ScrDyn *n, ScrDyn *capsu
return false;
}
}
} else if (!capsule->v.typed_ref.record && !capsule->v.typed_ref.klass && !capsule->v.typed_ref.array &&
n->kind == SCR_DYN_ARR && !n->v.arr.presence) {
/* A capsule without element hooks over an array: a tuple, whose index
* field names get no record hooks. Its view holds a live capsule of
* each record or array element, which the source element adopts (the
* walk skips the other elements). */
ScrDyn *view = capsule->v.typed_ref.materialize(ptr);
if (scr_exc_pending()) {
scr_dyn_release(view);
if (!(n->rc > 1 && n->rc != SIZE_MAX)) scr_dyn_release(capsule);
return false;
}
if (view && view->kind == SCR_DYN_ARR) {
size_t len = n->v.arr.len < view->v.arr.len ? n->v.arr.len : view->v.arr.len;
for (size_t i = 0; i < len; i++) {
ScrDyn *child = n->v.arr.items[i], *element = view->v.arr.items[i];
if (!child || !element || (child->kind != SCR_DYN_OBJ && child->kind != SCR_DYN_ARR)) continue;
ScrDynPath p = { path, NULL, i };
if (!scr_decl_adopt_walk(list, child, scr_dyn_retain(element), &p, depth + 1)) {
scr_dyn_release(view);
if (!(n->rc > 1 && n->rc != SIZE_MAX)) scr_dyn_release(capsule);
return false;
}
}
}
scr_dyn_release(view);
} else if (!capsule->v.typed_ref.record && !capsule->v.typed_ref.klass &&
capsule->v.typed_ref.array && n->kind == SCR_DYN_ARR && !n->v.arr.presence) {
const ScrDynTypedArrayOps *ops = capsule->v.typed_ref.array;
+6
View File
@@ -368,6 +368,12 @@ typedef struct ScrDynTypedRecordOps {
/* A record capsule's ops while it answers from the native record (no view
* created yet), or NULL. */
const ScrDynTypedRecordOps *scr_dyn_typed_ref_live_record(const ScrDyn *d);
/* The declaration-typed boundary's adoption (scr_decl_adopt.c, linked by
* programs with boundary casts): the nodes of `source` that something else
* still references become live capsules of the native value `capsule`
* holds. Both borrowed; a node that is not plain data throws the path
* TypeError before anything changes. */
void scr_dyn_decl_adopt(ScrDyn *source, ScrDyn *capsule);
/* Prototype members of a compiled class seen as `unknown`, emitted per
* class by the compiler: the string names of methods, getters and setters
+9
View File
@@ -37,3 +37,12 @@ cyc.meta = cyc;
const back = AsRow.parse(cyc);
back.id = 10;
console.log("cycle", cyc.id, back === cyc, back.meta === cyc);
// A tuple's elements keep their identity too: the package-side array
// still holds the element that typed code mutates.
const Pair = z.custom<[{ id: number; tags: string[] }, string]>();
const pairSource = JSON.parse('[{"id":1,"tags":["a"]},"s"]') as unknown[];
const pairHead = pairSource[0];
const pair = Pair.parse(pairSource);
pair[0].tags.push("b");
pair[0].id = 2;
console.log("tuple", JSON.stringify(pairHead), JSON.stringify(pairSource), pair[1]);
+21
View File
@@ -0,0 +1,21 @@
// Rewrites the declaration-typed boundary makes in user code: a typed
// parameter of a bare `async` arrow zod calls, and a cast at the start of
// an unbraced loop body.
import { z } from "zod";
const Box = z.object({ n: z.number() });
const raw: unknown = { n: 2 };
// The cast opens the loop body's statement; the loop runs it each time.
let seen = 0;
for (let i = 0; i < 3; i++) Box.parse(raw).n > 0 && seen++;
let left = 2;
while (left-- > 0) Box.parse(raw).n > 1 && seen++;
console.log("loops", seen);
// zod calls the callback with the string it checked.
const Length = z.string().transform(async s => s.length + 1);
async function main(): Promise<void> {
console.log("async arrow", await Length.parseAsync("abc"));
}
void main();
+32 -1
View File
@@ -3,7 +3,10 @@
* naming the path, and a value the native record cannot represent (a
* frozen object) fails with the reason. Node does not check, so this is a
* native-only test; npm.test.ts runs the differential cases
* (zod3-boundary, zod3-adoption, zod3-infer). */
* (zod3-boundary, zod3-adoption, zod3-infer). The rewrite test checks that
* a file keeps its projection (no SC6102 note) where the rewrite has to
* parenthesize a bare async arrow's parameter and cast the start of an
* unbraced loop body. */
import { execFile } from "node:child_process";
import { mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
@@ -35,3 +38,31 @@ test(
);
},
);
test(
"boundary rewrites keep bare async arrows and unbraced loop bodies valid",
{ timeout: 600_000 },
async () => {
const rewrites = join(repoRoot, "tests/fixtures/npm/cases/zod3-boundary/rewrites.ts");
mkdirSync(outDir, { recursive: true });
const result = await compile(rewrites, {
outPath: join(outDir, "rewrites"),
outDir,
sanitize,
});
if (!result.ok) {
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
}
// The file keeps its projection: no SC6102 note drops it (a build
// that lowered nothing carries no warnings at all).
expect((result.warnings ?? []).map((w) => w.code)).not.toContain("SC6102");
const pending = execFileAsync(result.binaryPath, [], {
cwd: dirname(rewrites),
encoding: "utf8",
});
pending.child.stdin?.end();
const { stdout } = await pending;
// Node's output: every loop iteration runs the cast statement.
expect(stdout).toBe("loops 5\nasync arrow 4\n");
},
);
+6 -3
View File
@@ -112,13 +112,16 @@ const KNOWN: Record<string, Known> = {
const cases = shardSelect(npmCases(fixturesRoot), (c) => c.name);
/** Per-case timeouts past the default 120 s: the undici cases compile the
* built-in undici implementation's module graph, and zod3 and zod3-infer
* compile zod 3's 3,700-line types module. A sanitized build of either
* takes minutes on a CI runner. */
* built-in undici implementation's module graph, and the zod 3 cases
* (zod3, zod3-adoption, zod3-boundary, zod3-infer) compile zod 3's
* 3,700-line types module. A sanitized build of either takes minutes on a
* CI runner. */
const TIMEOUTS: Record<string, number> = {
undici5: 600_000,
undici7: 600_000,
zod3: 600_000,
"zod3-adoption": 600_000,
"zod3-boundary": 600_000,
"zod3-infer": 600_000,
};