|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// Boot-path contract: importing @objectstack/lint must NOT load its heavy, |
| 4 | +// gate-only dependencies. The package sits on the kernel boot path, while |
| 5 | +// each dep below serves a gate that only runs when a `kind:'react'` page is |
| 6 | +// actually validated — so each must load lazily, on first use: |
| 7 | +// - `typescript` (~9 MB, and has been pruned from production images before |
| 8 | +// — see validate-react-page-props.ts), loaded by the react-props gate; |
| 9 | +// - `sucrase` (~1.5 MB), loaded by the react syntax gate |
| 10 | +// (validate-react-pages.ts). |
| 11 | +// |
| 12 | +// Guarded at three levels because vitest inlines static imports through its |
| 13 | +// transform (they never hit the native require cache), so an in-worker |
| 14 | +// require.cache probe alone CANNOT catch a reintroduced eager import: |
| 15 | +// 1. structural — no src file may eagerly `import ... from` a lazy dep |
| 16 | +// (only `import type`, which erases at build); |
| 17 | +// 2. built dist — child `node` processes import both dist formats and prove |
| 18 | +// each dep is absent until a react page is validated (skipped when dist |
| 19 | +// has not been built); |
| 20 | +// 3. behavioral — each lazy path really loads its dep on demand and the |
| 21 | +// gate still produces findings. |
| 22 | +import { execFileSync } from 'node:child_process'; |
| 23 | +import { existsSync, readFileSync, readdirSync } from 'node:fs'; |
| 24 | +import { createRequire } from 'node:module'; |
| 25 | +import { dirname, join } from 'node:path'; |
| 26 | +import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 27 | +import { describe, it, expect } from 'vitest'; |
| 28 | + |
| 29 | +const srcDir = dirname(fileURLToPath(import.meta.url)); |
| 30 | +const distDir = join(srcDir, '..', 'dist'); |
| 31 | + |
| 32 | +// Deps that must never load at import time. Extend this list when another |
| 33 | +// heavy, rarely-hit dependency joins the package. |
| 34 | +const LAZY_DEPS = ['typescript', 'sucrase']; |
| 35 | + |
| 36 | +const depLoaded = (cache: Record<string, unknown> | undefined, dep: string) => |
| 37 | + Object.keys(cache ?? {}).some((p) => p.split(/[/\\]/).join('/').includes(`/node_modules/${dep}/`)); |
| 38 | + |
| 39 | +describe('lazy dependency loading (kernel boot-path contract)', () => { |
| 40 | + it('no src file eagerly imports a lazy dep (import type only)', () => { |
| 41 | + // Static `import`/`export ... from '<dep>'` executes at module init in |
| 42 | + // both dist formats; `import type` erases. Dynamic import()/createRequire |
| 43 | + // inside functions are the sanctioned lazy forms and are not matched. |
| 44 | + const eagerImport = (dep: string) => |
| 45 | + new RegExp( |
| 46 | + String.raw`^\s*(?:import\s+['"]${dep}['"]|(?:import|export)\s+(?!type[\s{])[^;]*?from\s*['"]${dep}['"]|import\s+\w+\s*=\s*require\(\s*['"]${dep}['"]\s*\))`, |
| 47 | + 'm', |
| 48 | + ); |
| 49 | + const offenders = readdirSync(srcDir) |
| 50 | + .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts')) |
| 51 | + .flatMap((f) => { |
| 52 | + const body = readFileSync(join(srcDir, f), 'utf8'); |
| 53 | + return LAZY_DEPS.filter((dep) => eagerImport(dep).test(body)).map((dep) => `${f} eagerly imports ${dep}`); |
| 54 | + }); |
| 55 | + expect(offenders).toEqual([]); |
| 56 | + }); |
| 57 | + |
| 58 | + // Reused by both dist probes: import the dist entry, prove no lazy dep came |
| 59 | + // with it, then run each react-page gate and prove exactly its own dep loads |
| 60 | + // — and that the gate still produces its finding. |
| 61 | + const reactStack = (source: string) => `{ pages: [{ name: 'r', kind: 'react', source: ${JSON.stringify(source)} }] }`; |
| 62 | + const childBody = ` |
| 63 | + const probe = require('node:module').createRequire(process.cwd() + '/probe.js'); |
| 64 | + const loaded = (dep) => Object.keys(probe.cache ?? {}).some((p) => p.split(/[/\\\\]/).join('/').includes('/node_modules/' + dep + '/')); |
| 65 | + const fail = (msg) => { console.error(msg); process.exit(1); }; |
| 66 | + const check = (mod) => { |
| 67 | + for (const dep of ${JSON.stringify(LAZY_DEPS)}) { |
| 68 | + if (loaded(dep)) fail(dep + ' was loaded eagerly, at import time'); |
| 69 | + } |
| 70 | + const syntax = mod.validateReactPages(${reactStack('function Page(){ return <div>oops; }')}); |
| 71 | + if (!loaded('sucrase')) fail('sucrase was not loaded by a react-page syntax validation'); |
| 72 | + if (loaded('typescript')) fail('the syntax gate must not load typescript'); |
| 73 | + if (!syntax.some((f) => f.rule === 'react-page-syntax')) fail('syntax gate produced no finding'); |
| 74 | + const props = mod.validateReactPageProps(${reactStack('function Page(){ return <ObjectForm mode="edit" />; }')}); |
| 75 | + if (!loaded('typescript')) fail('typescript was not loaded by a react-page props validation'); |
| 76 | + if (!props.some((f) => f.rule === 'react-prop-missing-required')) fail('props gate produced no finding'); |
| 77 | + console.log('OK'); |
| 78 | + }; |
| 79 | + `; |
| 80 | + |
| 81 | + it.skipIf(!existsSync(join(distDir, 'index.cjs')))('built CJS dist does not load a lazy dep until a react page is validated', () => { |
| 82 | + const out = execFileSync( |
| 83 | + process.execPath, |
| 84 | + ['-e', `${childBody}; check(require(${JSON.stringify(join(distDir, 'index.cjs'))}));`], |
| 85 | + { encoding: 'utf8' }, |
| 86 | + ); |
| 87 | + expect(out).toContain('OK'); |
| 88 | + }); |
| 89 | + |
| 90 | + it.skipIf(!existsSync(join(distDir, 'index.js')))('built ESM dist does not load a lazy dep until a react page is validated', () => { |
| 91 | + const out = execFileSync( |
| 92 | + process.execPath, |
| 93 | + [ |
| 94 | + '--input-type=module', |
| 95 | + '-e', |
| 96 | + `import { createRequire } from 'node:module'; |
| 97 | + const require = createRequire(process.cwd() + '/probe.js'); |
| 98 | + ${childBody}; |
| 99 | + check(await import(${JSON.stringify(pathToFileURL(join(distDir, 'index.js')).href)}));`, |
| 100 | + ], |
| 101 | + { encoding: 'utf8' }, |
| 102 | + ); |
| 103 | + expect(out).toContain('OK'); |
| 104 | + }); |
| 105 | + |
| 106 | + it('loads each dep lazily in-process and the gates still work', async () => { |
| 107 | + const req = createRequire(import.meta.url); |
| 108 | + const { validateReactPages, validateReactPageProps } = await import('./index.js'); |
| 109 | + |
| 110 | + // Stacks without a react-source page never touch either dep. |
| 111 | + expect(validateReactPages({ pages: [{ name: 'p', kind: 'object' }] })).toEqual([]); |
| 112 | + expect(validateReactPageProps({ pages: [{ name: 'p', kind: 'object' }] })).toEqual([]); |
| 113 | + expect(validateReactPageProps({ pages: [{ name: 'r', kind: 'react', source: ' ' }] })).toEqual([]); |
| 114 | + for (const dep of LAZY_DEPS) { |
| 115 | + expect(depLoaded(req.cache, dep), `${dep} loaded before any react-source validation`).toBe(false); |
| 116 | + } |
| 117 | + |
| 118 | + // The first react page with source pays the cost of exactly its own gate's |
| 119 | + // dep — and the gates still work. |
| 120 | + const syntax = validateReactPages({ |
| 121 | + pages: [{ name: 'r', kind: 'react', source: 'function Page(){ return <div>oops; }' }], |
| 122 | + }); |
| 123 | + expect(depLoaded(req.cache, 'sucrase')).toBe(true); |
| 124 | + expect(depLoaded(req.cache, 'typescript'), 'the syntax gate must not load typescript').toBe(false); |
| 125 | + expect(syntax.some((f) => f.rule === 'react-page-syntax' && f.severity === 'error')).toBe(true); |
| 126 | + |
| 127 | + const props = validateReactPageProps({ |
| 128 | + pages: [{ name: 'r', kind: 'react', source: 'function Page(){ return <ObjectForm mode="edit" />; }' }], |
| 129 | + }); |
| 130 | + expect(depLoaded(req.cache, 'typescript')).toBe(true); |
| 131 | + expect(props.some((f) => f.rule === 'react-prop-missing-required' && /objectName/.test(f.message))).toBe(true); |
| 132 | + }); |
| 133 | +}); |
0 commit comments