diff --git a/.changeset/lint-lazy-typescript.md b/.changeset/lint-lazy-typescript.md
new file mode 100644
index 0000000000..1aa8a75461
--- /dev/null
+++ b/.changeset/lint-lazy-typescript.md
@@ -0,0 +1,12 @@
+---
+'@objectstack/lint': patch
+---
+
+Load the TypeScript compiler lazily in `validateReactPageProps` instead of at module top level (ADR-0081 Phase 2 follow-up).
+
+`@objectstack/lint` sits on the kernel boot path, so the eager `import ts from 'typescript'` (framework#2482) made every boot parse the ~9 MB compiler (~70 ms+ on a warm laptop, worse on container cold starts) for a gate that only runs when a `kind:'react'` page is actually validated — a rare, trusted-tier case. It also hard-crashed boot in deployments that prune the package from the image (cloud's Docker pruner did exactly that; worked around in cloud#728).
+
+- The compiler now loads on the first validated react-source page, via a deferred `createRequire` (same bundling-safe pattern as driver-sqlite-wasm's knex-wasm-dialect); the public API stays synchronous and unchanged.
+- Importing the package, and validating stacks with no react pages, no longer touches `typescript` at all — so images that prune it boot fine and only fail (with an actionable error naming the package and the fix) if a react-source page is actually validated.
+- `typescript` remains a regular dependency of `@objectstack/lint`.
+- Guarded by a three-level regression test (structural no-eager-import scan, child-process probes of both dist formats, in-process lazy-load behavior), verified to go red if the eager import is reintroduced.
diff --git a/packages/lint/src/lazy-typescript.test.ts b/packages/lint/src/lazy-typescript.test.ts
new file mode 100644
index 0000000000..dfeb75335a
--- /dev/null
+++ b/packages/lint/src/lazy-typescript.test.ts
@@ -0,0 +1,98 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// Boot-path contract: importing @objectstack/lint must NOT load the TypeScript
+// compiler. The package sits on the kernel boot path, and `typescript` is
+// ~9 MB (and has been pruned from production images before — see
+// validate-react-page-props.ts); it must load lazily, on the first validated
+// `kind:'react'` page.
+//
+// Guarded at three levels because vitest inlines static imports through its
+// transform (they never hit the native require cache), so an in-worker
+// require.cache probe alone CANNOT catch a reintroduced eager import:
+// 1. structural — no src file may eagerly `import ... from 'typescript'`
+// (only `import type`, which erases at build);
+// 2. built dist — child `node` processes import both dist formats and prove
+// the compiler is absent until a react page is validated (skipped when
+// dist has not been built);
+// 3. behavioral — the lazy path really loads the compiler on demand and the
+// gate still produces findings.
+import { execFileSync } from 'node:child_process';
+import { existsSync, readFileSync, readdirSync } from 'node:fs';
+import { createRequire } from 'node:module';
+import { dirname, join } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { describe, it, expect } from 'vitest';
+
+const srcDir = dirname(fileURLToPath(import.meta.url));
+const distDir = join(srcDir, '..', 'dist');
+
+describe('lazy typescript loading (kernel boot-path contract)', () => {
+ it('no src file eagerly imports typescript (import type only)', () => {
+ // Static `import`/`export ... from 'typescript'` executes at module init in
+ // both dist formats; `import type` erases. Dynamic import()/createRequire
+ // inside functions are the sanctioned lazy forms and are not matched.
+ const eagerTsImport =
+ /^\s*(?:import\s+['"]typescript['"]|(?:import|export)\s+(?!type[\s{])[^;]*?from\s*['"]typescript['"]|import\s+\w+\s*=\s*require\(\s*['"]typescript['"]\s*\))/m;
+ const offenders = readdirSync(srcDir)
+ .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'))
+ .filter((f) => eagerTsImport.test(readFileSync(join(srcDir, f), 'utf8')));
+ expect(offenders).toEqual([]);
+ });
+
+ const reactStack = `{ pages: [{ name: 'r', kind: 'react', source: 'function Page(){ return ; }' }] }`;
+ const childBody = `
+ const probe = require('node:module').createRequire(process.cwd() + '/probe.js');
+ const tsLoaded = () => Object.keys(probe.cache ?? {}).some((p) => /[/\\\\]node_modules[/\\\\]typescript[/\\\\]/.test(p));
+ const fail = (msg) => { console.error(msg); process.exit(1); };
+ const check = (mod) => {
+ if (tsLoaded()) fail('typescript was loaded eagerly, at import time');
+ const findings = mod.validateReactPageProps(${reactStack});
+ if (!tsLoaded()) fail('typescript was not loaded by a react-page validation');
+ if (!findings.some((f) => f.rule === 'react-prop-missing-required')) fail('gate produced no finding');
+ console.log('OK');
+ };
+ `;
+
+ it.skipIf(!existsSync(join(distDir, 'index.cjs')))('built CJS dist does not load typescript until a react page is validated', () => {
+ const out = execFileSync(
+ process.execPath,
+ ['-e', `${childBody}; check(require(${JSON.stringify(join(distDir, 'index.cjs'))}));`],
+ { encoding: 'utf8' },
+ );
+ expect(out).toContain('OK');
+ });
+
+ it.skipIf(!existsSync(join(distDir, 'index.js')))('built ESM dist does not load typescript until a react page is validated', () => {
+ const out = execFileSync(
+ process.execPath,
+ [
+ '--input-type=module',
+ '-e',
+ `import { createRequire } from 'node:module';
+ const require = createRequire(process.cwd() + '/probe.js');
+ ${childBody};
+ check(await import(${JSON.stringify(pathToFileURL(join(distDir, 'index.js')).href)}));`,
+ ],
+ { encoding: 'utf8' },
+ );
+ expect(out).toContain('OK');
+ });
+
+ it('loads the compiler lazily in-process and the gate still works', async () => {
+ const req = createRequire(import.meta.url);
+ const tsLoaded = () => Object.keys(req.cache ?? {}).some((p) => /[/\\]node_modules[/\\]typescript[/\\]/.test(p));
+ const { validateReactPageProps } = await import('./index.js');
+
+ // Stacks without a react-source page never touch the compiler.
+ expect(validateReactPageProps({ pages: [{ name: 'p', kind: 'object' }] })).toEqual([]);
+ expect(validateReactPageProps({ pages: [{ name: 'r', kind: 'react', source: ' ' }] })).toEqual([]);
+ expect(tsLoaded()).toBe(false);
+
+ // The first react page with source pays the cost — and the gate still works.
+ const findings = validateReactPageProps({
+ pages: [{ name: 'r', kind: 'react', source: 'function Page(){ return ; }' }],
+ });
+ expect(tsLoaded()).toBe(true);
+ expect(findings.some((f) => f.rule === 'react-prop-missing-required' && /objectName/.test(f.message))).toBe(true);
+ });
+});
diff --git a/packages/lint/src/validate-react-page-props.ts b/packages/lint/src/validate-react-page-props.ts
index 51cae173c6..3683898e96 100644
--- a/packages/lint/src/validate-react-page-props.ts
+++ b/packages/lint/src/validate-react-page-props.ts
@@ -15,9 +15,44 @@
// unknown props (the contract's data props are a curated subset) — only the
// likely typos, to keep false positives near zero.
-import ts from 'typescript';
+import { createRequire } from 'node:module';
+import type ts from 'typescript';
import { REACT_BLOCKS } from '@objectstack/spec/ui';
+// The TypeScript compiler must NOT be imported at module top level: it is
+// ~9 MB of CJS (~70 ms+ to parse, worse on container cold starts), and
+// @objectstack/lint sits on the kernel boot path — while this gate only runs
+// when a `kind:'react'` page is actually validated (rare, trusted tier). An
+// eager import also hard-crashes boot in deployments that prune the package
+// from the image (cloud's Docker pruner did exactly that). So the compiler is
+// loaded lazily, on first use, and stays a regular dependency in package.json.
+// Guarded by lazy-typescript.test.ts.
+//
+// `node:module` is a Node builtin, untouched by esbuild/tsup, so the static
+// `createRequire` import survives bundling; the `createRequire(...)` call is
+// deferred because `import.meta.url` is rewritten to an empty stub in the CJS
+// build (same pattern as driver-sqlite-wasm's knex-wasm-dialect).
+let cachedTs: typeof ts | null = null;
+function loadTypeScript(): typeof ts {
+ if (cachedTs) return cachedTs;
+ const anchor =
+ typeof import.meta !== 'undefined' && import.meta.url
+ ? import.meta.url
+ : typeof __filename !== 'undefined'
+ ? __filename
+ : process.cwd() + '/';
+ try {
+ cachedTs = createRequire(anchor)('typescript') as typeof ts;
+ } catch (err) {
+ throw new Error(
+ `@objectstack/lint: validating a kind:'react' page requires the "typescript" package, which could not be loaded ` +
+ `(${err instanceof Error ? err.message : String(err)}). It is a declared dependency of @objectstack/lint — ` +
+ `if this deployment prunes packages, keep "typescript" in the image; it is only loaded when a react-source page is validated.`,
+ );
+ }
+ return cachedTs;
+}
+
export type ReactPropSeverity = 'error' | 'warning';
export interface ReactPropFinding {
@@ -82,23 +117,27 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] {
if (typeof source !== 'string' || source.trim() === '') continue;
const name = String(page.name ?? `#${p}`);
+ // Outside the try below on purpose: a missing compiler must surface as an
+ // error, not be swallowed as "unparseable source".
+ const tsc = loadTypeScript();
+
let sf: ts.SourceFile;
try {
- sf = ts.createSourceFile('page.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
+ sf = tsc.createSourceFile('page.tsx', source, tsc.ScriptTarget.Latest, true, tsc.ScriptKind.TSX);
} catch {
continue; // the syntax gate reports unparseable sources
}
const visit = (node: ts.Node): void => {
- if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
+ if (tsc.isJsxOpeningElement(node) || tsc.isJsxSelfClosingElement(node)) {
const tag = node.tagName.getText(sf);
const block = BLOCKS.get(tag);
if (block) {
let hasSpread = false;
const used = new Set();
for (const a of node.attributes.properties) {
- if (ts.isJsxSpreadAttribute(a)) { hasSpread = true; continue; }
- if (ts.isJsxAttribute(a)) used.add(a.name.getText(sf));
+ if (tsc.isJsxSpreadAttribute(a)) { hasSpread = true; continue; }
+ if (tsc.isJsxAttribute(a)) used.add(a.name.getText(sf));
}
const where = `page "${name}" › <${tag}>`;
const path = `pages[${p}].source`;
@@ -129,7 +168,7 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] {
}
}
}
- ts.forEachChild(node, visit);
+ tsc.forEachChild(node, visit);
};
visit(sf);
}
diff --git a/packages/lint/tsconfig.json b/packages/lint/tsconfig.json
index 5e3095a8a4..1294309a21 100644
--- a/packages/lint/tsconfig.json
+++ b/packages/lint/tsconfig.json
@@ -2,7 +2,8 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
- "rootDir": "./src"
+ "rootDir": "./src",
+ "types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]