transform.spec.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710
  1. /**
  2. * Semantic check of the worker module transform (`src/compile/transform.ts`): what the
  3. * emitted CommonJS body looks like for each module form, how suspension points
  4. * are rewritten, that line numbers survive, which forms are refused, and that
  5. * every trap the retired lexer pipeline hit stays fixed.
  6. *
  7. * Scope boundary: this file checks the transform itself; the image collector's
  8. * loop around it is covered by the packer's `transform-image.spec.ts`.
  9. * Emitted-code assertions are deliberately written against substrings
  10. * of the real output rather than whole-file goldens: a golden would fail on every
  11. * helper reordering, which is not the contract. The contract is the observable
  12. * one — the code parses as script, publishes the right bindings, keeps line
  13. * count, and routes suspension through `__als`.
  14. *
  15. * The trap cases come from the two reports the lexer retirement produced
  16. * (`.artifacts/w0-lexer-conclusion.md` §"seven traps", `.artifacts/v3-transform.md`
  17. * §2). Five traps cannot recur under an AST pass, but they are checked anyway:
  18. * they are the forms that actually broke a boot, and a future parser swap would
  19. * reintroduce exactly them.
  20. */
  21. import { expect, test } from 'vitest'
  22. import { parse } from 'acorn'
  23. import { lowerModuleSource } from '../../src/compile/transform.ts'
  24. import { LOWERING_VERSION, WRAPPER_PARAMS } from '../../src/image-layout.ts'
  25. /**
  26. * Lower one probe module the way the packer does — the transform's only caller.
  27. * @param source - Module source under test.
  28. * @param path - Path the diagnostics name.
  29. * @returns The emitted body.
  30. */
  31. const transformModule = (source: string, path = 'probe.js'): string =>
  32. lowerModuleSource({ filename: path, source }).code
  33. /** Register one comparison as its own case, serialized at call time. */
  34. const check = (label: string, actual: unknown, expected: unknown): void => {
  35. const [seen, wanted] = [JSON.stringify(actual), JSON.stringify(expected)]
  36. test(label, () => { expect(seen).toBe(wanted) })
  37. }
  38. /** Assert a substring is present in an emitted body. */
  39. const contains = (label: string, code: string, needle: string): void => {
  40. test(label, () => { expect(code).toContain(needle) })
  41. }
  42. /** Assert a substring is absent (used for "must survive untouched" cases). */
  43. const lacks = (label: string, code: string, needle: string): void => {
  44. test(label, () => { expect(code).not.toContain(needle) })
  45. }
  46. /** @returns The error message of a refused transform, or undefined when it succeeded. */
  47. const refusal = (source: string, path = 'probe.js'): string | undefined => {
  48. try {
  49. transformModule(source, path)
  50. return undefined
  51. } catch (reason) {
  52. return (reason as Error).message
  53. }
  54. }
  55. /** Assert the transform refuses a source and names the reason. */
  56. const refuses = (label: string, source: string, fragment: string): void => {
  57. const message = refusal(source)
  58. test(label, () => { expect(message).toContain(fragment) })
  59. }
  60. /**
  61. * The wrapper contract, applied for real: compile the body with the declared
  62. * parameters and run it. This is the same `new Function` shape the loader uses
  63. * (module-loader.ts), so a body that compiles here compiles there.
  64. * @param code - Emitted CommonJS body.
  65. * @param require - Module resolver the body's `require` calls reach.
  66. * @param als - Suspension runtime bound to `__als`.
  67. * @returns The populated `exports` object.
  68. */
  69. function runBody(
  70. code: string,
  71. require: (specifier: string) => unknown = () => ({}),
  72. als?: unknown,
  73. ): Record<string, unknown> {
  74. const exports: Record<string, unknown> = {}
  75. const module = { exports }
  76. // eslint-disable-next-line @typescript-eslint/no-implied-eval -- the wrapper contract under test is a `new Function` body
  77. const factory = new Function(...WRAPPER_PARAMS, code) as (...args: unknown[]) => void
  78. factory(exports, require, module, '/vfs/probe.js', '/vfs', { url: 'file:///vfs/probe.js' }, als)
  79. return exports
  80. }
  81. /** Every emitted body must parse as a script — the transform's own exit gate, re-checked here. */
  82. const parsesAsScript = (label: string, code: string): void => {
  83. test(label, () => {
  84. expect(() => parse(code, { ecmaVersion: 'latest', sourceType: 'script', allowAwaitOutsideFunction: false })).not.toThrow()
  85. })
  86. }
  87. // ---------------------------------------------------------------------------
  88. // 1. The published contract: the three names the packer and loader share.
  89. // ---------------------------------------------------------------------------
  90. check('LOWERING_VERSION is a non-empty string', typeof LOWERING_VERSION === 'string' && LOWERING_VERSION.length > 0, true)
  91. check('WRAPPER_PARAMS is the frozen 7-parameter shape', [...WRAPPER_PARAMS], [
  92. 'exports', 'require', 'module', '__filename', '__dirname', '__dsh$meta', '__als',
  93. ])
  94. // The wrapper signature is a contract with the loader's `new Function`, so the
  95. // parameters must be valid identifiers in that position.
  96. check(
  97. 'every wrapper parameter is a usable identifier',
  98. (() => {
  99. try {
  100. // eslint-disable-next-line @typescript-eslint/no-implied-eval -- proves the parameter names compile where the loader uses them
  101. new Function(...WRAPPER_PARAMS, 'return 0')
  102. return true
  103. } catch {
  104. return false
  105. }
  106. })(),
  107. true,
  108. )
  109. // ---------------------------------------------------------------------------
  110. // 2. lowerModuleSource: the packer face. `lowered` is the pack-time decision.
  111. // ---------------------------------------------------------------------------
  112. {
  113. const esm = lowerModuleSource({ filename: 'node_modules/p/index.js', source: 'export const a = 1\n' })
  114. check('lowered=true for a module that needed rewriting', esm.lowered, true)
  115. check('lowered code differs from source', esm.code !== 'export const a = 1\n', true)
  116. // Plain CommonJS with no suspension point is the "pack as-is" case: the
  117. // collector relies on this to leave 1693-odd entries untouched.
  118. const plain = 'module.exports = 1\n'
  119. const cjs = lowerModuleSource({ filename: 'node_modules/p/legacy.cjs', source: plain })
  120. check('lowered=false for plain CommonJS', cjs.lowered, false)
  121. check('unlowered code is the input verbatim', cjs.code, plain)
  122. // A CommonJS body that still contains a suspension point must be rewritten:
  123. // `await` inside a function is the ALS protocol's business even with no ESM.
  124. const cjsAwait = lowerModuleSource({
  125. filename: 'node_modules/p/async.cjs',
  126. source: 'module.exports = async () => { await 1 }\n',
  127. })
  128. check('lowered=true for CommonJS carrying a suspension point', cjsAwait.lowered, true)
  129. contains('CommonJS await still routes through __als', cjsAwait.code, '__als.pause(')
  130. // `lowered` must agree with the code/source comparison by construction.
  131. check('lowered mirrors code !== source', cjsAwait.lowered, cjsAwait.code !== 'module.exports = async () => { await 1 }\n')
  132. }
  133. // ---------------------------------------------------------------------------
  134. // 3. Import forms.
  135. // ---------------------------------------------------------------------------
  136. {
  137. // Side-effect import: a bare require, nothing bound.
  138. const code = transformModule("import './side-effect.js'\n", 'probe.js')
  139. contains('side-effect import becomes a bare require', code, 'require("./side-effect.js")')
  140. parsesAsScript('side-effect import', code)
  141. const requested: string[] = []
  142. runBody(code, (specifier) => {
  143. requested.push(specifier)
  144. return {}
  145. })
  146. check('side-effect import actually requires at run time', requested, ['./side-effect.js'])
  147. }
  148. {
  149. // Named imports are snapshots (CommonJS destructuring semantics), which is the
  150. // documented, accepted divergence from ESM live bindings on the import side.
  151. const code = transformModule("import { a, b as c } from 'p'\nexport const out = [a, c]\n", 'probe.js')
  152. parsesAsScript('named imports', code)
  153. const exports = runBody(code, () => ({ a: 1, b: 2 }))
  154. check('named import binds by imported name, honouring the alias', exports.out, [1, 2])
  155. }
  156. {
  157. // Default and namespace imports go through the two interop helpers, which must
  158. // agree with `Loader.unwrapExports` on the `__esModule` convention.
  159. const code = transformModule("import d from 'p'\nimport * as ns from 'q'\nexport const seen = [d, ns.x, ns.default]\n", 'probe.js')
  160. parsesAsScript('default and namespace imports', code)
  161. // An `__esModule` module: default comes from `.default`, namespace passes through.
  162. const esModule = { __esModule: true, default: 'D', x: 'X' }
  163. const withEsm = runBody(code, () => esModule)
  164. check('default import of an __esModule module reads .default', (withEsm.seen as unknown[])[0], 'D')
  165. // A plain CommonJS module: the module object *is* the default, and the
  166. // namespace gains a `default` key pointing at it.
  167. const plain = { x: 'X' }
  168. const withCjs = runBody(code, () => plain)
  169. check('default import of plain CommonJS is the module object', (withCjs.seen as unknown[])[0], plain)
  170. check('namespace of plain CommonJS keeps the named key', (withCjs.seen as unknown[])[1], 'X')
  171. check('namespace of plain CommonJS synthesizes default', (withCjs.seen as unknown[])[2], plain)
  172. }
  173. // ---------------------------------------------------------------------------
  174. // 4. Export forms, including the live-binding contract.
  175. // ---------------------------------------------------------------------------
  176. {
  177. const code = transformModule('export const a = 1\nexport function f() {}\nexport class K {}\n', 'probe.js')
  178. parsesAsScript('exported declarations', code)
  179. contains('module bodies get the __esModule marker', code, '__esModule')
  180. contains('use strict is part of the prologue', code, '"use strict"')
  181. const exports = runBody(code)
  182. check('exported const is published', exports.a, 1)
  183. check('exported function is published', typeof exports.f, 'function')
  184. check('exported class is published', typeof exports.K, 'function')
  185. }
  186. {
  187. // Local exports are getters, so a later assignment is observable through
  188. // `exports` — the ESM live-binding property the report calls out explicitly.
  189. const code = transformModule('export let counter = 0\nexport function bump() { counter += 1 }\n', 'probe.js')
  190. parsesAsScript('live binding', code)
  191. const exports = runBody(code)
  192. check('live binding starts at its initializer', exports.counter, 0)
  193. ;(exports.bump as () => void)()
  194. check('live binding observes a later assignment', exports.counter, 1)
  195. // A getter, not a data property: this is what makes the above work.
  196. check(
  197. 'exported local is an accessor',
  198. typeof Object.getOwnPropertyDescriptor(exports, 'counter')?.get,
  199. 'function',
  200. )
  201. }
  202. {
  203. // Trap 4 in the lexer report: `export const a = 1, b = 2` reported only the
  204. // first declarator, so the AST pass upgraded this from "loud refusal" to
  205. // "correctly supported". Both bindings must appear.
  206. const code = transformModule('export const a = 1, b = 2\n', 'probe.js')
  207. parsesAsScript('multi-declarator export', code)
  208. const exports = runBody(code)
  209. check('multi-declarator export publishes every binding', [exports.a, exports.b], [1, 2])
  210. }
  211. {
  212. // Destructuring exports exercise the pattern walker (object, array, rest,
  213. // default) — every branch of `declaredBindings`.
  214. const code = transformModule(
  215. 'export const { p, q: renamed, ...restObj } = { p: 1, q: 2, z: 3 }\n'
  216. + 'export const [first, , third = 30, ...restArr] = [10, 20, undefined, 40, 50]\n',
  217. 'probe.js',
  218. )
  219. parsesAsScript('destructuring exports', code)
  220. const exports = runBody(code)
  221. check('object pattern export', [exports.p, exports.renamed], [1, 2])
  222. check('object rest export', exports.restObj, { z: 3 })
  223. check('array pattern export with hole', [exports.first, exports.third], [10, 30])
  224. check('array rest export', exports.restArr, [40, 50])
  225. // The renamed target is what is published; the source key is not a binding.
  226. check('object pattern publishes the local name, not the source key', 'q' in exports, false)
  227. }
  228. {
  229. const code = transformModule('const x = 1\nexport { x as y }\n', 'probe.js')
  230. parsesAsScript('local export clause', code)
  231. const exports = runBody(code)
  232. check('local export clause publishes under the exported name', exports.y, 1)
  233. check('local export clause does not publish the local name', 'x' in exports, false)
  234. }
  235. {
  236. // Re-export clause: a getter onto the required module, so it also stays live.
  237. const module: Record<string, unknown> = { a: 1 }
  238. const code = transformModule("export { a, a as aliased } from 'p'\n", 'probe.js')
  239. parsesAsScript('re-export clause', code)
  240. const exports = runBody(code, () => module)
  241. check('re-export publishes the name', exports.a, 1)
  242. check('re-export publishes the alias', exports.aliased, 1)
  243. module.a = 2
  244. check('re-export is live against the source module', exports.a, 2)
  245. }
  246. {
  247. // `export *` copies enumerable keys, skips `default`, and must not clobber an
  248. // existing local export.
  249. const code = transformModule("export const own = 'local'\nexport * from 'p'\n", 'probe.js')
  250. parsesAsScript('export all', code)
  251. const exports = runBody(code, () => ({ extra: 'E', default: 'D', own: 'theirs' }))
  252. check('export * copies named keys', exports.extra, 'E')
  253. check('export * skips default', 'default' in exports, false)
  254. check('export * does not overwrite an existing export', exports.own, 'local')
  255. }
  256. {
  257. const code = transformModule("export * as ns from 'p'\n", 'probe.js')
  258. parsesAsScript('export all as namespace', code)
  259. const exports = runBody(code, () => ({ x: 1 }))
  260. check('export * as ns publishes a namespace object', (exports.ns as Record<string, unknown>).x, 1)
  261. }
  262. {
  263. const code = transformModule('export default 42\n', 'probe.js')
  264. parsesAsScript('default export value', code)
  265. check('default export lands on exports.default', runBody(code).default, 42)
  266. }
  267. {
  268. // Documented cost: the function name stops being a module-scope binding, but
  269. // the named function expression can still refer to itself.
  270. const code = transformModule('export default function self(n) { return n <= 0 ? 0 : self(n - 1) }\n', 'probe.js')
  271. parsesAsScript('default export function', code)
  272. const fn = runBody(code).default as (n: number) => number
  273. check('default-exported function keeps self-reference', fn(3), 0)
  274. }
  275. {
  276. const code = transformModule("export { x as default } from 'p'\n", 'probe.js')
  277. parsesAsScript('re-export as default', code)
  278. check('re-export as default publishes default', runBody(code, () => ({ x: 'D' })).default, 'D')
  279. }
  280. // ---------------------------------------------------------------------------
  281. // 5. import.meta and dynamic import.
  282. // ---------------------------------------------------------------------------
  283. {
  284. const code = transformModule('export const here = import.meta.url\n', 'probe.js')
  285. parsesAsScript('import.meta', code)
  286. contains('import.meta becomes the wrapper parameter', code, '__dsh$meta')
  287. check('import.meta.url resolves through the wrapper', runBody(code).here, 'file:///vfs/probe.js')
  288. }
  289. {
  290. // Dynamic import routes through the same require chain (which is what makes
  291. // typert-loader's absolute-path `import()` land on the VFS resolver), and the
  292. // result is namespace-shaped.
  293. const code = transformModule("export const load = () => import('p')\n", 'probe.js')
  294. parsesAsScript('dynamic import', code)
  295. contains('dynamic import becomes the helper call', code, '__dsh$dynImport')
  296. const load = runBody(code, () => ({ x: 1 })).load as () => Promise<Record<string, unknown>>
  297. const namespace = await load()
  298. check('dynamic import resolves to a namespace object', namespace.x, 1)
  299. check('dynamic import namespace has a default', 'default' in namespace, true)
  300. }
  301. // ---------------------------------------------------------------------------
  302. // 6. Suspension points. Behaviour is checked against a recording runtime, so
  303. // these assert the protocol shape rather than re-testing als-runtime.
  304. // ---------------------------------------------------------------------------
  305. /** A recording stand-in for the ALS runtime: proves the emitted calls happen in order. */
  306. function recordingAls(): { als: Record<string, unknown>; calls: string[] } {
  307. const calls: string[] = []
  308. const als = {
  309. pause: (value: unknown) => {
  310. calls.push('pause')
  311. return Promise.resolve(value).then(
  312. settled => ({ ok: true, value: settled, snapshot: 'S' }),
  313. (error: unknown) => ({ ok: false, error, snapshot: 'S' }),
  314. )
  315. },
  316. resume: (token: { ok: boolean; value?: unknown; error?: unknown }) => {
  317. calls.push('resume')
  318. if (token.ok) return token.value
  319. throw token.error
  320. },
  321. snapshot: () => {
  322. calls.push('snapshot')
  323. return 'S'
  324. },
  325. afterYield: (_snapshot: unknown, sent: unknown) => {
  326. calls.push('afterYield')
  327. return sent
  328. },
  329. iterator: (value: unknown) => {
  330. calls.push('iterator')
  331. const source = value as Record<PropertyKey, unknown>
  332. const asyncFactory = source[Symbol.asyncIterator] as (() => AsyncIterator<unknown>) | undefined
  333. if (typeof asyncFactory === 'function') return asyncFactory.call(source)
  334. const syncFactory = source[Symbol.iterator] as () => Iterator<unknown, unknown>
  335. const inner = syncFactory.call(source)
  336. return {
  337. next: async (...args: unknown[]) => {
  338. const step = inner.next(...args as [unknown])
  339. return { done: step.done ?? false, value: await step.value }
  340. },
  341. return: async (sent?: unknown) => {
  342. const step = inner.return?.(sent) ?? { done: true, value: undefined }
  343. return { done: step.done ?? true, value: await step.value }
  344. },
  345. }
  346. },
  347. close: async (iterator: AsyncIterator<unknown>) => {
  348. calls.push('close')
  349. return iterator.return?.(undefined)
  350. },
  351. }
  352. return { als, calls }
  353. }
  354. {
  355. const code = transformModule('export const run = async () => await 7\n', 'probe.js')
  356. parsesAsScript('await rewrite', code)
  357. contains('await is wrapped in resume(await pause(', code, '__als.resume(await __als.pause(')
  358. const { als, calls } = recordingAls()
  359. const run = runBody(code, () => ({}), als).run as () => Promise<number>
  360. check('await still yields its value', await run(), 7)
  361. check('await goes pause-then-resume', calls, ['pause', 'resume'])
  362. }
  363. {
  364. // The rejection path is the half that a naive "snapshot on success" rewrite
  365. // gets wrong, so it is checked as its own case.
  366. const code = transformModule(
  367. "export const run = async () => { try { await Promise.reject(new Error('boom')) } catch (reason) { return `caught:${reason.message}` } }\n",
  368. 'probe.js',
  369. )
  370. parsesAsScript('await rejection', code)
  371. const { als, calls } = recordingAls()
  372. const run = runBody(code, () => ({}), als).run as () => Promise<string>
  373. check('rejection surfaces through resume', await run(), 'caught:boom')
  374. check('rejection path also goes pause-then-resume', calls, ['pause', 'resume'])
  375. }
  376. {
  377. // for-await desugars to an explicit loop; `return()` must run only on abrupt
  378. // completion, which is the language rule the report calls out. The two
  379. // completion paths need two different loop bodies, so they are separate cases.
  380. const plain = 'export const run = async (src) => { const seen = []\n'
  381. + 'for await (const item of src) { seen.push(item) }\n'
  382. + 'return seen }\n'
  383. const code = transformModule(plain, 'probe.js')
  384. parsesAsScript('for-await', code)
  385. contains('for-await uses the iterator helper', code, '__als.iterator(')
  386. contains('for-await closes on abrupt completion', code, '__als.close(')
  387. /** An async iterable counting up to `n`, rebuilt per case so state cannot leak. */
  388. const counting = (n: number): unknown => ({
  389. [Symbol.asyncIterator]: () => {
  390. let emitted = 0
  391. return {
  392. next: () => Promise.resolve(
  393. emitted < n ? { done: false, value: ++emitted } : { done: true, value: undefined },
  394. ),
  395. }
  396. },
  397. })
  398. // Normal completion: the iterator is exhausted, so `return()` must NOT run.
  399. const { als, calls } = recordingAls()
  400. const run = runBody(code, () => ({}), als).run as (src: unknown) => Promise<number[]>
  401. check('for-await over an async source collects values', await run(counting(2)), [1, 2])
  402. check('normal completion does not close the iterator', calls.includes('close'), false)
  403. // Abrupt completion (break), and a sync source whose values are promises
  404. // (async-from-sync): close must run exactly once.
  405. const breaking = 'export const run = async (src) => { const seen = []\n'
  406. + 'for await (const item of src) { seen.push(item); if (item === 2) break }\n'
  407. + 'return seen }\n'
  408. const breakingCode = transformModule(breaking, 'probe.js')
  409. parsesAsScript('for-await with break', breakingCode)
  410. const { als: als2, calls: calls2 } = recordingAls()
  411. const run2 = runBody(breakingCode, () => ({}), als2).run as (src: unknown) => Promise<number[]>
  412. const syncSource = {
  413. [Symbol.iterator]: () => [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)][Symbol.iterator](),
  414. }
  415. check('for-await accepts a sync source of promises', await run2(syncSource), [1, 2])
  416. check('break closes the iterator exactly once', calls2.filter(name => name === 'close').length, 1)
  417. }
  418. {
  419. // Destructuring in the loop head goes through the same binding path.
  420. const code = transformModule(
  421. 'export const run = async (src) => { const seen = []\nfor await (const { v } of src) seen.push(v)\nreturn seen }\n',
  422. 'probe.js',
  423. )
  424. parsesAsScript('for-await destructuring', code)
  425. const { als } = recordingAls()
  426. const run = runBody(code, () => ({}), als).run as (src: unknown) => Promise<number[]>
  427. check('for-await destructures each step', await run([{ v: 1 }, { v: 2 }]), [1, 2])
  428. }
  429. {
  430. // A non-block body must still be wrapped, or the emitted loop would swallow
  431. // the following statement.
  432. const code = transformModule(
  433. 'export const run = async (src) => { let sum = 0\nfor await (const n of src) sum += n\nreturn sum }\n',
  434. 'probe.js',
  435. )
  436. parsesAsScript('for-await single-statement body', code)
  437. const { als } = recordingAls()
  438. const run = runBody(code, () => ({}), als).run as (src: unknown) => Promise<number>
  439. check('for-await with a non-block body runs correctly', await run([1, 2, 3]), 6)
  440. }
  441. {
  442. // `yield` in an async generator: the snapshot is taken before suspending and
  443. // the consumer's sent value comes back through afterYield.
  444. const code = transformModule(
  445. 'export async function* gen() { const got = yield 1\nyield got * 2 }\n',
  446. 'probe.js',
  447. )
  448. parsesAsScript('yield rewrite', code)
  449. contains('yield is wrapped in afterYield(snapshot(), yield ...)', code, '__als.afterYield(__als.snapshot(),yield ')
  450. const { als, calls } = recordingAls()
  451. const gen = runBody(code, () => ({}), als).gen as () => AsyncGenerator<number, void, number>
  452. const iterator = gen()
  453. check('first yield produces its value', (await iterator.next(0)).value, 1)
  454. check('sent value returns through afterYield', (await iterator.next(21)).value, 42)
  455. check('yield recorded snapshot and afterYield', calls.filter(name => name === 'afterYield').length >= 1, true)
  456. }
  457. {
  458. // Statement-position `yield*` desugars into a forwarding loop.
  459. const code = transformModule(
  460. 'export async function* outer(inner) { yield* inner\nyield "tail" }\n',
  461. 'probe.js',
  462. )
  463. parsesAsScript('yield* rewrite', code)
  464. const { als } = recordingAls()
  465. const outer = runBody(code, () => ({}), als).outer as (inner: unknown) => AsyncGenerator<unknown, void, unknown>
  466. const collected: unknown[] = []
  467. for await (const value of outer(['a', 'b'])) collected.push(value)
  468. check('yield* forwards inner values then continues', collected, ['a', 'b', 'tail'])
  469. }
  470. // ---------------------------------------------------------------------------
  471. // 7. Line numbers. The debugging contract: a stack frame in a transformed body
  472. // points at the same line as the artifact it came from.
  473. // ---------------------------------------------------------------------------
  474. /** @returns Line count of a string, counting a trailing newline's line as the last. */
  475. const lineCount = (text: string): number => text.split('\n').length
  476. {
  477. // The prologue is emitted without a trailing newline, so a transformed body
  478. // has exactly as many lines as its source. Anything else is line drift.
  479. const cases: Array<{ readonly label: string; readonly source: string }> = [
  480. { label: 'imports and exports', source: "import { a } from 'p'\n\nexport const b = a\n\nexport default b\n" },
  481. { label: 'await in a function', source: 'export const f = async () => {\n const v = await g()\n return v\n}\n' },
  482. {
  483. label: 'for-await (body re-emitted)',
  484. source: 'export const f = async (src) => {\n for await (const x of src) {\n use(x)\n }\n done()\n}\n',
  485. },
  486. {
  487. label: 'yield* (statement desugared)',
  488. source: 'export async function* f(inner) {\n yield* inner\n after()\n}\n',
  489. },
  490. { label: 'export * with following lines', source: "export * from 'p'\nconst tail = 1\nexport { tail }\n" },
  491. { label: 'multi-line import clause', source: "import {\n a,\n b,\n} from 'p'\nexport const out = [a, b]\n" },
  492. ]
  493. for (const { label, source } of cases) {
  494. const code = transformModule(source, 'probe.js')
  495. check(`line count survives: ${label}`, lineCount(code), lineCount(source))
  496. }
  497. }
  498. // ---------------------------------------------------------------------------
  499. // 8. Refusals. Every one of these is a form the transform must reject loudly
  500. // rather than emit something that breaks later.
  501. // ---------------------------------------------------------------------------
  502. refuses('top-level await is refused', 'export const a = 1\nawait boot()\n', 'top-level await')
  503. refuses('top-level for-await is refused', 'for await (const x of src) use(x)\n', 'top-level for-await')
  504. refuses(
  505. 'labeled for-await is refused',
  506. 'export const f = async (src) => { outer: for await (const x of src) { break outer } }\n',
  507. 'labeled for-await',
  508. )
  509. refuses(
  510. 'import attributes are refused',
  511. "import data from './d.json' with { type: 'json' }\n",
  512. 'import attributes',
  513. )
  514. refuses(
  515. 'value-position yield* is refused',
  516. 'export async function* f(inner) { const v = yield* inner\nuse(v) }\n',
  517. 'yield* is only supported as a statement',
  518. )
  519. refuses(
  520. 'assignment around yield* is refused, never silently dropped',
  521. 'export async function* f(inner) { let v\nv = yield* inner\nuse(v) }\n',
  522. 'yield* is only supported as the whole statement expression',
  523. )
  524. refuses(
  525. 'a call around yield* is refused, never silently dropped',
  526. 'export async function* f(inner) { use(yield* inner) }\n',
  527. 'yield* is only supported as the whole statement expression',
  528. )
  529. refuses(
  530. 'already-lowered source is refused',
  531. 'const x = __als.pause(1)\n',
  532. 'already lowered',
  533. )
  534. refuses('unparseable source is refused', 'export const = \n', 'parse failed')
  535. {
  536. // A refusal must name the file and the line, which is what makes a build
  537. // failure actionable.
  538. const message = refusal('export const a = 1\n\n\nawait boot()\n', 'node_modules/p/index.js')
  539. check('refusal names the file', message?.includes('node_modules/p/index.js'), true)
  540. check('refusal names the offending line', message?.includes(':4'), true)
  541. }
  542. // ---------------------------------------------------------------------------
  543. // 9. Trap regressions. Each case broke a real boot under the retired lexer
  544. // pipeline; the AST pass must keep them fixed.
  545. // Sources: .artifacts/w0-lexer-conclusion.md, .artifacts/v3-transform.md §2.
  546. // ---------------------------------------------------------------------------
  547. {
  548. // Trap 1: a file with no module syntax can still contain a dynamic import.
  549. // Early-returning on "no module syntax" left it unrewritten and it escaped to
  550. // the host engine's parser.
  551. const code = transformModule("module.exports = () => import('./x.js')\n", 'probe.js')
  552. contains('trap 1: dynamic import in a CommonJS file is still rewritten', code, '__dsh$dynImport')
  553. parsesAsScript('trap 1', code)
  554. }
  555. {
  556. // Trap 2: `export {}` is a bundler module marker. The lexer reported nothing
  557. // for it, so it survived into `new Function` as `Unexpected token 'export'`.
  558. // The needle is the keyword in statement position, since `exports.` in the
  559. // prologue legitimately contains the same letters.
  560. const code = transformModule('export {};\n', 'probe.js')
  561. lacks('trap 2: bare export {} is removed', code, 'export {')
  562. lacks('trap 2: no export keyword survives', code, 'export;')
  563. parsesAsScript('trap 2', code)
  564. check('trap 2: emitted body still marks __esModule', '__esModule' in runBody(code), true)
  565. }
  566. {
  567. // Trap 3/4: `export const a = 1, b = 2` — only the first declarator was
  568. // reported. Now both are published (checked in §4); here the point is that
  569. // the no-initializer form works too.
  570. const code = transformModule('export let x, y\nexport const set = () => { x = 1; y = 2 }\n', 'probe.js')
  571. parsesAsScript('trap 3', code)
  572. const exports = runBody(code)
  573. ;(exports.set as () => void)()
  574. check('trap 3: every declarator is exported, initializer or not', [exports.x, exports.y], [1, 2])
  575. }
  576. {
  577. // Trap 6, the most costly one: a block comment before a class member named
  578. // `import` made the lexer report a dynamic import, renaming
  579. // `EntryTree.prototype.import` and breaking the loading chain at
  580. // `Entry._init` with "this.parent.tree.import is not a function".
  581. const source = 'export class A {\n /** doc */ import(name) { return name }\n}\n'
  582. const code = transformModule(source, 'probe.js')
  583. lacks('trap 6: a method named import is not rewritten', code, '__dsh$dynImport')
  584. parsesAsScript('trap 6', code)
  585. const A = runBody(code).A as new () => { import: (name: string) => string }
  586. check('trap 6: the method is still callable under its own name', new A().import('kept'), 'kept')
  587. }
  588. {
  589. // Trap 7: a comment between `export` and the declaration keyword made the
  590. // gap-matching regex miss, refusing zod's `export /*@__NO_SIDE_EFFECTS__*/ function`
  591. // and taking 30-odd roster rows down with it.
  592. const code = transformModule('export /*@__NO_SIDE_EFFECTS__*/ function $constructor(x) { return x }\n', 'probe.js')
  593. parsesAsScript('trap 7', code)
  594. check('trap 7: export with an interposed comment still publishes', typeof runBody(code).$constructor, 'function')
  595. }
  596. {
  597. // The trap the AST pass introduced and the byte-level oracle caught:
  598. // `new.target` is also a MetaProperty. Replacing every MetaProperty made
  599. // `new.target === Cls` permanently false, silently disabling abstract-seam
  600. // guards in `jobs` and `llm`.
  601. const source = 'export class Base {\n constructor() { this.direct = new.target === Base }\n}\n'
  602. const code = transformModule(source, 'probe.js')
  603. contains('trap 8: new.target survives verbatim', code, 'new.target')
  604. lacks('trap 8: new.target is not replaced by the meta parameter', code, '__dsh$meta')
  605. parsesAsScript('trap 8', code)
  606. const Base = runBody(code).Base as new () => { direct: boolean }
  607. class Derived extends Base {}
  608. check('trap 8: new.target compares true for a direct construction', new Base().direct, true)
  609. check('trap 8: new.target compares false for a subclass', new Derived().direct, false)
  610. }
  611. {
  612. // Shebang handling (found while packing `yaml/bin.mjs`): `#!` is only legal at
  613. // offset 0, which the prologue occupies. It is commented out in place so both
  614. // offsets and the line count stay put.
  615. const source = '#!/usr/bin/env node\nexport const main = 1\n'
  616. const code = transformModule(source, 'probe.js')
  617. lacks('shebang is not left in the emitted body', code, '#!')
  618. parsesAsScript('shebang', code)
  619. check('shebang: line count still survives', lineCount(code), lineCount(source))
  620. check('shebang: the module still works', runBody(code).main, 1)
  621. }
  622. {
  623. // The exit gate itself: the transform re-parses its own output as a script.
  624. // Any leftover module syntax or mis-spliced interval fails there, not at load.
  625. // Re-checked here over a source that exercises several edits at once.
  626. const source = "import a from 'p'\nexport * from 'q'\nexport const f = async () => { for await (const x of a) { await x } }\n"
  627. parsesAsScript('exit gate over combined edits', transformModule(source, 'probe.js'))
  628. }
  629. // ---------------------------------------------------------------------------
  630. // 10. Caching: the transform memoizes by source text, and the cache must not
  631. // leak a different file's result.
  632. // ---------------------------------------------------------------------------
  633. {
  634. const source = 'export const cached = 1\n'
  635. const first = transformModule(source, 'a.js')
  636. const second = transformModule(source, 'b.js')
  637. check('identical sources return the identical cached body', first === second, true)
  638. // Distinct sources must not collide.
  639. check(
  640. 'distinct sources produce distinct bodies',
  641. transformModule('export const other = 2\n', 'c.js') !== first,
  642. true,
  643. )
  644. }