diff --git a/packages/compiler/src/frontend/decl-projection.ts b/packages/compiler/src/frontend/decl-projection.ts index 4ff843c8b..31d32834e 100644 --- a/packages/compiler/src/frontend/decl-projection.ts +++ b/packages/compiler/src/frontend/decl-projection.ts @@ -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; diff --git a/packages/runtime/src/scr_decl_adopt.c b/packages/runtime/src/scr_decl_adopt.c index 3173ffb84..7372826ab 100644 --- a/packages/runtime/src/scr_decl_adopt.c +++ b/packages/runtime/src/scr_decl_adopt.c @@ -22,7 +22,6 @@ #include 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; diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index cf50194c8..e18719ec1 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -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 diff --git a/tests/fixtures/npm/cases/zod3-adoption/main.ts b/tests/fixtures/npm/cases/zod3-adoption/main.ts index 6566d0e3a..d21ff69ee 100644 --- a/tests/fixtures/npm/cases/zod3-adoption/main.ts +++ b/tests/fixtures/npm/cases/zod3-adoption/main.ts @@ -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]); diff --git a/tests/fixtures/npm/cases/zod3-boundary/rewrites.ts b/tests/fixtures/npm/cases/zod3-boundary/rewrites.ts new file mode 100644 index 000000000..7a98cccf0 --- /dev/null +++ b/tests/fixtures/npm/cases/zod3-boundary/rewrites.ts @@ -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 { + console.log("async arrow", await Length.parseAsync("abc")); +} +void main(); diff --git a/tests/harness/decl-boundary.test.ts b/tests/harness/decl-boundary.test.ts index 6729dd530..931208836 100644 --- a/tests/harness/decl-boundary.test.ts +++ b/tests/harness/decl-boundary.test.ts @@ -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"); + }, +); diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index 9a13c0968..461b7fbcd 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -112,13 +112,16 @@ const KNOWN: Record = { 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 = { undici5: 600_000, undici7: 600_000, zod3: 600_000, + "zod3-adoption": 600_000, + "zod3-boundary": 600_000, "zod3-infer": 600_000, };