Files
Module-Rust/client/scripts/checkExternals.js
wtclaude 0cb9bdd1f0
All checks were successful
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Successful in -1m9s
feat(rust): the live map (phase 14, protocol 11)
PLAN.md §30 as approved, plus D119/D120 from the build.

Server:
- rust_map_images (one row per server: picture as MEDIUMBLOB, geometry,
  monuments, DERIVATION_VERSION) and rust_map_overrides; purge.sql pair.
- mapImages.js: D110. The board poll notices a new boot/wipe/seed/size and
  asks map.info; a new key or hash from the free Rust+ cache (or a render
  kept on disk) is fetched in slices, checked against its SHA-256 and stored
  in one statement. One fetch per server, a backoff on failure, `stale`
  abandons a fetch that straddles a map change. Render now (D109) is
  admin-only and watched to completion.
- mapLive.js: D111. One map.live per server per 5 s whoever asks; positions
  are held in memory only.
- model/map: four layers (world, events public; players, bases staff), a
  fleet default plus per-server override (D114), the players layer capped by
  presence (D113), own dot and online first-party clan mates for a linked
  viewer (D115, D117, D118). A layer the viewer may not see is absent from
  the answer, never sent and hidden.
- Routes: public /servers/:id/map, /map/image (immutable under its hash),
  /map/live; admin /servers/:id/map/fetch and /render; the Map card on the
  visibility PUT. Swagger fragment and frozen manifest regenerated.

Client:
- A Map tab: Leaflet over the picture in CRS.Simple, the game's own grid
  (labels only when a cell is wide enough to hold one), a legend that lists
  hidden layers with who can see them, polled every 10 s while visible.
- D120: Leaflet is a lazy split chunk beside entry.js, not in it. release.yml
  copies every dist/*.js; checkExternals and build.test.js hold both ends.
- The Map card on Admin -> Rust visibility, with Fetch again and Render now.

Capability `map` declared for the Android app (phase 15).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 01:06:10 -05:00

214 lines
9.5 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
// specifier is a chunk that was split — since D120 there is one, Leaflet's,
// which the Map tab imports with `import()` — and is `relativeImports`'s
// concern below; a bare name is this check's.
//
// **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]
}
/**
* Every relative specifier the chunk imports — its split chunks (D120).
*
* Each one is a file the browser will ask for beside `entry.js`, and core
* serves that directory, so it works in development. Whether it SHIPS is
* `release.yml`'s business, which is why the script below also asserts every
* one of them exists in `dist/`, and `test/build.test.js` asserts the release
* copies every `.js` in `dist/` rather than naming `entry.js`. A split chunk the
* release forgot is a Map tab that spins for ever on an operator's site while
* every check here passes.
*/
export function relativeImports(chunk) {
const masked = stringMask(chunk)
const found = new Set()
for (const match of chunk.matchAll(IMPORTS)) {
const keywordAt = match.index + (match[0].startsWith('import') ? 0 : 1)
if (masked[keywordAt]) continue
if (match[1].startsWith('./')) found.add(match[1].slice(2))
}
return [...found]
}
// 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 dist = path.dirname(CHUNK)
const entry = fs.readFileSync(CHUNK, 'utf8')
const problems = []
// Every chunk, not only the entry: a split chunk that bundled a second React
// would load on the tab that imports it and fail there, and nowhere else.
for (const file of fs.readdirSync(dist).filter((f) => f.endsWith('.js'))) {
for (const p of problemsWith(fs.readFileSync(path.join(dist, file), 'utf8'))) problems.push(`${file}: ${p}`)
}
for (const name of relativeImports(entry)) {
if (!fs.existsSync(path.join(dist, name))) {
problems.push(`entry.js imports ./${name}, which is not in dist/ — the chunk would load and that import would fail`)
}
}
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)
const split = relativeImports(entry)
console.log(
`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency` +
(split.length ? `; its ${split.length} split chunk(s) (${split.join(', ')}) are present and clean.` : '.'),
)
}