Skip to content

Commit 4f83db5

Browse files
fix(spec): the pure-annotation build plugin marks real calls only, so the published text equals its source (#20686) (#20992)
Fixes #20686 Clause-②: no The `pure-schema-construction` build plugin (`packages/spec/tsup.config.ts`) now marks real calls of `lazySchema` / `strictObject` / `defineForm` only, and never touches text. The published `@objectstack/spec/migrations` bundle carries the source's text again. The one affected D3 entry, `18.dashboard-widget-stage-order-non-funnel-refused`, has no text change. ## What changed - **`annotatePureCalls(source, fileName)`** replaces the line-prefix rule. The TypeScript parser drives the scanner. Every `CallExpression` whose callee is one of the three names gets the PURE annotation at the start of the call, whether the callee is bare (`lazySchema(…)`) or a property-access member (`z.strictObject(…)`). String and template text, comments, JSDoc and declarations are not call expressions, so they are never touched. A call inside a template substitution is code, and it is marked (that behaviour is decided here and pinned). The output is the source plus inserted annotations, and nothing else. - The esbuild `Plugin` type is now read off tsup's `Options['esbuildPlugins']`. This package has no direct `esbuild` dependency, so a bare `'esbuild'` type import resolves for no type checker. `ts.resolveModuleName` from this file answers UNRESOLVED. The new test puts this file in front of `tsconfig.scripts.json`. - `pureSchemaConstruction` and `mainConfig` are named exports, for the test. tsup reads only the default export. - New test `packages/spec/scripts/pure-schema-construction.test.ts`, in the `local` project (6 cases). It adds no gate. - `.changeset/20686-pure-plugin-skips-strings.md`: `@objectstack/spec` patch. ## Why a parse and not a bare `ts.createScanner` pass Whether `/` starts a regex literal and whether `}` resumes a template are grammar questions. The scanner answers them only when the parser asks it to rescan. Measured over `packages/spec/src` at 3693a1b (1774 files, test files excluded): a `createScanner` loop without those rescans reports 1538 calls where the syntax tree has 1727. It loses 191 real calls, because every regex literal that contains a quote is mis-lexed. It also marks 2 mentions inside comments: one in the `defineForm` JSDoc `@example` (`ui/view.zod.ts:7013`) and one in a line comment (`:7042`). So the positions are read off the syntax tree that the parser builds with that same scanner. Parse cost: the new rule parses the 249 files that name a marked constructor (the old rule rewrote 212). The census that ran both rules over all of `src` took 1.2 s. ## Annotation census **Source level** (insertion positions over `packages/spec/src` non-test files, old rule vs new): | | old rule | new | |---|---|---| | insertions | 1730 | 1727 | The old rule's extra or misplaced insertions, all in non-call positions: - `migrations/registry.ts:8608`: **inside a string** (the `acceptanceCriteria` of `dashboard-widget-stage-order-non-funnel-refused`). This one shipped. - `migrations/entries/semantic/18.dashboard-widget-stage-order-non-funnel-refused.ts:59`: the same string in the entry source. No bundle imports the entry file. Only the generated registry copy is bundled. - `ui/view.zod.ts:7032`: `export function defineForm(`, a **declaration**. esbuild drops a comment there, so it has no effect on the bundles. - `data/driver/turso.zod.ts:469`: `z.strictObject({`. The annotation **moved** from after the `z.` to the start of the call. See the note below. No new position exists beyond that move. Real calls with a type argument or a space before the paren: 0 in `src`. So including them changes nothing today. **Bundle level** (tsup JS pass, all 48 `.js` / `.mjs` bundles, base plugin vs fixed plugin, same source tree): | | before | after | |---|---|---| | PURE annotations, all bundles | 17202 | 17220 (+18) | | `migrations/index.js` / `.mjs` | 82 / 82 | 81 / 81 (the in-string one removed) | | the 20 bundles that reach the Turso schema (10 entries, ESM and CJS) | n | n + 1 each (the moved member-call annotation) | | the marker inside `` z.strictObject(DashboardWidgetSchema.shape) `` | 2 | 0 | | bundles differing once every annotation is stripped | | 0 of 48 | So the published code is byte-identical apart from annotations: −2 in the string and +20 at the one member call. **The member-call annotation (bounded in-place fix).** esbuild 0.28.1 (the version tsup resolves) drops an annotation written after a member access's dot. Measured on an input carrying both spellings: an unused `const` initialised from `z.` + PURE + `strictObject({…})` is kept, and its PURE annotation is gone from the output. An unused `const` initialised from PURE + `z.strictObject({…})` is dropped. So the old rule's output at `turso.zod.ts:469` marked nothing. The new rule puts the annotation where esbuild honours it, because it marks call expressions. It is the same defect class (the plugin wrote an annotation at a token that is not a call start). The fix is mechanical, pinned by the fixture test, in the file this claim holds, and adds no verification surface. ## Tree-shake control Measured with a consumer bundle per row: esbuild, `bundle`, `minify`, `treeShaking`, `zod` external. Sizes are bytes against the before tree, the after tree, and the before tree with every annotation stripped (what the plugin buys): | consumer imports | before | after | no annotations | |---|---|---|---| | `FieldSchema` from `./data` | 231,683 | 231,683 | 551,411 | | `TursoConfigSchema` from `./data` | 173,044 | 173,044 | 551,411 | | `defineStack` from the root | 954,689 | 954,689 | 968,107 | | `MIGRATION_SUPPORT_FLOOR` from `./migrations` | 1,548,424 | 1,548,408 | 1,548,521 | Tree-shaking is unchanged. The −16 bytes on `./migrations` is the 16-character marker that left the string. ## The test, and how it reads the build No spec test reads the real `packages/spec/dist`. Only gates do, and they run after the build step in `lint.yml`. turbo's `@objectstack/spec#test` depends on `^build` only, and spec has no workspace dependency. So in CI, `dist` is either absent or built in parallel when the suite runs. A test that read it would be skipped or racy there. This test therefore runs the real build itself. It calls tsup's own `build()` with the exported `mainConfig` (the plugin, target and treeshake included) over `src/migrations/index.ts`. It writes into a temp directory under the package's `node_modules/.cache` (so the bundle's `zod` import resolves), imports the bundle and compares: - `MIGRATIONS_BY_MAJOR`, `MIGRATION_MAJORS`, `MIGRATION_SUPPORT_FLOOR`, `RETIRED_KEYS_BY_MAJOR` and `RETIRED_DEFS_BY_MAJOR` must equal the source values exactly. Markers are **not** stripped before comparing: stripped, the buggy build compares equal too. A control requires at least one annotation in that bundle, so the equality cannot hold vacuously for a build that ran no plugin. - A fixture quotes every marked name in a single-quoted and a double-quoted string, template text, a template substitution, a regex literal, JSDoc, and trailing block and line comments, next to a declaration and the three real call shapes. It is built through the same `build()`. Each string survives byte-identical, and the real calls carry the annotation at the call start. - Four `annotatePureCalls` cases pin positions directly. They cover the unparsed hand-back, untouched non-calls, every call shape, and that removing the insertions gives the source back. ## Ablation The fix was committed first (`7189a7c485`). `node scripts/ablation-replace.mjs` replaced the plugin's call to `annotatePureCalls` with the old line-prefix rule. The anchor went from x1 to x0, and the blob from `7adc1d65404d` to `e73d50e2a72e`. The run, under the verify lock: - **2 failed | 4 passed.** The built `MIGRATIONS_BY_MAJOR` differs from the source at entry `dashboard-widget-stage-order-non-funnel-refused`, field `acceptanceCriteria`: the live entry. - The fixture build fails with esbuild `Unexpected "export"`. The old rule wrote an annotation inside a trailing block comment and closed it early. That is the comment-in-comment hazard the old docblock cites. - The four `annotatePureCalls` cases stay green, as expected: they call the function, not the plugin. The restore was proven by the tool: blob after restore = blob at HEAD = `7adc1d65404d`, and `git diff HEAD` is empty. No `dist` is involved. The test imports the plugin from source and builds in-test, so there was no rebuild leg. ## Verification (at `7272847e14`: the branch merged with `origin/main` `5f6b63a6fd`) - `pnpm --filter @objectstack/spec build`: green. `dist` has 0 in-string markers and 17220 annotations. - `vitest run scripts/pure-schema-construction.test.ts`: 6 passed. - `pnpm --filter @objectstack/spec test`: 585 files passed, 17211 tests passed, 1 todo. - `pnpm --filter @objectstack/spec typecheck`: green. `tsc -p tsconfig.scripts.json --listFiles` includes the new test and `tsup.config.ts`. - `dispatch-gates --commands --repo objectstack-ai/objectstack`: 67 derived. `--ran`: 65 run, all exit 0, and **2 NOT MEASURED** (exit 3, PREREQUISITE NOT MET): - `check:dual-build-cjs-loads` needs the `dist` of every workspace package (86 named). Spec's own half was measured directly: all 24 `require` export conditions of `@objectstack/spec` load. - `check:type-check-debt` needs 30 workspace dependencies built. `@objectstack/spec` is graduated from that ledger, and its own `typecheck` is green above. - Both are declared narrowings, and CI runs them. - `check:lean-entry-closure` ran against an objectql closure rebuilt at this head. `check:query-options-erasure` ran in 307 s. - After this head, `origin/main` moved 4 commits (to `7fa67dada3`). None touches `packages/spec`. The derivation files that changed (`scripts/pm/ensure-pm-labels.sh` and two `scripts/pm/fleet-write/` modules) are unrelated to these paths. ## Acceptance notes - The card attributes the altered text to the entry's `reason` (printed as `why:`). It is the entry's `acceptanceCriteria`, which `os migrate meta` prints as the `verify:` line (`packages/cli/src/commands/migrate/meta.ts:380`). The premise holds. Only the field name differs. - The old rule also rewrote 56 positions in `src/**/*.test.ts` files. No entry's graph reaches a test file, so none of them shipped. --- _Generated by [Claude Code](https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6073bb9 commit 4f83db5

3 files changed

Lines changed: 280 additions & 33 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): the published bundles' text equals the source again — the build marks pure calls only, never a string (#20686)
6+
7+
The build annotates each call of `lazySchema`, `strictObject` and `defineForm` as pure so a consumer's bundler can drop the schemas it never reaches. The rule that placed those annotations also rewrote a marked name quoted inside a string, so one D3 migration entry of protocol 18 (`dashboard-widget-stage-order-non-funnel-refused`) shipped a build-time comment marker inside its `acceptanceCriteria` text in `@objectstack/spec/migrations`, where its source reads `` `z.strictObject(DashboardWidgetSchema.shape)` ``. `os migrate meta` prints that text as the entry's `verify:` line when protocol 18 is the migration target. The published value now equals the source, character for character.
8+
9+
The annotations now come from the TypeScript parse of each file, so a marked name inside a string, template text, a comment or a declaration is never touched. Every call that carried an annotation still carries one, and the published code is otherwise unchanged. One call, `z.strictObject(…)` in the Turso driver config schema, now has its annotation in front of the call rather than after the `z.`, where esbuild had been dropping it. Measured with esbuild, a consumer that imports one schema from `@objectstack/spec/data` or `defineStack` from the root gets a bundle of the same size as before.
10+
11+
Clause-②: no
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The `pure-schema-construction` build plugin (`tsup.config.ts`) marks real
4+
// calls only (#20686). Its earlier line-prefix rule also rewrote a marked name
5+
// inside a STRING on a code line, so the published `./migrations` bundle
6+
// carried a PURE marker inside a D3 migration `reason` that its source does not
7+
// — text `os migrate meta` prints to an author.
8+
//
9+
// Both halves run the plugin through a real tsup build, not a copy of it: the
10+
// `./migrations` entry built with the package's own main options, and a fixture
11+
// that quotes every marked name in every place that is not a call.
12+
13+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14+
import path from 'node:path';
15+
import { pathToFileURL } from 'node:url';
16+
import { build } from 'tsup';
17+
import { afterAll, describe, expect, it } from 'vitest';
18+
19+
import { annotatePureCalls, mainConfig, pureSchemaConstruction } from '../tsup.config';
20+
import * as sourceMigrations from '../src/migrations/index';
21+
22+
/** This package's root — every read and write below stays inside it. */
23+
const PKG_DIR = path.resolve(__dirname, '..');
24+
const PURE = '/* @__PURE__ */ ';
25+
26+
// The bundles import `zod` like the published ones do, so they are written
27+
// where this package's own `node_modules` resolves it.
28+
const CACHE_DIR = path.join(PKG_DIR, 'node_modules', '.cache');
29+
mkdirSync(CACHE_DIR, { recursive: true });
30+
const WORK_DIR = mkdtempSync(path.join(CACHE_DIR, 'pure-schema-construction-'));
31+
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));
32+
33+
const DEPENDENCIES = Object.keys(
34+
(JSON.parse(readFileSync(path.join(PKG_DIR, 'package.json'), 'utf8')) as { dependencies?: Record<string, string> })
35+
.dependencies ?? {},
36+
);
37+
38+
/** Build one entry through tsup with the main pass's options; returns the ESM bundle's path and text. */
39+
async function buildEntry(entry: string, outName: string): Promise<{ file: string; text: string }> {
40+
const outDir = path.join(WORK_DIR, outName);
41+
await build({
42+
...mainConfig,
43+
config: false,
44+
entry: { index: entry },
45+
outDir,
46+
format: ['esm'],
47+
clean: false,
48+
dts: false,
49+
sourcemap: false,
50+
silent: true,
51+
external: DEPENDENCIES,
52+
});
53+
const file = path.join(outDir, 'index.mjs');
54+
return { file, text: readFileSync(file, 'utf8') };
55+
}
56+
57+
describe('the built ./migrations entry equals its source', () => {
58+
it('MIGRATIONS_BY_MAJOR and the registry tables beside it survive the build unchanged', async () => {
59+
const { file, text } = await buildEntry(path.join(PKG_DIR, 'src', 'migrations', 'index.ts'), 'migrations');
60+
61+
// The control: the plugin really ran in this build — otherwise the equality
62+
// below would hold for a build that marks nothing at all.
63+
expect(text.split(PURE).length - 1, 'no PURE annotation in the bundle: the plugin did not run').toBeGreaterThan(0);
64+
65+
const built = (await import(pathToFileURL(file).href)) as typeof sourceMigrations;
66+
expect(built.MIGRATIONS_BY_MAJOR).toEqual(sourceMigrations.MIGRATIONS_BY_MAJOR);
67+
expect(built.MIGRATION_MAJORS).toEqual(sourceMigrations.MIGRATION_MAJORS);
68+
expect(built.MIGRATION_SUPPORT_FLOOR).toBe(sourceMigrations.MIGRATION_SUPPORT_FLOOR);
69+
expect(built.RETIRED_KEYS_BY_MAJOR).toEqual(sourceMigrations.RETIRED_KEYS_BY_MAJOR);
70+
expect(built.RETIRED_DEFS_BY_MAJOR).toEqual(sourceMigrations.RETIRED_DEFS_BY_MAJOR);
71+
});
72+
});
73+
74+
// Every marked name, quoted everywhere a call is NOT: string, template text,
75+
// regex, JSDoc, trailing block and line comments, a declaration. Plus the three
76+
// real call shapes, one of them inside a template substitution.
77+
const FIXTURE = [
78+
'const lazySchema = <T>(fn: () => T): T => fn();',
79+
'const strictObject = (shape: object): object => ({ ...shape });',
80+
'const z = { strictObject };',
81+
"export const single = 'measured: `z.strictObject(DashboardWidgetSchema.shape)` accepts it';",
82+
'export const double = "lazySchema(fn) and defineForm(cfg)";',
83+
'export const template = `a strictObject({}) mention in template text`;',
84+
'export const substituted = `${lazySchema(() => \'x\')} then lazySchema( in text`;',
85+
'export const regex = /strictObject(\\w+)/.source;',
86+
'/** JSDoc naming strictObject(shape) and lazySchema(fn) */',
87+
'export const trailing = 1; /* strictObject(x) */ // lazySchema(y)',
88+
'export function defineForm(cfg: object): object { return cfg; }',
89+
'export const called = strictObject({ a: 1 });',
90+
'export const member = z.strictObject({ b: 1 });',
91+
'',
92+
].join('\n');
93+
94+
describe('a fixture quoting every marked name survives the build byte-identical', () => {
95+
it('keeps strings, template text and regex source as written, and marks the real calls', async () => {
96+
// `src/` in the path: the plugin's onLoad filter is the build's own.
97+
const fixture = path.join(WORK_DIR, 'fixture', 'src', 'quoted.ts');
98+
mkdirSync(path.dirname(fixture), { recursive: true });
99+
writeFileSync(fixture, FIXTURE);
100+
const { file, text } = await buildEntry(fixture, 'fixture-out');
101+
102+
const built = (await import(pathToFileURL(file).href)) as Record<string, unknown>;
103+
expect(built.single).toBe('measured: `z.strictObject(DashboardWidgetSchema.shape)` accepts it');
104+
expect(built.double).toBe('lazySchema(fn) and defineForm(cfg)');
105+
expect(built.template).toBe('a strictObject({}) mention in template text');
106+
expect(built.substituted).toBe('x then lazySchema( in text');
107+
expect(built.regex).toBe('strictObject(\\w+)');
108+
109+
// The real calls carry the annotation where esbuild honours it — at the
110+
// start of the call expression, member calls included.
111+
expect(text).toContain(`${PURE}strictObject({ a: 1 })`);
112+
expect(text).toContain(`${PURE}z.strictObject({ b: 1 })`);
113+
expect(text).toContain(`\${${PURE}lazySchema(`);
114+
});
115+
});
116+
117+
describe('annotatePureCalls — where the annotation goes', () => {
118+
it('hands back a file that names no marked constructor untouched, unparsed', () => {
119+
expect(annotatePureCalls('export const a = z.object({});\n', 'a.ts')).toBeUndefined();
120+
});
121+
122+
it('touches nothing that is not a call', () => {
123+
const text = [
124+
"const a = 'strictObject(';",
125+
'const b = `lazySchema(${x}) defineForm(`;',
126+
'/** @example lazySchema(() => z.object({})) */',
127+
'// strictObject(shape)',
128+
'const c = 1; /* defineForm(cfg) */',
129+
'const d = /lazySchema(x)/;',
130+
'export function defineForm(cfg: object) { return cfg; }',
131+
'',
132+
].join('\n');
133+
expect(annotatePureCalls(text, 'a.ts')).toBe(text);
134+
});
135+
136+
it('marks every call at the start of its call expression', () => {
137+
const text = [
138+
'const A = lazySchema(() => z.object({}));',
139+
'const B = strictObject({ a: z.string() });',
140+
'const C = defineForm({ schemaId: "x" });',
141+
'const D = z.strictObject({});',
142+
'const E = `${lazySchema(() => 1)} lazySchema(`;',
143+
'const F = lazySchema<Shape>(() => shape);',
144+
'',
145+
].join('\n');
146+
const out = annotatePureCalls(text, 'a.ts');
147+
expect(out).toBe(
148+
[
149+
`const A = ${PURE}lazySchema(() => z.object({}));`,
150+
`const B = ${PURE}strictObject({ a: z.string() });`,
151+
`const C = ${PURE}defineForm({ schemaId: "x" });`,
152+
`const D = ${PURE}z.strictObject({});`,
153+
`const E = \`\${${PURE}lazySchema(() => 1)} lazySchema(\`;`,
154+
`const F = ${PURE}lazySchema<Shape>(() => shape);`,
155+
'',
156+
].join('\n'),
157+
);
158+
// Insertions only: removing them gives the source back byte-identical.
159+
expect(out!.split(PURE).join('')).toBe(text);
160+
});
161+
162+
it('is the transform the plugin applies', () => {
163+
expect(pureSchemaConstruction.name).toBe('pure-schema-construction');
164+
expect(mainConfig.esbuildPlugins).toContain(pureSchemaConstruction);
165+
});
166+
});

‎packages/spec/tsup.config.ts‎

Lines changed: 103 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,103 @@
22

33
import { readFile } from 'node:fs/promises';
44
import { dirname, join } from 'node:path';
5+
import ts from 'typescript';
56
import { defineConfig, type Options } from 'tsup';
6-
import type { Plugin } from 'esbuild';
77

88
import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs';
99

10+
/**
11+
* esbuild's plugin type, read off tsup's own option rather than imported from
12+
* `esbuild`: this package does not depend on esbuild directly (tsup does), so
13+
* a bare `'esbuild'` specifier resolves for no type checker that reads this
14+
* file — and `scripts/pure-schema-construction.test.ts` puts it in front of one.
15+
*/
16+
type Plugin = NonNullable<Options['esbuildPlugins']>[number];
17+
18+
/**
19+
* The three marked constructors, each with its purity argument:
20+
* - `lazySchema(fn)` allocates a Proxy, defers `fn` to first use;
21+
* - `strictObject(shape, …)` builds a closed zod object (construction only —
22+
* the unknown-key error closure runs at parse time, not now);
23+
* - `defineForm(cfg)` parses STATIC author-time data through `FormViewSchema`
24+
* — pure computation whose only observable effect is a throw on invalid
25+
* static input, which this package's own build (`gen:schema` under
26+
* OS_EAGER_SCHEMAS) and tests still exercise.
27+
*/
28+
const PURE_CONSTRUCTORS: ReadonlySet<string> = new Set(['lazySchema', 'strictObject', 'defineForm']);
29+
30+
/** A file that never names a marked constructor is handed back untouched, unparsed. */
31+
const NAMES_A_PURE_CONSTRUCTOR = /\b(?:lazySchema|strictObject|defineForm)\b/;
32+
33+
const PURE_ANNOTATION = '/* @__PURE__ */ ';
34+
35+
/**
36+
* Insert the PURE annotation in front of every CALL of a marked constructor in
37+
* `source`, and nowhere else. Returns `undefined` when the file names none of
38+
* them, so the loader can leave the file to esbuild's own reading.
39+
*
40+
* ## Why a parse, and not a line or token heuristic
41+
*
42+
* Only a real call may be marked. A marked name also appears inside string
43+
* literals, template text, comments and JSDoc, and an annotation written there
44+
* changes the text itself: inside a string it ships in the published bundle as
45+
* part of the value (a D3 migration `reason` quoting `z.strictObject(…)` did,
46+
* so `os migrate meta` would have printed the marker inside an author-facing
47+
* sentence), and inside a block comment it terminates the outer comment and
48+
* breaks the parse (measured on `shared/strict-object.ts:48`).
49+
*
50+
* The rule this replaced split the file into lines and skipped the ones that
51+
* START like a comment. It could not see a string, a template, a trailing
52+
* comment or a function declaration on a code line. A bare `ts.createScanner`
53+
* pass cannot either, on its own: whether `/` starts a regular expression and
54+
* whether `}` resumes a template are grammar questions the scanner answers only
55+
* when the parser asks it to rescan. Measured over this package's `src` (1727
56+
* real calls): a scanner loop without those rescans mis-lexes every regex
57+
* literal containing a quote and loses 191 real calls, while marking two
58+
* mentions inside a JSDoc `@example`. So the TypeScript parser drives the
59+
* scanner here, and the marked positions are read off the syntax tree:
60+
*
61+
* - a `CallExpression` whose callee is one of the three names, bare
62+
* (`lazySchema(…)`) or as the member of a property access
63+
* (`z.strictObject(…)`);
64+
* - the annotation goes at the START of that call expression — the one place
65+
* esbuild honours it. `z./* PURE *\/ strictObject(…)` (the old rule's
66+
* output for a member call) is dropped by esbuild and marks nothing;
67+
* - string and template TEXT, comments, JSDoc and declarations are not call
68+
* expressions, so nothing in them is ever touched. A call inside a template
69+
* SUBSTITUTION is code, and is marked like any other.
70+
*
71+
* The output differs from `source` by inserted annotations only: removing
72+
* every inserted `PURE_ANNOTATION` gives `source` back byte-identical.
73+
*/
74+
export function annotatePureCalls(source: string, fileName: string): string | undefined {
75+
if (!NAMES_A_PURE_CONSTRUCTOR.test(source)) return undefined;
76+
const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
77+
// A Set: a marked call chained off another (`lazySchema(f).strictObject(…)`)
78+
// starts where the inner one does, and one annotation there is the mark.
79+
const starts = new Set<number>();
80+
const visit = (node: ts.Node): void => {
81+
if (ts.isCallExpression(node)) {
82+
const callee = node.expression;
83+
const name = ts.isIdentifier(callee)
84+
? callee
85+
: ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name)
86+
? callee.name
87+
: undefined;
88+
if (name && PURE_CONSTRUCTORS.has(name.text)) starts.add(node.getStart(file));
89+
}
90+
ts.forEachChild(node, visit);
91+
};
92+
visit(file);
93+
let out = '';
94+
let from = 0;
95+
for (const at of [...starts].sort((a, b) => a - b)) {
96+
out += source.slice(from, at) + PURE_ANNOTATION;
97+
from = at;
98+
}
99+
return out + source.slice(from);
100+
}
101+
10102
/**
11103
* [#10031] Annotate deferred schema construction as pure IN THE EMITTED
12104
* BUNDLES, so a CONSUMER's bundler may drop the schema consts its entry never
@@ -29,42 +121,18 @@ import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs'
29121
* the metadata-url-spelling agreement assertion, is now the build-time
30122
* `check:meta-url-spelling` gate).
31123
*
32-
* The word-boundary regex cannot hit the import specifier (`lazySchema,` /
33-
* `lazySchema }` carry no paren), the declaration (its token is
34-
* `lazySchema<T…>(`), or strings/comments in any way that survives bundling
35-
* (non-annotation comments are dropped from the bundle output).
124+
* WHERE the annotation goes is `annotatePureCalls` above: real calls only,
125+
* never text. `scripts/pure-schema-construction.test.ts` builds the
126+
* `./migrations` entry through this plugin and holds its published
127+
* `MIGRATIONS_BY_MAJOR` equal to the source value.
36128
*/
37-
const pureSchemaConstruction: Plugin = {
129+
export const pureSchemaConstruction: Plugin = {
38130
name: 'pure-schema-construction',
39131
setup(build) {
40-
// The three marked constructors, each with its purity argument:
41-
// - `lazySchema(fn)` allocates a Proxy, defers `fn` to first use;
42-
// - `strictObject(shape, …)` builds a closed zod object (construction
43-
// only — the unknown-key error closure runs at parse time, not now);
44-
// - `defineForm(cfg)` parses STATIC author-time data through
45-
// `FormViewSchema` — pure computation whose only observable effect is a
46-
// throw on invalid static input, which this package's own build
47-
// (`gen:schema` under OS_EAGER_SCHEMAS) and tests still exercise.
48-
const PURE_CALL = /\b(lazySchema|strictObject|defineForm)\(/g;
49132
build.onLoad({ filter: /src[\\/].*\.(ts|mts)$/ }, async (args) => {
50133
const source = await readFile(args.path, 'utf8');
51-
PURE_CALL.lastIndex = 0;
52-
if (!PURE_CALL.test(source)) return undefined;
53-
// Line-based on purpose: a marked name mentioned inside a JSDoc block
54-
// must NOT receive an annotation — a comment injected inside a comment
55-
// terminates the outer one and breaks the parse (measured on
56-
// shared/strict-object.ts:48). Comment lines start with `*`, `//` or
57-
// `/*` after indentation; every real call site in the tree starts with
58-
// code (surveyed: 1731 code lines vs 9 comment mentions), and no code
59-
// line carries a marked token in a trailing comment.
60-
const contents = source
61-
.split('\n')
62-
.map((line) => {
63-
const lead = line.trimStart();
64-
if (lead.startsWith('*') || lead.startsWith('//') || lead.startsWith('/*')) return line;
65-
return line.replace(PURE_CALL, '/* @__PURE__ */ $1(');
66-
})
67-
.join('\n');
134+
const contents = annotatePureCalls(source, args.path);
135+
if (contents === undefined) return undefined;
68136
return { contents, loader: 'ts' };
69137
});
70138
},
@@ -245,7 +313,9 @@ const swapServerOnlyGrammarArm: Plugin = {
245313
*/
246314
const isDts = process.env.BUILD_DTS === 'true';
247315

248-
const mainConfig: Options = {
316+
// Exported for `scripts/pure-schema-construction.test.ts`, which builds the
317+
// `./migrations` entry with these options; tsup itself reads only the default.
318+
export const mainConfig: Options = {
249319
entry: entries,
250320
splitting: false,
251321
sourcemap: true,

0 commit comments

Comments
 (0)