Files
Mert Koseoglu e9a7e69629 fix(assert-bundle): Windows entry-point detection (closes #525 windows ci)
CI run 25655545561 windows-latest failed on
  tests/scripts/assert-bundle.test.ts > exits 0 on a clean fixture bundle
  tests/scripts/assert-bundle.test.ts > exits 1 when given polluted fixture

with "expected '' to match /OK/" and "expected +0 to be 1" — assertions
that only fire if the script produces no output and exits 0 by default.

Root cause: the direct-invocation check at the bottom of the script used
string equality between two values that diverge on Windows:

  import.meta.url            = "file:///C:/path/to/assert-bundle.mjs"
  `file://${process.argv[1]}` = "file://C:\\path\\to\\assert-bundle.mjs"

The first has triple-slash + forward separators (URL form). The second
has double-slash + backslashes (template-literal of the OS path). They
never compare equal on Windows, the fallback `endsWith` likewise never
matches (URL has `/`, argv has `\`), so `isDirectInvocation` was always
false → main() never ran → script exited 0 silently.

The G3 invariant check (`npm run assert-bundle`) was therefore a no-op
on the windows-latest runner — bundles could ship polluted with the
`Dynamic require of` shim and the guardrail wouldn't catch it. This
also explained why the test "current production bundles pass the
assert-bundle clean check" passed-by-accident on Windows: exit 0 from
a silent no-op satisfies `expect(r.status).toBe(0)`.

Fix: use `pathToFileURL(process.argv[1]).href` so the entry-point
comparison is OS-agnostic. Both sides are now normalized to the
canonical `file:///C:/...` form on Windows and `file:///...` on POSIX.

Verified locally on macOS:
  $ npm run bundle && npx vitest run tests/scripts/assert-bundle.test.ts
   Test Files  1 passed (1)
        Tests  4 passed (4)

Bundles are intentionally not rebuilt here — CI step `npm run bundle`
regenerates them from source on every run.
2026-05-11 10:21:57 +03:00

105 lines
3.8 KiB
JavaScript

#!/usr/bin/env node
// G3 — post-build bundle invariant assertion.
//
// Issue #511 class: esbuild rewrites bare `require("node:...")` calls into a
// `__require` shim that throws `Dynamic require of "..." is not supported`
// under Node ESM / Bun. Detecting the shim text in the produced bundle is the
// single invariant signal — the variable name is renamed by the minifier, but
// the embedded error literal is stable.
//
// This script is invoked by `npm run assert-bundle` (chained from `build`)
// and by the CI workflows. It scans every passed file for forbidden patterns
// and exits 1 with a violations report if any hit, exits 0 otherwise.
//
// Usage:
// node scripts/assert-bundle.mjs <file> [<file>...]
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
/** @type {Array<{ name: string; pattern: RegExp; reason: string }>} */
const FORBIDDEN_PATTERNS = [
{
name: "esbuild-throwing-require-shim",
pattern: /Dynamic require of/,
reason:
"esbuild emitted the throwing __require shim. This means a bare require() of a node: module reached the bundle and will throw under Node ESM / Bun. Use createRequire(import.meta.url) at module top instead. (Issue #511 class.)",
},
{
name: "shimmed-node-builtin-call",
pattern: /__require\s*\(\s*["'`]\s*node:/,
reason:
"Bundle contains a __require('node:...') call site, which routes through the throwing shim. Replace with createRequire(import.meta.url) at module top.",
},
{
name: "raw-bare-require-node-builtin",
pattern: /\brequire\s*\(\s*["'`]\s*node:/,
reason:
"Bundle contains a bare require('node:...') call. esbuild ESM output cannot resolve this at runtime. Use createRequire(import.meta.url). (Pattern catches single, double, and template-literal quote forms with optional whitespace.)",
},
];
/**
* Scan a single bundle file for forbidden patterns.
* @param {string} filePath
* @returns {{ clean: boolean; violations: string[] }}
*/
export function assertBundleClean(filePath) {
if (!existsSync(filePath)) {
return {
clean: false,
violations: [`File not found: ${filePath}`],
};
}
const content = readFileSync(filePath, "utf-8");
const violations = [];
for (const { name, pattern, reason } of FORBIDDEN_PATTERNS) {
const match = content.match(pattern);
if (match) {
violations.push(
`[${name}] matched ${JSON.stringify(match[0])}${reason}`,
);
}
}
return { clean: violations.length === 0, violations };
}
function main() {
const files = process.argv.slice(2);
if (files.length === 0) {
console.error(
"assert-bundle: no bundle paths provided.\nUsage: node scripts/assert-bundle.mjs <file> [<file>...]",
);
process.exit(2);
}
let failed = false;
for (const f of files) {
const abs = resolve(f);
const { clean, violations } = assertBundleClean(abs);
if (clean) {
console.log(`assert-bundle: OK ${f}`);
} else {
failed = true;
console.error(`assert-bundle: FAIL ${f}`);
for (const v of violations) console.error(` - ${v}`);
}
}
process.exit(failed ? 1 : 0);
}
// Run only when invoked directly, not when imported.
// On Windows, `import.meta.url` is `file:///C:/...` (forward slashes),
// while `process.argv[1]` is `C:\...` (backslashes). A literal-string
// `file://${argv[1]}` template never matches, so the prior comparison
// silently skipped main() on Windows and the script exited 0 with empty
// stdout — making the assert-bundle CI guardrail vacuous. Use
// `pathToFileURL` so the entry-point comparison is OS-agnostic.
const isDirectInvocation =
process.argv[1] != null &&
import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectInvocation) {
main();
}