Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/20686-pure-plugin-skips-strings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/spec': patch
---

fix(spec): the published bundles' text equals the source again — the build marks pure calls only, never a string (#20686)

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.

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.

Clause-②: no
166 changes: 166 additions & 0 deletions packages/spec/scripts/pure-schema-construction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// The `pure-schema-construction` build plugin (`tsup.config.ts`) marks real
// calls only (#20686). Its earlier line-prefix rule also rewrote a marked name
// inside a STRING on a code line, so the published `./migrations` bundle
// carried a PURE marker inside a D3 migration `reason` that its source does not
// — text `os migrate meta` prints to an author.
//
// Both halves run the plugin through a real tsup build, not a copy of it: the
// `./migrations` entry built with the package's own main options, and a fixture
// that quotes every marked name in every place that is not a call.

import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { build } from 'tsup';
import { afterAll, describe, expect, it } from 'vitest';

import { annotatePureCalls, mainConfig, pureSchemaConstruction } from '../tsup.config';
import * as sourceMigrations from '../src/migrations/index';

/** This package's root — every read and write below stays inside it. */
const PKG_DIR = path.resolve(__dirname, '..');
const PURE = '/* @__PURE__ */ ';

// The bundles import `zod` like the published ones do, so they are written
// where this package's own `node_modules` resolves it.
const CACHE_DIR = path.join(PKG_DIR, 'node_modules', '.cache');
mkdirSync(CACHE_DIR, { recursive: true });
const WORK_DIR = mkdtempSync(path.join(CACHE_DIR, 'pure-schema-construction-'));
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));

const DEPENDENCIES = Object.keys(
(JSON.parse(readFileSync(path.join(PKG_DIR, 'package.json'), 'utf8')) as { dependencies?: Record<string, string> })
.dependencies ?? {},
);

/** Build one entry through tsup with the main pass's options; returns the ESM bundle's path and text. */
async function buildEntry(entry: string, outName: string): Promise<{ file: string; text: string }> {
const outDir = path.join(WORK_DIR, outName);
await build({
...mainConfig,
config: false,
entry: { index: entry },
outDir,
format: ['esm'],
clean: false,
dts: false,
sourcemap: false,
silent: true,
external: DEPENDENCIES,
});
const file = path.join(outDir, 'index.mjs');
return { file, text: readFileSync(file, 'utf8') };
}

describe('the built ./migrations entry equals its source', () => {
it('MIGRATIONS_BY_MAJOR and the registry tables beside it survive the build unchanged', async () => {
const { file, text } = await buildEntry(path.join(PKG_DIR, 'src', 'migrations', 'index.ts'), 'migrations');

// The control: the plugin really ran in this build — otherwise the equality
// below would hold for a build that marks nothing at all.
expect(text.split(PURE).length - 1, 'no PURE annotation in the bundle: the plugin did not run').toBeGreaterThan(0);

const built = (await import(pathToFileURL(file).href)) as typeof sourceMigrations;
expect(built.MIGRATIONS_BY_MAJOR).toEqual(sourceMigrations.MIGRATIONS_BY_MAJOR);
expect(built.MIGRATION_MAJORS).toEqual(sourceMigrations.MIGRATION_MAJORS);
expect(built.MIGRATION_SUPPORT_FLOOR).toBe(sourceMigrations.MIGRATION_SUPPORT_FLOOR);
expect(built.RETIRED_KEYS_BY_MAJOR).toEqual(sourceMigrations.RETIRED_KEYS_BY_MAJOR);
expect(built.RETIRED_DEFS_BY_MAJOR).toEqual(sourceMigrations.RETIRED_DEFS_BY_MAJOR);
});
});

// Every marked name, quoted everywhere a call is NOT: string, template text,
// regex, JSDoc, trailing block and line comments, a declaration. Plus the three
// real call shapes, one of them inside a template substitution.
const FIXTURE = [
'const lazySchema = <T>(fn: () => T): T => fn();',
'const strictObject = (shape: object): object => ({ ...shape });',
'const z = { strictObject };',
"export const single = 'measured: `z.strictObject(DashboardWidgetSchema.shape)` accepts it';",
'export const double = "lazySchema(fn) and defineForm(cfg)";',
'export const template = `a strictObject({}) mention in template text`;',
'export const substituted = `${lazySchema(() => \'x\')} then lazySchema( in text`;',
'export const regex = /strictObject(\\w+)/.source;',
'/** JSDoc naming strictObject(shape) and lazySchema(fn) */',
'export const trailing = 1; /* strictObject(x) */ // lazySchema(y)',
'export function defineForm(cfg: object): object { return cfg; }',
'export const called = strictObject({ a: 1 });',
'export const member = z.strictObject({ b: 1 });',
'',
].join('\n');

describe('a fixture quoting every marked name survives the build byte-identical', () => {
it('keeps strings, template text and regex source as written, and marks the real calls', async () => {
// `src/` in the path: the plugin's onLoad filter is the build's own.
const fixture = path.join(WORK_DIR, 'fixture', 'src', 'quoted.ts');
mkdirSync(path.dirname(fixture), { recursive: true });
writeFileSync(fixture, FIXTURE);
const { file, text } = await buildEntry(fixture, 'fixture-out');

const built = (await import(pathToFileURL(file).href)) as Record<string, unknown>;
expect(built.single).toBe('measured: `z.strictObject(DashboardWidgetSchema.shape)` accepts it');
expect(built.double).toBe('lazySchema(fn) and defineForm(cfg)');
expect(built.template).toBe('a strictObject({}) mention in template text');
expect(built.substituted).toBe('x then lazySchema( in text');
expect(built.regex).toBe('strictObject(\\w+)');

// The real calls carry the annotation where esbuild honours it — at the
// start of the call expression, member calls included.
expect(text).toContain(`${PURE}strictObject({ a: 1 })`);
expect(text).toContain(`${PURE}z.strictObject({ b: 1 })`);
expect(text).toContain(`\${${PURE}lazySchema(`);
});
});

describe('annotatePureCalls — where the annotation goes', () => {
it('hands back a file that names no marked constructor untouched, unparsed', () => {
expect(annotatePureCalls('export const a = z.object({});\n', 'a.ts')).toBeUndefined();
});

it('touches nothing that is not a call', () => {
const text = [
"const a = 'strictObject(';",
'const b = `lazySchema(${x}) defineForm(`;',
'/** @example lazySchema(() => z.object({})) */',
'// strictObject(shape)',
'const c = 1; /* defineForm(cfg) */',
'const d = /lazySchema(x)/;',
'export function defineForm(cfg: object) { return cfg; }',
'',
].join('\n');
expect(annotatePureCalls(text, 'a.ts')).toBe(text);
});

it('marks every call at the start of its call expression', () => {
const text = [
'const A = lazySchema(() => z.object({}));',
'const B = strictObject({ a: z.string() });',
'const C = defineForm({ schemaId: "x" });',
'const D = z.strictObject({});',
'const E = `${lazySchema(() => 1)} lazySchema(`;',
'const F = lazySchema<Shape>(() => shape);',
'',
].join('\n');
const out = annotatePureCalls(text, 'a.ts');
expect(out).toBe(
[
`const A = ${PURE}lazySchema(() => z.object({}));`,
`const B = ${PURE}strictObject({ a: z.string() });`,
`const C = ${PURE}defineForm({ schemaId: "x" });`,
`const D = ${PURE}z.strictObject({});`,
`const E = \`\${${PURE}lazySchema(() => 1)} lazySchema(\`;`,
`const F = ${PURE}lazySchema<Shape>(() => shape);`,
'',
].join('\n'),
);
// Insertions only: removing them gives the source back byte-identical.
expect(out!.split(PURE).join('')).toBe(text);
});

it('is the transform the plugin applies', () => {
expect(pureSchemaConstruction.name).toBe('pure-schema-construction');
expect(mainConfig.esbuildPlugins).toContain(pureSchemaConstruction);
});
});
136 changes: 103 additions & 33 deletions packages/spec/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,103 @@

import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import ts from 'typescript';
import { defineConfig, type Options } from 'tsup';
import type { Plugin } from 'esbuild';

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

/**
* esbuild's plugin type, read off tsup's own option rather than imported from
* `esbuild`: this package does not depend on esbuild directly (tsup does), so
* a bare `'esbuild'` specifier resolves for no type checker that reads this
* file — and `scripts/pure-schema-construction.test.ts` puts it in front of one.
*/
type Plugin = NonNullable<Options['esbuildPlugins']>[number];

/**
* The three marked constructors, each with its purity argument:
* - `lazySchema(fn)` allocates a Proxy, defers `fn` to first use;
* - `strictObject(shape, …)` builds a closed zod object (construction only —
* the unknown-key error closure runs at parse time, not now);
* - `defineForm(cfg)` parses STATIC author-time data through `FormViewSchema`
* — pure computation whose only observable effect is a throw on invalid
* static input, which this package's own build (`gen:schema` under
* OS_EAGER_SCHEMAS) and tests still exercise.
*/
const PURE_CONSTRUCTORS: ReadonlySet<string> = new Set(['lazySchema', 'strictObject', 'defineForm']);

/** A file that never names a marked constructor is handed back untouched, unparsed. */
const NAMES_A_PURE_CONSTRUCTOR = /\b(?:lazySchema|strictObject|defineForm)\b/;

const PURE_ANNOTATION = '/* @__PURE__ */ ';

/**
* Insert the PURE annotation in front of every CALL of a marked constructor in
* `source`, and nowhere else. Returns `undefined` when the file names none of
* them, so the loader can leave the file to esbuild's own reading.
*
* ## Why a parse, and not a line or token heuristic
*
* Only a real call may be marked. A marked name also appears inside string
* literals, template text, comments and JSDoc, and an annotation written there
* changes the text itself: inside a string it ships in the published bundle as
* part of the value (a D3 migration `reason` quoting `z.strictObject(…)` did,
* so `os migrate meta` would have printed the marker inside an author-facing
* sentence), and inside a block comment it terminates the outer comment and
* breaks the parse (measured on `shared/strict-object.ts:48`).
*
* The rule this replaced split the file into lines and skipped the ones that
* START like a comment. It could not see a string, a template, a trailing
* comment or a function declaration on a code line. A bare `ts.createScanner`
* pass cannot either, on its own: whether `/` starts a regular expression and
* whether `}` resumes a template are grammar questions the scanner answers only
* when the parser asks it to rescan. Measured over this package's `src` (1727
* real calls): a scanner loop without those rescans mis-lexes every regex
* literal containing a quote and loses 191 real calls, while marking two
* mentions inside a JSDoc `@example`. So the TypeScript parser drives the
* scanner here, and the marked positions are read off the syntax tree:
*
* - a `CallExpression` whose callee is one of the three names, bare
* (`lazySchema(…)`) or as the member of a property access
* (`z.strictObject(…)`);
* - the annotation goes at the START of that call expression — the one place
* esbuild honours it. `z./* PURE *\/ strictObject(…)` (the old rule's
* output for a member call) is dropped by esbuild and marks nothing;
* - string and template TEXT, comments, JSDoc and declarations are not call
* expressions, so nothing in them is ever touched. A call inside a template
* SUBSTITUTION is code, and is marked like any other.
*
* The output differs from `source` by inserted annotations only: removing
* every inserted `PURE_ANNOTATION` gives `source` back byte-identical.
*/
export function annotatePureCalls(source: string, fileName: string): string | undefined {
if (!NAMES_A_PURE_CONSTRUCTOR.test(source)) return undefined;
const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
// A Set: a marked call chained off another (`lazySchema(f).strictObject(…)`)
// starts where the inner one does, and one annotation there is the mark.
const starts = new Set<number>();
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node)) {
const callee = node.expression;
const name = ts.isIdentifier(callee)
? callee
: ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name)
? callee.name
: undefined;
if (name && PURE_CONSTRUCTORS.has(name.text)) starts.add(node.getStart(file));
}
ts.forEachChild(node, visit);
};
visit(file);
let out = '';
let from = 0;
for (const at of [...starts].sort((a, b) => a - b)) {
out += source.slice(from, at) + PURE_ANNOTATION;
from = at;
}
return out + source.slice(from);
}

/**
* [#10031] Annotate deferred schema construction as pure IN THE EMITTED
* BUNDLES, so a CONSUMER's bundler may drop the schema consts its entry never
Expand All @@ -29,42 +121,18 @@ import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs'
* the metadata-url-spelling agreement assertion, is now the build-time
* `check:meta-url-spelling` gate).
*
* The word-boundary regex cannot hit the import specifier (`lazySchema,` /
* `lazySchema }` carry no paren), the declaration (its token is
* `lazySchema<T…>(`), or strings/comments in any way that survives bundling
* (non-annotation comments are dropped from the bundle output).
* WHERE the annotation goes is `annotatePureCalls` above: real calls only,
* never text. `scripts/pure-schema-construction.test.ts` builds the
* `./migrations` entry through this plugin and holds its published
* `MIGRATIONS_BY_MAJOR` equal to the source value.
*/
const pureSchemaConstruction: Plugin = {
export const pureSchemaConstruction: Plugin = {
name: 'pure-schema-construction',
setup(build) {
// The three marked constructors, each with its purity argument:
// - `lazySchema(fn)` allocates a Proxy, defers `fn` to first use;
// - `strictObject(shape, …)` builds a closed zod object (construction
// only — the unknown-key error closure runs at parse time, not now);
// - `defineForm(cfg)` parses STATIC author-time data through
// `FormViewSchema` — pure computation whose only observable effect is a
// throw on invalid static input, which this package's own build
// (`gen:schema` under OS_EAGER_SCHEMAS) and tests still exercise.
const PURE_CALL = /\b(lazySchema|strictObject|defineForm)\(/g;
build.onLoad({ filter: /src[\\/].*\.(ts|mts)$/ }, async (args) => {
const source = await readFile(args.path, 'utf8');
PURE_CALL.lastIndex = 0;
if (!PURE_CALL.test(source)) return undefined;
// Line-based on purpose: a marked name mentioned inside a JSDoc block
// must NOT receive an annotation — a comment injected inside a comment
// terminates the outer one and breaks the parse (measured on
// shared/strict-object.ts:48). Comment lines start with `*`, `//` or
// `/*` after indentation; every real call site in the tree starts with
// code (surveyed: 1731 code lines vs 9 comment mentions), and no code
// line carries a marked token in a trailing comment.
const contents = source
.split('\n')
.map((line) => {
const lead = line.trimStart();
if (lead.startsWith('*') || lead.startsWith('//') || lead.startsWith('/*')) return line;
return line.replace(PURE_CALL, '/* @__PURE__ */ $1(');
})
.join('\n');
const contents = annotatePureCalls(source, args.path);
if (contents === undefined) return undefined;
return { contents, loader: 'ts' };
});
},
Expand Down Expand Up @@ -245,7 +313,9 @@ const swapServerOnlyGrammarArm: Plugin = {
*/
const isDts = process.env.BUILD_DTS === 'true';

const mainConfig: Options = {
// Exported for `scripts/pure-schema-construction.test.ts`, which builds the
// `./migrations` entry with these options; tsup itself reads only the default.
export const mainConfig: Options = {
entry: entries,
splitting: false,
sourcemap: true,
Expand Down
Loading