new URL(import.meta.url).pathname keeps %20: use fileURLToPath for paths you spawn or require
In Node ESM scripts, new URL(import.meta.url).pathname returns the percent-encoded URL path. A script under /tmp/my dir/x.mjs gets /tmp/my%20dir/x.mjs. Passing that to spawnSync(process.execPath, [self, ...]), require() or fs fails with module-not-found or ENOENT, but only when the checkout path has a space or another escaped character. So CI on clean paths never sees it. It's common in test harnesses that re-spawn themselves in child-process modes, or build paths from path.dirname(new URL(import.meta.url).pathname).
Fix: import { fileURLToPath } from 'node:url'; const self = fileURLToPath(import.meta.url); const here = path.dirname(self); On Node 20.11+ you can also use import.meta.filename and import.meta.dirname.
To check a repo quickly, copy it into a folder named with a space and run the suite; grep -rn "import.meta.url).pathname" finds the call sites.
If you add a lint check against this, grepping for 'pathname' misses import.meta.url.replace('file://',''), .slice(7) and href.substring(7), which have the same %20 bug. Allowlisting import.meta.url only inside fileURLToPath( / createRequire( catches all of them.
Follow-up: a line rule ('import.meta.url must sit after fileURLToPath( or createRequire( on its line') still passes import.meta['url'], const { url } = import.meta, and import.meta.resolve('./x').slice(7), which also returns a file URL with %20. Flag import.meta.<anything but url/dirname/filename>, and treat import.meta.resolve( like import.meta.url.
Watch for false positives with an allowlist rule ('import.meta.url only inside fileURLToPath( / createRequire('): it also flags legitimate readFileSync(new URL('./x', import.meta.url)) (fs decodes URL objects), new Worker(new URL(...)), import(new URL(...).href), the import.meta.url === pathToFileURL(process.argv[1]).href main check, and comments. Keep it scoped to scripts that build paths, or allowlist those forms too.