Three things, all about checks that had never met a real chunk.
`checkExternals.js` rejected slice 3's build outright, naming a fragment of
minified JSX as an imported specifier: a button reading "Approve and import"
puts the token immediately before a quote, and no regexp can tell that from a
statement. Same wall the server's `checkImports.js` hit, answered the same way —
a character walk. A mask rather than a rewrite, because a real import has its
keyword outside a string and its specifier inside one.
Writing the test for that false positive found the false NEGATIVE underneath
it: the pattern required whitespace after `import`, so it could not see
`import{useState}from"react"` — the one shape a minified build actually emits,
and the most likely way for a missed alias to reach production. It has never
been able to see it.
`registration.test.js` is new: stand up a fake `window.__rg` with a recording
registry and the real React, import the BUILT chunk, and read back what it
asked for. No DOM, because nothing renders. It holds the agreement that rots
quietly — every nav row points at a route this module actually registered —
rather than restating both lists.
CI now builds before it tests, because both of those read `dist/entry.js` and
skip without it. Run the other way round they are green and asking nothing.
Co-Authored-By: Claude <noreply@anthropic.com>
173 lines
7.8 KiB
JavaScript
173 lines
7.8 KiB
JavaScript
#!/usr/bin/env node
|
|
// ── §5.1's client half — what stayed a bare import in the built chunk ──────
|
|
//
|
|
// The server half's boundary check reads source. The client half's has to read
|
|
// the BUILD OUTPUT, because the failure it exists to catch is invisible in
|
|
// source: `import { useState } from 'react'` is correct in every file, and
|
|
// whether it ends up as core's React or as a second copy welded into the chunk
|
|
// is decided by vite.config.js's aliases. A missed alias changes nothing you can
|
|
// see until a hook throws in the browser.
|
|
//
|
|
// So: build, then ask the artifact two questions.
|
|
//
|
|
// 1. **Is there a bare import left?** There must not be. Aliased shims are
|
|
// bundled, so a surviving bare specifier means an alias missed and
|
|
// `external` caught it — the loud failure the config prefers, but still a
|
|
// failure, and better found here than by a browser refusing to load.
|
|
// 2. **Did a shared dependency get bundled?** React's own source has
|
|
// fingerprints that no module of ours would contain by accident. Finding
|
|
// one means the chunk carries a second React, which is the silent version
|
|
// of the same mistake and the one worth the fingerprint check.
|
|
//
|
|
// Run after `npm run build`, in CI, on the artifact that ships.
|
|
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const CHUNK = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'entry.js')
|
|
|
|
/**
|
|
* Which characters of the chunk are inside a string, template or comment.
|
|
*
|
|
* **A check that reads code with a regexp fails on code that talks about
|
|
* itself.** The first real chunk this script ever saw — slice 3's, the first
|
|
* with any content in it — was rejected for importing `" }),\n !l && …`,
|
|
* because a button reading "Approve and import" put the token `import`
|
|
* immediately before a quote and the pattern could not tell that from a
|
|
* statement. Slice 0's chunk was 0.2 kB and this branch had never run against
|
|
* anything.
|
|
*
|
|
* The server half hit the same wall from the other side and answered it the same
|
|
* way (`server/scripts/checkImports.js`): a character walk, not a cleverer
|
|
* regexp. There is no regexp that distinguishes a keyword from the same letters
|
|
* inside a string, because that distinction is a property of the parse.
|
|
*
|
|
* A mask rather than a rewrite, because the two halves of a real import — the
|
|
* keyword and the specifier — sit on opposite sides of the boundary: the keyword
|
|
* must be OUTSIDE a string and the specifier must be a string. Blanking strings
|
|
* would take the answer with the noise.
|
|
*/
|
|
export function stringMask(src) {
|
|
const inString = new Uint8Array(src.length)
|
|
let i = 0
|
|
while (i < src.length) {
|
|
const c = src[i]
|
|
const two = src.slice(i, i + 2)
|
|
if (two === '//') {
|
|
const nl = src.indexOf('\n', i)
|
|
const end = nl === -1 ? src.length : nl
|
|
inString.fill(1, i, end)
|
|
i = end
|
|
} else if (two === '/*') {
|
|
const close = src.indexOf('*/', i + 2)
|
|
const end = close === -1 ? src.length : close + 2
|
|
inString.fill(1, i, end)
|
|
i = end
|
|
} else if (c === '"' || c === "'" || c === '`') {
|
|
// The opening quote itself stays unmasked: a specifier is read starting
|
|
// at its quote, and the regexp below anchors on that.
|
|
i += 1
|
|
while (i < src.length && src[i] !== c) {
|
|
// A backslash escapes the next character, including the closing quote.
|
|
const step = src[i] === '\\' ? 2 : 1
|
|
inString.fill(1, i, Math.min(i + step, src.length))
|
|
i += step
|
|
}
|
|
i += 1
|
|
} else {
|
|
i += 1
|
|
}
|
|
}
|
|
return inString
|
|
}
|
|
|
|
// Static and dynamic imports that survived into the output. A relative or
|
|
// absolute specifier is a chunk that was split, which this build does not do —
|
|
// `lib` mode with one entry emits one file — so anything here is a bare name.
|
|
//
|
|
// **This pattern used to require whitespace after `import`, and so could not see
|
|
// the one shape the build actually emits.** Minified Rollup output is
|
|
// `import{useState}from"react"`, with no space anywhere in it; the old
|
|
// `import\s+[^'"]*?from` needed at least one, fell through to the bare-specifier
|
|
// alternative, met `{` instead of a quote and matched nothing. A bare named
|
|
// import — the most likely way for an alias to miss — would have passed this
|
|
// check silently. It was found by writing the test for the false POSITIVE above
|
|
// it, which is the argument for testing a check against both answers.
|
|
//
|
|
// `(?:^|[^\w$.])` rather than a whitespace class, so `a.import(x)` and
|
|
// `myimport"x"` are excluded for the right reason: `import` must not be preceded
|
|
// by an identifier character or a dot. `[^'"()]*?` cannot swallow a dynamic
|
|
// import's parenthesis.
|
|
const IMPORTS = /(?:^|[^\w$.])import\s*(?:\(\s*|[^'"()]*?from\s*)?['"]([^'"]+)['"]/g
|
|
|
|
/** Every bare specifier the chunk still imports at runtime. */
|
|
export function bareImports(chunk) {
|
|
const masked = stringMask(chunk)
|
|
const bare = new Set()
|
|
for (const match of chunk.matchAll(IMPORTS)) {
|
|
// Where the `import` keyword itself starts — one past the leading delimiter,
|
|
// unless the match began at position 0.
|
|
const keywordAt = match.index + (match[0].startsWith('import') ? 0 : 1)
|
|
if (masked[keywordAt]) continue // the letters, inside a string. Not a statement.
|
|
const specifier = match[1]
|
|
if (!specifier.startsWith('.') && !specifier.startsWith('/')) bare.add(specifier)
|
|
}
|
|
return [...bare]
|
|
}
|
|
|
|
// Fingerprints from the shared libraries' own source. Each is a string those
|
|
// packages ship and this module has no other reason to contain.
|
|
//
|
|
// These are matched against the RAW chunk, deliberately unmasked: a bundled
|
|
// library's source arrives as code AND as its own error-message strings, and
|
|
// masking would discard half the evidence. The direction of the risk is opposite
|
|
// to the import check's — here a false positive is a fingerprint too generic,
|
|
// which is a fixable choice of probe, not a property of the parse.
|
|
const BUNDLED = [
|
|
{ what: 'react', probe: 'react.development.js' },
|
|
{ what: 'react', probe: 'Invalid hook call' },
|
|
{ what: 'react-dom', probe: 'react-dom.development.js' },
|
|
{ what: 'react-router-dom', probe: 'useRoutes() may be used only in the context of a <Router> component' },
|
|
]
|
|
|
|
/** Every problem with this chunk, as sentences. Empty means it ships. */
|
|
export function problemsWith(chunk) {
|
|
const problems = []
|
|
const bare = bareImports(chunk)
|
|
if (bare.length) {
|
|
problems.push(
|
|
`the chunk still imports ${bare.map((s) => `"${s}"`).join(', ')} — ` +
|
|
'nothing can resolve a bare specifier in the browser without an import map, ' +
|
|
'and CSP forbids one. Alias it to a shim in vite.config.js (MODULE_API.md §3.6).',
|
|
)
|
|
}
|
|
for (const { what, probe } of BUNDLED) {
|
|
if (chunk.includes(probe)) {
|
|
problems.push(
|
|
`the chunk appears to BUNDLE ${what} (found ${JSON.stringify(probe)}). ` +
|
|
'There is exactly one React in the page and core owns it — a second copy ' +
|
|
'loads fine and then fails at the first hook (MODULE_API.md §3.2).',
|
|
)
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// Only when run as a script. Importing this from a test must not read a chunk
|
|
// that may not have been built, and must not call process.exit.
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
if (!fs.existsSync(CHUNK)) {
|
|
console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`)
|
|
process.exit(1)
|
|
}
|
|
const problems = problemsWith(fs.readFileSync(CHUNK, 'utf8'))
|
|
if (problems.length) {
|
|
console.error('\nThe built chunk breaks the shared-dependency rule:\n')
|
|
for (const p of problems) console.error(` - ${p}\n`)
|
|
process.exit(1)
|
|
}
|
|
const kb = (fs.statSync(CHUNK).size / 1024).toFixed(1)
|
|
console.log(`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency.`)
|
|
}
|