// Attribute a built chunk's minified bytes to source npm packages / workspace dirs via its // sourcemap (zero-dependency VLQ decoder). The dist-audit companion of the shell chunk-layout // decision (.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md): // verifies vendor/index membership after changing VENDOR_PACKAGES or bumping render deps. // Usage: node scripts/attribute-chunk-bytes.mjs [--top N] import { readFileSync } from 'node:fs' const chunkPath = process.argv[2] const topN = Number(process.argv[process.argv.indexOf('--top') + 1] || 40) const code = readFileSync(chunkPath, 'utf8') const map = JSON.parse(readFileSync(chunkPath + '.map', 'utf8')) const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' const charToInt = new Map([...B64].map((c, i) => [c, i])) // Decode one VLQ-encoded segment list line by line. const lines = code.split('\n') const bySource = new Float64Array(map.sources.length) let unmapped = 0 let srcIdx = 0, srcLine = 0, srcCol = 0, nameIdx = 0 const mappingLines = map.mappings.split(';') for (let li = 0; li < mappingLines.length; li++) { const lineLen = li < lines.length ? lines[li].length + 1 : 0 const segsRaw = mappingLines[li] if (segsRaw === '') { unmapped += lineLen; continue } let genCol = 0 const segs = [] for (const segStr of segsRaw.split(',')) { const fields = [] let shift = 0, value = 0 for (const ch of segStr) { const digit = charToInt.get(ch) value += (digit & 31) << shift if (digit & 32) { shift += 5 } else { fields.push((value & 1) ? -(value >>> 1) : (value >>> 1)) shift = 0; value = 0 } } genCol += fields[0] if (fields.length > 1) { srcIdx += fields[1]; srcLine += fields[2]; srcCol += fields[3] if (fields.length > 4) nameIdx += fields[4] segs.push([genCol, srcIdx]) } else { segs.push([genCol, -1]) } } if (segs[0][0] > 0) unmapped += segs[0][0] for (let i = 0; i < segs.length; i++) { const end = i + 1 < segs.length ? segs[i + 1][0] : lineLen const span = Math.max(0, end - segs[i][0]) if (segs[i][1] >= 0) bySource[segs[i][1]] += span else unmapped += span } } // Aggregate source path -> package bucket. function bucketOf(src) { if (src.startsWith('') || src.includes('vite/')) return '(vite virtual/helpers)' const nm = [...src.matchAll(/node_modules\/(@[^/]+\/[^/]+|[^@./][^/]*)\//g)] if (nm.length > 0) return nm[nm.length - 1][1] let m = src.match(/packages\/([^/]+\/[^/]+)\//) if (m) return 'ws:packages/' + m[1] m = src.match(/vendor\/([^/]+)\//) if (m) return 'ws:vendor/' + m[1] m = src.match(/apps\/web\//) if (m) return 'ws:apps/web' return src } const byBucket = new Map() for (let i = 0; i < map.sources.length; i++) { if (bySource[i] === 0) continue const b = bucketOf(map.sources[i]) byBucket.set(b, (byBucket.get(b) || 0) + bySource[i]) } byBucket.set('(unmapped: interop glue/helpers)', unmapped) const total = code.length const rows = [...byBucket.entries()].sort((a, b) => b[1] - a[1]) const kb = n => (n / 1024).toFixed(1).padStart(8) const pc = n => ((n / total) * 100).toFixed(1).padStart(5) console.log(`chunk: ${chunkPath} total ${(total / 1024).toFixed(1)} kB (minified, pre-gzip)`) console.log(`${'kB'.padStart(8)} ${'%'.padStart(5)} package`) let shown = 0, acc = 0 for (const [name, bytes] of rows) { if (shown++ < topN) console.log(`${kb(bytes)} ${pc(bytes)} ${name}`) acc += bytes } if (rows.length > topN) console.log(` ... ${rows.length - topN} more buckets`) console.log(`accounted: ${(acc / 1024).toFixed(1)} kB of ${(total / 1024).toFixed(1)} kB`) // Group summary: npm vendor vs workspace. let vendor = 0, ws = 0, other = 0 for (const [name, bytes] of rows) { if (name.startsWith('ws:')) ws += bytes else if (name.startsWith('(')) other += bytes else vendor += bytes } console.log(`\nGROUPS npm-vendor ${(vendor / 1024).toFixed(1)} kB | workspace ${(ws / 1024).toFixed(1)} kB | glue ${(other / 1024).toFixed(1)} kB`)