/** * CI HARD GATE — the shipped bundle must not be *able* to fetch a game archive. * * WHY A STRING GREP IS NOT ENOUGH (and what this does instead) * ------------------------------------------------------------ * The obvious gate is "grep `dist-game/` for `d2char.mpq`". That gate is a trap. * It proves only that *one particular spelling* is absent, and every one of * these evades it while leaving the capability fully intact: * * const a = 'd2char' + '.mpq' * const b = `${name}.mpq` * const c = ['d2','char','.mpq'].join('') * * So the moment someone removes the literal — by an innocent refactor, by a * minifier transformation, or by deliberately routing around the gate — it goes * green, convincingly, while the bundle can still stream an archive. A false * green is worse than no gate, because it terminates scrutiny. * * This gate therefore asserts a **capability**, not a spelling: * * 1. CAPABILITY_NOT_REACHABLE (primary) * Reconstruct the chunk dependency graph from the emitted artifact * itself — follow `import("./x.js")` and `from"./x.js"` out of every * HTML entry — and assert that no reachable chunk contains the * *behavioural* fingerprints of the MPQ machinery: the HTTP-Range * archive reader and the MPQ header decoder. Those fingerprints are * error-message string literals inside the functions themselves, which * survive minification and have nothing to do with any archive filename. * Renaming or concatenating `d2char.mpq` does not move them. * * 2. NO_ARCHIVE_LITERALS (secondary, retained) * The original literal scan. Still useful: it catches a new archive name * that no fingerprint covers. * * 3. FRAGMENT_SCAN (informational) * Fragmented forms — a bare `.mpq`, a standalone `mpq`/`dll` token — * listed for human review, since a concatenation gate cannot be made * reliable automatically. * * Both `*.mpq` AND `*.dll` are covered; the acceptance criterion names both. * * KNOWN-RED ON `main` (2026-09-21) — the expected, desired result * --------------------------------------------------------------- * `src/scene/act-scene.ts` has an unguarded production path: * * loadCharacterArt L2384 * -> getMountedCharArchives L2013 * -> getCachedMpqArchive L1968 * -> httpRangeSource(base + '/d2char.mpq') L1974 * * Measured consequence in the artifact: `acts-*.js` dynamically imports * `source-*.js` (the HTTP-Range reader) and `archive-*.js` (the MPQ decoder), * and carries `DATA_ARCHIVES` / `CHARACTER_ARCHIVE` through minification. * * ⚠ BINDING NOTE FOR THE MILESTONE THAT FIXES THIS (M4): * the fix must be **STRUCTURAL** — remove the live-MPQ path from the production * entry graph, or isolate it behind a dev-only entry point, so the chunks are * not emitted/reachable at all. It must NOT be renaming the constants, and it * must NOT be splitting them into concatenation to evade the literal scan. * Assertion 1 exists precisely so that evasion cannot produce a green gate. * * SOURCE MAPS * ----------- * A `.map` embeds `sourcesContent`, i.e. the entire original source text, so a * literal survives in the map even after the code using it is perfectly * dead-code-eliminated. Hard-failing on maps would make the gate unsatisfiable * without deleting the string from the source tree — exactly the pressure that * gets a gate weakened instead of a bug fixed. Maps are therefore * INFORMATIONAL, and are classified **by content** (parsed as a source map with * `version` + `sources`), not by filename, so renaming an executable asset to * `*.map` cannot launder it into the exempt bucket. * * VACUITY GUARD * ------------- * A gate that passes because it examined nothing is the same failure class it * is meant to prevent. The run fails unless it actually scanned at least one * emitted `.js` asset and resolved at least one HTML entry into a chunk graph. * * Usage: * npm run verify:bundle-no-mpq * npm run verify:bundle-no-mpq -- --dir=dist-game --report= */ import { readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'node:fs' import { dirname, extname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const ROOT = resolve(process.cwd()) // --------------------------------------------------------------------------- // Patterns // --------------------------------------------------------------------------- /** * Whole archive/library filenames. * * The named entries mirror `src/scene/act-scene.ts` L80-82 (`DATA_ARCHIVES`, * `CHARACTER_ARCHIVE`); the two catch-alls mean a newly introduced archive name * cannot slip past just because this list was not updated. */ const FORBIDDEN_PATTERNS: readonly { readonly label: string; readonly re: RegExp }[] = [ { label: 'd2char.mpq', re: /d2char\.mpq/gi }, { label: 'd2data.mpq', re: /d2data\.mpq/gi }, { label: 'd2exp.mpq', re: /d2exp\.mpq/gi }, { label: 'Patch_D2.mpq', re: /patch_d2\.mpq/gi }, { label: '.mpq', re: /[\w-]+\.mpq/gi }, { label: '.dll', re: /[\w-]+\.dll/gi }, ] /** * Fragmented spellings a literal scan cannot reason about. * * Reported, never auto-failed: `'mpq'` appears in plenty of innocent contexts * (a directory name, a comment that survived, a decoder's own identity string). * Automatic failure here would produce noise that trains people to ignore the * gate. Assertion 1 is what actually closes the concatenation hole; this exists * so a human can see the fragments while reviewing. */ const FRAGMENT_PATTERNS: readonly { readonly label: string; readonly re: RegExp }[] = [ { label: "bare '.mpq'", re: /["'`]\.mpq["'`]/gi }, { label: "bare '.dll'", re: /["'`]\.dll["'`]/gi }, { label: "standalone 'mpq' string", re: /["'`]mpq["'`]/gi }, { label: "standalone 'dll' string", re: /["'`]dll["'`]/gi }, ] /** * Behavioural fingerprints of the MPQ machinery. * * These are error-message literals from inside the functions that implement the * capability, so they identify *the code being present*, independent of any * archive filename. Verified to survive Vite/esbuild minification in the * current build (they live in `source-*.js` and `archive-*.js`). */ const CAPABILITY_FINGERPRINTS: readonly { readonly id: string readonly needle: string readonly origin: string readonly why: string }[] = [ { id: 'HTTP_RANGE_ARCHIVE_READER', needle: 'range requests are required to read an archive this large', origin: 'src/mpq/source.ts httpRangeSource() L107-114', why: 'the function that streams an archive over HTTP Range — the actual download capability', }, { id: 'MPQ_HEADER_DECODER', needle: 'not an MPQ archive (magic 0x', origin: 'src/mpq/archive.ts MpqArchive.open() L136', why: 'the MPQ container decoder; present only if archive parsing ships', }, { id: 'MPQ_HASH_TABLE_DECODER', needle: 'is not a non-zero power of two', origin: 'src/mpq/archive.ts L156', why: 'MPQ hash-table validation; corroborates the decoder fingerprint', }, ] /** Extensions considered emitted/executed output. */ const EXECUTABLE_EXT = new Set(['.js', '.mjs', '.cjs', '.html', '.htm', '.css', '.json']) // --------------------------------------------------------------------------- // Model // --------------------------------------------------------------------------- interface Occurrence { readonly file: string readonly pattern: string readonly count: number readonly sample: string } interface CapabilityHit { readonly fingerprintId: string readonly file: string readonly reachableFrom: readonly string[] readonly origin: string readonly why: string } export interface BundleScanResult { readonly root: string readonly filesTotal: number readonly emittedJsScanned: number readonly entries: readonly string[] /** entry html -> emitted chunk files reachable from it. */ readonly reachable: Readonly> readonly orphanChunks: readonly string[] readonly capabilityHits: readonly CapabilityHit[] readonly literalHard: readonly Occurrence[] readonly literalSourceMapOnly: readonly Occurrence[] readonly fragments: readonly Occurrence[] } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function walk(dir: string, out: string[] = []): string[] { for (const entry of readdirSync(dir)) { const full = join(dir, entry) if (statSync(full).isDirectory()) walk(full, out) else out.push(full) } return out } /** * Decide whether a file is a genuine source map, by parsing it. * * Extension alone is not enough: a rename must not be able to move an * executable asset into the informational bucket. */ function isRealSourceMap(text: string): boolean { if (text.length === 0 || !text.trimStart().startsWith('{')) return false try { const parsed = JSON.parse(text) as Record return ( typeof parsed['version'] === 'number' && Array.isArray(parsed['sources']) && (parsed['mappings'] !== undefined || parsed['sourcesContent'] !== undefined) ) } catch { // Not parseable as JSON, therefore not a source map. This is a // classification answer, not a swallowed error: the caller treats the file // as emitted output, which is the conservative direction. return false } } function sampleAround(text: string, re: RegExp): string { const probe = new RegExp(re.source, re.flags.replace('g', '')) const m = probe.exec(text) if (m === null) return '(no sample)' const from = Math.max(0, m.index - 90) const to = Math.min(text.length, m.index + 90) return `...${text.slice(from, to).replace(/\s+/g, ' ')}...` } /** * Rebuild the chunk dependency graph from the artifact. * * Reading the artifact rather than a build-time manifest matters: it is the * thing that actually ships, and it needs no change to `vite.config.ts` (which * this milestone does not own). Static `from"./x.js"` / `import"./x.js"` and * dynamic `import("./x.js")` are all followed, so a lazily-imported chunk — the * exact shape the MPQ fallback uses — is still counted as reachable. */ function buildChunkGraph(root: string, files: readonly string[]): { entries: string[] reachable: Record orphans: string[] } { const rel = (f: string): string => relative(root, f).split('\\').join('/') const byRel = new Map() for (const f of files) byRel.set(rel(f), f) const htmlEntries = files.filter(f => f.toLowerCase().endsWith('.html')).map(rel) const refsOf = (relPath: string): string[] => { const abs = byRel.get(relPath) if (abs === undefined) return [] let text: string try { text = readFileSync(abs, 'utf8') } catch { return [] } const out = new Set() const dir = dirname(relPath) const add = (spec: string): void => { const joined = spec.startsWith('/') ? spec.replace(/^\/diablo2\//, '').replace(/^\//, '') : join(dir, spec).split('\\').join('/') if (byRel.has(joined)) out.add(joined) } // HTML: