|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// Lazify codemod — wrap every `export const XxxSchema = <expr>;` in |
| 4 | +// `lazySchema(() => <expr>)` so Zod construction is deferred until first use. |
| 5 | +// |
| 6 | +// Usage: |
| 7 | +// pnpm tsx scripts/lazify-schemas.ts # transform src/**/*.zod.ts |
| 8 | +// pnpm tsx scripts/lazify-schemas.ts --dry # preview, do not write |
| 9 | +// pnpm tsx scripts/lazify-schemas.ts src/data # restrict to a subdir |
| 10 | +// |
| 11 | +// Strategy: parse each .zod.ts as text, find top-level |
| 12 | +// `export const NAME(\s*:\s*Type)? = <expr>;` declarations, and rewrite to |
| 13 | +// `export const NAME$1 = lazySchema(() => <expr>);`. Brace/paren/bracket/template |
| 14 | +// depth is tracked to find the terminating semicolon. String literals and |
| 15 | +// comments are skipped to avoid false matches. |
| 16 | +// |
| 17 | +// Skipped: |
| 18 | +// - lines whose RHS is exactly `z.lazy(...)` — already lazy |
| 19 | +// - already-wrapped declarations (RHS starts with `lazySchema(`) |
| 20 | +// - non-`Schema`-suffixed identifiers |
| 21 | +// |
| 22 | +// The codemod also injects: |
| 23 | +// `import { lazySchema } from '<relative path>/shared/lazy-schema';` |
| 24 | +// if not already present. |
| 25 | + |
| 26 | +import fs from 'fs'; |
| 27 | +import path from 'path'; |
| 28 | +import { fileURLToPath } from 'url'; |
| 29 | + |
| 30 | +const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 31 | +const repoRoot = path.resolve(__dirname, '..'); |
| 32 | +const srcRoot = path.join(repoRoot, 'src'); |
| 33 | +const sharedDir = path.join(srcRoot, 'shared'); |
| 34 | + |
| 35 | +interface Args { |
| 36 | + dry: boolean; |
| 37 | + targets: string[]; |
| 38 | +} |
| 39 | + |
| 40 | +function parseArgs(argv: string[]): Args { |
| 41 | + const dry = argv.includes('--dry'); |
| 42 | + const targets = argv.filter((a) => !a.startsWith('--')); |
| 43 | + if (targets.length === 0) targets.push(srcRoot); |
| 44 | + return { dry, targets: targets.map((t) => path.resolve(t)) }; |
| 45 | +} |
| 46 | + |
| 47 | +function listZodFiles(root: string): string[] { |
| 48 | + const out: string[] = []; |
| 49 | + const visit = (dir: string) => { |
| 50 | + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { |
| 51 | + if (ent.name === 'node_modules' || ent.name === 'dist' || ent.name.startsWith('.')) continue; |
| 52 | + const p = path.join(dir, ent.name); |
| 53 | + if (ent.isDirectory()) visit(p); |
| 54 | + else if (ent.isFile() && ent.name.endsWith('.zod.ts')) out.push(p); |
| 55 | + } |
| 56 | + }; |
| 57 | + if (fs.statSync(root).isDirectory()) visit(root); |
| 58 | + else out.push(root); |
| 59 | + return out; |
| 60 | +} |
| 61 | + |
| 62 | +interface Decl { |
| 63 | + start: number; // index of `export` |
| 64 | + rhsStart: number; // index just after `=` |
| 65 | + end: number; // index of `;` (inclusive) |
| 66 | + name: string; |
| 67 | + signature: string; // text from `export const NAME(:Type)? = ` (the prefix to keep) |
| 68 | + rhs: string; // raw RHS without trailing `;` |
| 69 | +} |
| 70 | + |
| 71 | +// Find top-level `export const NAME(:Type)? = <expr>;` declarations in source. |
| 72 | +// Honors string/comment/template boundaries so braces inside them don't |
| 73 | +// confuse depth tracking. |
| 74 | +function findDeclarations(src: string): Decl[] { |
| 75 | + const decls: Decl[] = []; |
| 76 | + const len = src.length; |
| 77 | + let i = 0; |
| 78 | + let depth = 0; // top-level when 0 |
| 79 | + // scan top-level only; inside any nested block we skip |
| 80 | + while (i < len) { |
| 81 | + // Skip whitespace |
| 82 | + if (src[i] === ' ' || src[i] === '\t' || src[i] === '\n' || src[i] === '\r') { i++; continue; } |
| 83 | + // Skip line comment |
| 84 | + if (src[i] === '/' && src[i + 1] === '/') { |
| 85 | + while (i < len && src[i] !== '\n') i++; |
| 86 | + continue; |
| 87 | + } |
| 88 | + // Skip block comment |
| 89 | + if (src[i] === '/' && src[i + 1] === '*') { |
| 90 | + i += 2; |
| 91 | + while (i < len && !(src[i] === '*' && src[i + 1] === '/')) i++; |
| 92 | + i += 2; |
| 93 | + continue; |
| 94 | + } |
| 95 | + // String / template literal |
| 96 | + if (src[i] === '"' || src[i] === "'" || src[i] === '`') { |
| 97 | + i = skipString(src, i); |
| 98 | + continue; |
| 99 | + } |
| 100 | + // Brace-aware nesting at top level |
| 101 | + if (src[i] === '{' || src[i] === '(' || src[i] === '[') { depth++; i++; continue; } |
| 102 | + if (src[i] === '}' || src[i] === ')' || src[i] === ']') { depth--; i++; continue; } |
| 103 | + |
| 104 | + if (depth === 0 && src.startsWith('export', i)) { |
| 105 | + const m = matchExportConst(src, i); |
| 106 | + if (m) { |
| 107 | + decls.push(m); |
| 108 | + i = m.end + 1; |
| 109 | + continue; |
| 110 | + } |
| 111 | + } |
| 112 | + i++; |
| 113 | + } |
| 114 | + return decls; |
| 115 | +} |
| 116 | + |
| 117 | +function skipString(src: string, start: number): number { |
| 118 | + const quote = src[start]; |
| 119 | + let i = start + 1; |
| 120 | + while (i < src.length) { |
| 121 | + if (src[i] === '\\') { i += 2; continue; } |
| 122 | + if (quote === '`' && src[i] === '$' && src[i + 1] === '{') { |
| 123 | + // template expression — skip balanced |
| 124 | + i += 2; |
| 125 | + let d = 1; |
| 126 | + while (i < src.length && d > 0) { |
| 127 | + if (src[i] === '"' || src[i] === "'" || src[i] === '`') { i = skipString(src, i); continue; } |
| 128 | + if (src[i] === '{') d++; |
| 129 | + else if (src[i] === '}') d--; |
| 130 | + i++; |
| 131 | + } |
| 132 | + continue; |
| 133 | + } |
| 134 | + if (src[i] === quote) return i + 1; |
| 135 | + i++; |
| 136 | + } |
| 137 | + return i; |
| 138 | +} |
| 139 | + |
| 140 | +const HEADER_RE = /^export\s+const\s+([A-Za-z_$][\w$]*)(\s*:\s*[^=]+?)?\s*=\s*/; |
| 141 | + |
| 142 | +function matchExportConst(src: string, start: number): Decl | null { |
| 143 | + const slice = src.slice(start, start + 4096); |
| 144 | + const m = HEADER_RE.exec(slice); |
| 145 | + if (!m) return null; |
| 146 | + const name = m[1]; |
| 147 | + if (!name.endsWith('Schema') && !name.endsWith('Schemas')) { |
| 148 | + // Only target *Schema / *Schemas exports |
| 149 | + return null; |
| 150 | + } |
| 151 | + const rhsStart = start + m[0].length; |
| 152 | + |
| 153 | + // Walk RHS until balanced top-level `;` |
| 154 | + const len = src.length; |
| 155 | + let i = rhsStart; |
| 156 | + let depth = 0; |
| 157 | + while (i < len) { |
| 158 | + const c = src[i]; |
| 159 | + if (c === '/' && src[i + 1] === '/') { while (i < len && src[i] !== '\n') i++; continue; } |
| 160 | + if (c === '/' && src[i + 1] === '*') { |
| 161 | + i += 2; while (i < len && !(src[i] === '*' && src[i + 1] === '/')) i++; i += 2; continue; |
| 162 | + } |
| 163 | + if (c === '"' || c === "'" || c === '`') { i = skipString(src, i); continue; } |
| 164 | + if (c === '{' || c === '(' || c === '[') { depth++; i++; continue; } |
| 165 | + if (c === '}' || c === ')' || c === ']') { depth--; i++; continue; } |
| 166 | + if (c === ';' && depth === 0) { |
| 167 | + const rhs = src.slice(rhsStart, i).trimEnd(); |
| 168 | + return { |
| 169 | + start, rhsStart, end: i, name, |
| 170 | + signature: src.slice(start, rhsStart), |
| 171 | + rhs, |
| 172 | + }; |
| 173 | + } |
| 174 | + i++; |
| 175 | + } |
| 176 | + return null; |
| 177 | +} |
| 178 | + |
| 179 | +function shouldSkip(rhs: string): boolean { |
| 180 | + const trimmed = rhs.trim(); |
| 181 | + if (trimmed.startsWith('lazySchema(')) return true; |
| 182 | + if (/^z\.lazy\s*\(/.test(trimmed)) return true; |
| 183 | + return false; |
| 184 | +} |
| 185 | + |
| 186 | +function relImportPath(file: string): string { |
| 187 | + const fileDir = path.dirname(file); |
| 188 | + const target = path.join(sharedDir, 'lazy-schema'); |
| 189 | + let rel = path.relative(fileDir, target); |
| 190 | + if (!rel.startsWith('.')) rel = './' + rel; |
| 191 | + return rel; |
| 192 | +} |
| 193 | + |
| 194 | +function ensureLazyImport(src: string, file: string): string { |
| 195 | + if (/from ['"][^'"]*shared\/lazy-schema['"]/.test(src)) return src; |
| 196 | + const importPath = relImportPath(file); |
| 197 | + const importLine = `import { lazySchema } from '${importPath}';\n`; |
| 198 | + |
| 199 | + // Insert after the last top-level `import ... from '...';` in the file header. |
| 200 | + // Stop at the first non-import, non-comment statement. |
| 201 | + const importBlock = /^(?:[ \t]*\/\/[^\n]*\n|[ \t]*\/\*[\s\S]*?\*\/\s*\n|[ \t]*import[\s\S]*?from[ \t]*['"][^'"]+['"];[ \t]*\n|[ \t]*\n)+/.exec(src); |
| 202 | + if (importBlock) { |
| 203 | + const idx = importBlock[0].length; |
| 204 | + return src.slice(0, idx) + importLine + src.slice(idx); |
| 205 | + } |
| 206 | + return importLine + src; |
| 207 | +} |
| 208 | + |
| 209 | +function transform(src: string, file: string): { out: string; changed: number; skipped: number } { |
| 210 | + const decls = findDeclarations(src); |
| 211 | + if (decls.length === 0) return { out: src, changed: 0, skipped: 0 }; |
| 212 | + |
| 213 | + // Process in reverse so offsets stay valid |
| 214 | + let out = src; |
| 215 | + let changed = 0; |
| 216 | + let skipped = 0; |
| 217 | + for (let k = decls.length - 1; k >= 0; k--) { |
| 218 | + const d = decls[k]; |
| 219 | + if (shouldSkip(d.rhs)) { skipped++; continue; } |
| 220 | + const wrapped = `lazySchema(() => ${d.rhs})`; |
| 221 | + out = out.slice(0, d.rhsStart) + wrapped + out.slice(d.end); |
| 222 | + changed++; |
| 223 | + } |
| 224 | + |
| 225 | + if (changed > 0) out = ensureLazyImport(out, file); |
| 226 | + return { out, changed, skipped }; |
| 227 | +} |
| 228 | + |
| 229 | +function main() { |
| 230 | + const { dry, targets } = parseArgs(process.argv.slice(2)); |
| 231 | + const files = targets.flatMap(listZodFiles); |
| 232 | + console.log(`Found ${files.length} .zod.ts files in ${targets.length} target(s)`); |
| 233 | + |
| 234 | + let totalChanged = 0; |
| 235 | + let totalSkipped = 0; |
| 236 | + let filesChanged = 0; |
| 237 | + |
| 238 | + for (const file of files) { |
| 239 | + // Don't transform the lazy-schema utility itself |
| 240 | + if (file.endsWith('lazy-schema.ts') || file.endsWith('lazy-schema.test.ts')) continue; |
| 241 | + |
| 242 | + const src = fs.readFileSync(file, 'utf8'); |
| 243 | + const { out, changed, skipped } = transform(src, file); |
| 244 | + totalChanged += changed; |
| 245 | + totalSkipped += skipped; |
| 246 | + if (changed > 0) { |
| 247 | + filesChanged++; |
| 248 | + const rel = path.relative(repoRoot, file); |
| 249 | + console.log(` ${dry ? '[dry] ' : ''}${rel}: +${changed} wrapped, ${skipped} skipped`); |
| 250 | + if (!dry) fs.writeFileSync(file, out); |
| 251 | + } |
| 252 | + } |
| 253 | + |
| 254 | + console.log(`\n${dry ? '[DRY] ' : ''}Done. ${filesChanged} files, ${totalChanged} schemas wrapped, ${totalSkipped} skipped.`); |
| 255 | +} |
| 256 | + |
| 257 | +main(); |
0 commit comments