Skip to content

Instantly share code, notes, and snippets.

@billywhizz
Created August 26, 2026 19:01
Show Gist options
  • Select an option

  • Save billywhizz/c891b157c87b44dc3591a1089655b35e to your computer and use it in GitHub Desktop.

Select an option

Save billywhizz/c891b157c87b44dc3591a1089655b35e to your computer and use it in GitHub Desktop.
#!/usr/bin/env node
// v8-deprecation-scan.js [--lo-dir <path>]
//
// Runs under either `node` or `lo` (verified against real output from
// both - see V8.md section 9 for the diff). Automates the *mechanical*
// half of that sweep: fetches V8 mainline's real embedder headers,
// extracts every V8_DEPRECATED/V8_DEPRECATE_SOON-tagged declaration,
// then greps <lo-dir> (default: ../repos/lo relative to this script)
// for method calls matching those names. Prints a candidate report to
// stdout - each candidate includes the real call site as a markdown
// link to the exact line on GitHub (pinned to <lo-dir>'s checked-out
// commit, not a moving branch), a link to the deprecated declaration in
// V8's own source (pinned to the commit its headers were fetched at),
// and a link to any crbug/bugs.chromium.org tracking bug mentioned in
// V8's own comment near the tag, when there is one.
//
// Deliberately does NOT try to fully automate V8.md section 9's table.
// Two things a script can't do reliably, left as a manual/AI follow-up
// pass on this report's output:
// 1. Confirm a name match is the *same overload/class*, not a
// same-named method on something unrelated (e.g. this session's
// Script::GetId() vs StackFrame::GetScriptId() false positive -
// only caught by reading the actual call site's receiver type).
// 2. Real-world removal-timeline evidence (e.g. finding that Node.js
// already migrated off a given API years ago and it's *still* not
// removed upstream) - that needs web research, not grep.
//
// Runtime notes for the `lo` path (verified empirically against
// /root/lo, not assumed from docs - see V8.md section 9's dual-runtime
// note for what was actually tested):
// - No global `fetch()`/`Buffer` under `lo`. Network fetch uses
// `lib/fs.js`/`lib/curl.js` - both **builtins**, `.incbin`-linked
// into the binary (`lo.builtins()` lists them), so they resolve as
// plain bare `import ... from 'lib/curl.js'` specifiers from *any*
// CWD, no filesystem dependency on a repos/lo checkout at all.
// `lib/curl.js`'s `fetch_fd(url, fd)` writes straight into an
// in-memory fd (`core.memfd_create` - no temp file, per direct user
// suggestion) - but note it sets `CURLOPT_HEADER=1`, so the raw HTTP
// response header block(s) land in the same stream ahead of the
// body and need stripping (one block per redirect hop) - confirmed
// by testing against a real URL, this wasn't obvious from the
// source alone. Base64 (gitiles `?format=TEXT`) is decoded with a
// small hand-rolled decoder used uniformly by *both* runtimes
// (simpler than branching Buffer vs. a lo-only decoder).
// - `fetch_fd`'s returned `size` (`CURLINFO_SIZE_DOWNLOAD_T`) is the
// response *body* size only - it never counts the extra header
// bytes `CURLOPT_HEADER=1` (see above) adds to the same stream, on
// top of the body. Trusting it truncated a real JSON response
// mid-string in testing; first guess was a gzip-decompression
// effect, which was wrong - confirmed the real, exact mechanism
// with `lib/pico.js`'s real HTTP parser instead of guessing further
// (a real response had 1329 header bytes + 11910 reported body
// bytes = 13239 total, matching `fstat` precisely). `fetchRawText`
// below `fstat`s the fd for the real total instead of trusting the
// return value - see PLAN.md task 49 bug 3 for whether the real fix
// belongs in `lib/curl.js` itself.
// - `lo.core.readFile`/`writeFile` can hard-crash the process (real,
// documented, uncatchable V8 OOM abort - see LO.md) if called with
// bare-string Node-`fs`-shaped arguments. File reads here always go
// through the raw `open`/`fstat`/`read`/`close` sequence instead,
// matching `lib/fs.js`'s own established-safe pattern - confirmed
// empirically against a real file before trusting it, not assumed.
// - `lib/proc.js`'s `exec()` has a real, documented bug (missing
// `lo.exit()` after a failed `execvp` in the forked child re-runs
// the whole calling script a second time as an orphaned duplicate -
// see OPENVSCODE-SERVER-PLAN.md). Sidestepped entirely: git
// metadata is read directly from `.git/HEAD`/`.git/config`/
// `.git/refs` (plain file reads, real git plumbing, no subprocess)
// instead of shelling out to `git rev-parse`/`git remote`. If a
// future script genuinely needs subprocess output capture,
// `lib/pmon.js`'s `Process` class is the safer option (real pipes +
// it actually calls `lo.exit()` on a failed exec, unlike
// `lib/proc.js`) - not needed here, noted for next time.
// - Directory walking uses `lib/fs.js`'s builtin `readdir_sync`
// (recursive by default, unlike Node's) rather than re-deriving its
// OS-specific dirent-parsing offset math by hand.
// - `console.log`/`console.error` under `lo` are single-parameter
// functions - extra arguments are silently dropped (confirmed
// directly in `main.js`: `log: str => write_string(STDOUT,
// \`${str}\n\`)`). Every call in this file passes exactly one
// (template-literal) argument, which is also perfectly valid Node
// style - no branching needed for this one.
// - `LO_HOME` (used elsewhere in this repo to point `lo` at a
// checkout for its own non-builtin `lib/*.js` resolution) isn't
// needed here - both modules used are builtins, not read from
// `--lo-dir` at all; `--lo-dir` is purely the directory this script
// scans, unrelated to module resolution.
const isNode = !!globalThis.process?.versions?.node
const HEADERS = [
'v8-object.h', 'v8-value.h', 'v8-function.h', 'v8-context.h',
'v8-isolate.h', 'v8-primitive.h', 'v8-array-buffer.h', 'v8-script.h',
'v8-microtask-queue.h', 'v8-exception.h', 'v8-data.h',
'v8-primitive-object.h', 'v8-promise.h'
]
const HEADER_BASE = 'https://chromium.googlesource.com/v8/v8/+/refs/heads/main/include/'
// ---------- universal helpers (no runtime-specific APIs) ----------
const B64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
function base64Decode (b64) {
const clean = b64.replace(/[\r\n\s]/g, '').replace(/=+$/, '')
const bytes = []
let buffer = 0
let bits = 0
for (let i = 0; i < clean.length; i++) {
const idx = B64_CHARS.indexOf(clean[i])
if (idx === -1) continue
buffer = (buffer << 6) | idx
bits += 6
if (bits >= 8) {
bits -= 8
bytes.push((buffer >> bits) & 0xff)
}
}
return new Uint8Array(bytes)
}
function relPath (from, to) {
return to.startsWith(from + '/') ? to.slice(from.length + 1) : to
}
// Collapses `.`/`..`/`//` segments in a POSIX path string. Needed
// because LO_DIR is built with a literal `..` (scriptDir/../repos/lo) -
// Node's path.join later normalizes that away when building call-site
// paths, but our own manual string joins (both runtimes' listSourceFiles,
// and relPath's prefix match) don't - normalize once here, at the one
// place `..` gets introduced, so every path built *from* LO_DIR downstream
// stays consistent regardless of runtime, instead of diverging.
function normalizePath (p) {
const abs = p.startsWith('/')
const parts = p.split('/').filter(s => s && s !== '.')
const out = []
for (const part of parts) {
if (part === '..' && out.length && out[out.length - 1] !== '..') out.pop()
else out.push(part)
}
return (abs ? '/' : '') + out.join('/')
}
// ---------- argv / script location ----------
const argv = isNode ? process.argv.slice(2) : lo.args.slice(2)
const loDirFlagIdx = argv.indexOf('--lo-dir')
// __dirname doesn't exist in real ESM (this file has top-level await, so
// Node parses it as ESM, not CommonJS) - derive it from import.meta.url
// instead, same as bindings/gen-loader.js does.
const scriptDir = isNode
? new URL('.', import.meta.url).pathname.replace(/\/$/, '')
: lo.args[1].split('/').slice(0, -1).join('/')
const LO_DIR = normalizePath(loDirFlagIdx !== -1 && argv[loDirFlagIdx + 1]
? argv[loDirFlagIdx + 1]
: `${scriptDir}/../repos/lo`)
// ---------- runtime-specific I/O shims ----------
// Each runtime fills in: fetchRawText, readFileText, listSourceFiles,
// fatalExit. Everything below this block is runtime-agnostic.
let fetchRawText, readFileText, listSourceFiles, fatalExit
if (isNode) {
// dynamic import, not require() - this file also has top-level await
// (the lo branch below), and Node can't parse a file as CommonJS
// (require) and ESM (top-level await) at once - matches
// bindings/gen-module.js's own established style for exactly this
// reason.
const fs = await import('node:fs')
const path = await import('node:path')
fetchRawText = async (url) => {
const res = await fetch(url)
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`)
return res.text()
}
readFileText = (p) => fs.readFileSync(p, 'utf8')
listSourceFiles = (dir) => {
const results = []
const skip = new Set(['v8', '.git', 'node_modules'])
function walk (d) {
let entries
try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { return }
for (const e of entries) {
if (skip.has(e.name)) continue
const full = path.join(d, e.name)
if (e.isDirectory()) walk(full)
else if (e.name.endsWith('.cc') || e.name === 'api.js') results.push(full)
}
}
walk(dir)
// deterministic order - Node's readdirSync and lo's readdir_sync
// enumerate directory entries differently (alphabetical vs. raw
// dirent/inode order), which otherwise reorders (not loses) call
// sites between runtimes - confirmed by diffing real output from
// both before adding this.
results.sort()
return results
}
fatalExit = (msg) => { console.error(msg); process.exit(1) }
} else {
// lo runtime - see the header comment above for why each piece here
// is shaped the way it is (all verified empirically, not guessed).
// Both imports are builtins (lo.builtins() lists them, .incbin-linked
// into the binary) - plain bare specifiers, no filesystem/CWD
// dependency on any repos/lo checkout.
const { readdir_sync } = await import('lib/fs.js')
const { fetch_fd } = await import('lib/curl.js')
const { ResponseParser } = await import('lib/pico.js')
const { assert, ptr, utf8Decode, core } = lo
const { memfd_create, lseek, read: coreRead, close: coreClose, open: coreOpen, fstat, O_RDONLY } = core
const SEEK_SET = 0
// fetch_fd sets CURLOPT_HEADER=1 (fixed upstream in this session's own
// repos/lo/lib/curl.js edit, PLAN.md task 49 bug 1 - but this script
// should keep working against a not-yet-rebuilt `lo` binary too), so
// the raw HTTP response header block(s) - one per redirect hop - land
// in the stream ahead of the body. Skip them with a real HTTP parser
// (lib/pico.js's ResponseParser, per direct user suggestion) rather
// than a hand-rolled regex - more correct (protocol-aware, exact byte
// count) and it's what caught the *actual* mechanism of the size bug
// below (a regex-based guess at "gzip decompression" was wrong).
function skipHttpHeaders (buf, size) {
let offset = 0
let hops = 0
const parser = new ResponseParser(buf, 32)
while (hops < 5) {
const consumed = parser.parse(size - offset)
if (consumed <= 0) break
offset += consumed
hops++
if (parser.status < 300 || parser.status >= 400) break
parser.rb = buf.subarray(offset)
}
return offset
}
fetchRawText = (url) => {
const fd = memfd_create('v8-deprecation-scan', 0)
assert(fd > 0, 'memfd_create failed')
fetch_fd(url, fd, 200)
// Real bug, confirmed precisely (not just worked around) using
// ResponseParser above: fetch_fd's returned `size`
// (CURLINFO_SIZE_DOWNLOAD_T) is the response *body* size only - it
// never counted the extra header bytes CURLOPT_HEADER=1 adds to the
// same stream, on top of the body. Confirmed exactly: a real
// response had 1329 header bytes + 11910 body bytes (the reported
// `size`) = 13239 total, matching `fstat` precisely - not a
// gzip/compression effect as first (wrongly) guessed, just headers
// never being counted. fstat the fd for the real total instead of
// trusting the returned size, same safe-size pattern as
// readFileText below.
const statBuf = ptr(new Uint8Array(160))
assert(fstat(fd, statBuf.ptr) === 0)
const size = Number(new BigUint64Array(statBuf.buffer)[6])
assert(lseek(fd, 0, SEEK_SET) === 0)
const buf = ptr(new Uint8Array(size))
const n = coreRead(fd, buf.ptr, size)
coreClose(fd)
const bodyOffset = skipHttpHeaders(buf, n)
return utf8Decode(buf.ptr + bodyOffset, n - bodyOffset)
}
readFileText = (p) => {
const fd = coreOpen(p, O_RDONLY)
if (fd <= 0) throw new Error(`could not open ${p}`)
const statBuf = ptr(new Uint8Array(160))
assert(fstat(fd, statBuf.ptr) === 0)
const size = Number(new BigUint64Array(statBuf.buffer)[6])
const buf = ptr(new Uint8Array(size))
const n = coreRead(fd, buf.ptr, size)
coreClose(fd)
return utf8Decode(buf.ptr, n)
}
listSourceFiles = (dir) => {
const skip = new Set(['v8', '.git', 'node_modules'])
const results = []
function walk (d) {
let entries
try { entries = readdir_sync(d, [], { recursive: false }) } catch { return }
for (const e of entries) {
if (skip.has(e.name)) continue
if (e.isDirectory) walk(`${d}/${e.name}`)
else if (e.isFile && (e.name.endsWith('.cc') || e.name === 'api.js')) {
results.push(`${d}/${e.name}`)
}
}
}
walk(dir)
// deterministic order - Node's readdirSync and lo's readdir_sync
// enumerate directory entries differently (alphabetical vs. raw
// dirent/inode order), which otherwise reorders (not loses) call
// sites between runtimes - confirmed by diffing real output from
// both before adding this.
results.sort()
return results
}
fatalExit = (msg) => { console.error(msg); lo.exit(1) }
}
// ---------- git plumbing, no subprocess (universal - see header comment) ----------
function resolveGithubOrigin (dir) {
const fallback = { repo: 'just-js/lo', ref: 'main' }
try {
const config = readFileText(`${dir}/.git/config`)
const remoteBlock = config.match(/\[remote "origin"\]([^[]*)/)
const urlMatch = remoteBlock && remoteBlock[1].match(/url\s*=\s*(\S+)/)
const remote = urlMatch ? urlMatch[1] : ''
const m = remote.match(/github\.com[:/]([^/]+\/[^/.]+)(\.git)?\/?$/)
const repo = m ? m[1] : fallback.repo
const head = readFileText(`${dir}/.git/HEAD`).trim()
let ref = fallback.ref
const refMatch = head.match(/^ref:\s*(\S+)/)
if (refMatch) {
try {
ref = readFileText(`${dir}/.git/${refMatch[1]}`).trim()
} catch {
const packed = readFileText(`${dir}/.git/packed-refs`)
const line = packed.split('\n').find(l => l.endsWith(` ${refMatch[1]}`))
if (line) ref = line.split(' ')[0]
}
} else if (/^[0-9a-f]{40}$/.test(head)) {
ref = head
}
return { repo, ref }
} catch {
return fallback
}
}
async function resolveV8Ref () {
try {
const body = await fetchRawText('https://api.github.com/repos/v8/v8/commits/main')
const data = JSON.parse(body)
return data.sha || 'main'
} catch {
return 'main'
}
}
function githubLink (repo, ref, relFile, line) {
return `https://github.com/${repo}/blob/${ref}/${relFile}#L${line}`
}
function v8SourceLink (v8Ref, header, line) {
return `https://github.com/v8/v8/blob/${v8Ref}/include/${header}#L${line}`
}
function crbugLink (id) {
return `https://crbug.com/${id}`
}
// ---------- header parsing ----------
async function fetchHeader (name) {
const b64 = await fetchRawText(`${HEADER_BASE}${name}?format=TEXT`)
const bytes = base64Decode(b64)
return new TextDecoder().decode(bytes)
}
function findCrbug (lines, i) {
const contextStart = Math.max(0, i - 15)
const contextText = lines.slice(contextStart, i).join('\n')
const crbugMatch = contextText.match(/crbug\.com\/(\d+)|bugs\.chromium\.org\/p\/chromium\/issues\/detail\?id=(\d+)/)
return crbugMatch ? (crbugMatch[1] || crbugMatch[2]) : null
}
// C++ keywords/primitive types that can appear immediately before a `(`
// without being the name of anything - a bare cast or a function-pointer
// type's parameter list (e.g. `void (*)(...)`), not a declared identifier.
// Guards the method-style fallback below: without this, a `using Name
// V8_DEPRECATE_SOON(...) = void (*)(...)` alias (real case:
// AccessorNameSetterCallback in v8-object.h) mis-extracts "void" as the
// deprecated name, and a bare \bvoid\b call-site match then matches
// nearly every function in a C++ file - confirmed live, this exact shape
// produced ~30 garbage "call sites" before this guard was added.
const CPP_NON_NAME_WORDS = new Set([
'void', 'int', 'bool', 'char', 'double', 'float', 'auto', 'const',
'static', 'unsigned', 'signed', 'long', 'short'
])
// Extracts every V8_DEPRECATED(...)/V8_DEPRECATE_SOON(...) tag, plus any
// crbug/bugs.chromium.org reference in the ~15 lines above the tag. Three
// declaration shapes, checked in this order:
// 1. Enum-style: `<name> V8_DEPRECATED(...) = <value>,` - the tagged
// name sits on the tag's own line, *before* the tag (confirmed
// against the real PromiseRejectEvent declaration in
// include/v8-promise.h - this session's own kPromiseRejectAfterResolved
// miss was exactly this shape going unrecognized).
// 2. Alias-style: `using <Name> // \n V8_DEPRECATE_SOON(...) = <type>;`
// - the name is on a line *before* the tag, not after (real case:
// AccessorNameSetterCallback in v8-object.h).
// 3. Method-style: the tag stands alone (or wraps a multi-line reason
// string) and the declaration - and the name to extract - follows on
// a later line, e.g. GetEmbedderData/SetEmbedderData.
function extractDeprecations (source, headerName) {
const lines = source.split('\n')
const out = []
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/V8_(DEPRECATED|DEPRECATE_SOON)\(/)
if (!m) continue
const tier = m[1]
let tagText = lines[i]
let j = i
while (!tagText.includes(')') && j - i < 4) {
j++
tagText += ' ' + lines[j]
}
// reason strings are sometimes split across adjacent C++ string
// literals (compiler-concatenated) - join every quoted segment.
const reasonParts = [...tagText.matchAll(/"([^"]*)"/g)].map(x => x[1])
const reason = reasonParts.length ? reasonParts.join('') : '(no reason string)'
const inlineMatch = lines[i].match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s+V8_(DEPRECATED|DEPRECATE_SOON)\(/)
if (inlineMatch) {
out.push({
method: inlineMatch[1],
reason,
tier,
header: headerName,
headerLine: i + 1,
snippet: lines[i].trim(),
crbugId: findCrbug(lines, i)
})
continue
}
const usingMatch = (lines[i - 1] || '').match(/^\s*using\s+([A-Za-z_][A-Za-z0-9_]*)\b/) ||
(lines[i - 2] || '').match(/^\s*using\s+([A-Za-z_][A-Za-z0-9_]*)\b/)
if (usingMatch) {
out.push({
method: usingMatch[1],
reason,
tier,
header: headerName,
headerLine: i + 1,
snippet: lines[i].trim(),
crbugId: findCrbug(lines, i)
})
continue
}
// Method-style fallback: scan forward for the first identifier-then-
// paren that isn't just a keyword/type (see CPP_NON_NAME_WORDS above),
// giving up once the declaration's own `;` is reached.
let k = j + 1
let methodMatch = null
while (k < lines.length) {
while (k < lines.length && lines[k].trim() === '') k++
const candidate = (lines[k] || '').match(/([A-Za-z_][A-Za-z0-9_]*)\s*\(/)
if (candidate && !CPP_NON_NAME_WORDS.has(candidate[1])) {
methodMatch = candidate
break
}
if ((lines[k] || '').includes(';')) break
k++
}
if (!methodMatch) continue
out.push({
method: methodMatch[1],
reason,
tier,
header: headerName,
headerLine: k + 1,
snippet: (lines[k] || '').trim(),
crbugId: findCrbug(lines, i)
})
}
return out
}
// Bare \bname\b (not just `(->|\.)name(`) so this also catches deprecated
// *values* - enum constants, static constexpr members - used as plain
// identifiers (`using v8::kPromiseRejectAfterResolved;`,
// `data.GetEvent() == kPromiseRejectAfterResolved`), not just method
// calls. Subsumes the old method-call-only pattern: `\b` already matches
// at the `>`/`.` boundary a call site like `foo->methodName(` has.
function findCallSites (files, methodName) {
const hits = []
const re = new RegExp(`\\b${methodName}\\b`)
for (const f of files) {
const lines = readFileText(f).split('\n')
lines.forEach((line, idx) => {
if (re.test(line)) hits.push({ file: f, line: idx + 1, text: line.trim() })
})
}
return hits
}
// ---------- main ----------
async function main () {
console.error(`Fetching ${HEADERS.length} V8 headers from chromium.googlesource.com...`)
const deprecations = []
for (const h of HEADERS) {
try {
const src = await fetchHeader(h)
deprecations.push(...extractDeprecations(src, h))
} catch (e) {
console.error(` skip ${h}: ${e.message}`)
}
}
console.error(`Found ${deprecations.length} deprecated declarations across fetched headers.`)
let dirExists = true
try { readFileText(`${LO_DIR}/lo.cc`) } catch { dirExists = false }
if (!dirExists) {
fatalExit(`lo dir not found (or missing lo.cc): ${LO_DIR} (pass --lo-dir <path>)`)
return
}
const sourceFiles = listSourceFiles(LO_DIR)
console.error(`Scanning ${sourceFiles.length} .cc/api.js files under ${LO_DIR}...`)
const origin = resolveGithubOrigin(LO_DIR)
console.error(`Linking call sites to https://github.com/${origin.repo}/blob/${origin.ref}/...`)
const v8Ref = await resolveV8Ref()
console.error(`Linking V8 source to https://github.com/v8/v8/blob/${v8Ref}/...`)
const candidates = []
for (const dep of deprecations) {
const hits = findCallSites(sourceFiles, dep.method)
if (hits.length) candidates.push({ ...dep, hits })
}
console.log(`# V8 deprecation scan — ${new Date().toISOString().slice(0, 10)} (${isNode ? 'node' : 'lo'})`)
console.log('')
console.log(`Fetched ${HEADERS.length} headers, found ${deprecations.length} deprecated`)
console.log(`declarations, ${candidates.length} have a name match in ${LO_DIR}.`)
console.log('')
console.log('**Candidates only — verify each before trusting it.** A name match can')
console.log('be a false positive (same method name, different class/overload) -')
console.log('check the call site below actually matches the deprecated signature.')
console.log('No risk/timeline assessment here; see V8.md section 9 for that layer.')
console.log('')
if (!candidates.length) {
console.log('No candidates found.')
return
}
for (const c of candidates) {
const srcLink = v8SourceLink(v8Ref, c.header, c.headerLine)
console.log(`## \`${c.method}\` — ${c.tier} ([${c.header}:${c.headerLine}](${srcLink}))`)
console.log('')
console.log(`Reason: ${c.reason}`)
if (c.crbugId) {
console.log('')
console.log(`Tracking bug: [crbug.com/${c.crbugId}](${crbugLink(c.crbugId)})`)
}
console.log('')
console.log(`Declaration: \`${c.snippet}\` ([source](${srcLink}))`)
console.log('')
console.log('Call sites:')
for (const hit of c.hits) {
const rel = relPath(LO_DIR, hit.file)
const link = githubLink(origin.repo, origin.ref, rel, hit.line)
console.log(`- [\`${rel}:${hit.line}\`](${link}) — \`${hit.text}\``)
}
console.log('')
}
}
main().catch(e => fatalExit(`${e.stack || e}`))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment