Skip to content

Instantly share code, notes, and snippets.

@sladg
Last active June 24, 2026 17:39
Show Gist options
  • Select an option

  • Save sladg/fa0fdb90ee1736a68ec3eeb503044145 to your computer and use it in GitHub Desktop.

Select an option

Save sladg/fa0fdb90ee1736a68ec3eeb503044145 to your computer and use it in GitHub Desktop.
type-profiler: per-symbol TypeScript instantiation + RAM profiler

type-profiler

Per-file, per-symbol TypeScript type resolution profiler. Finds instantiation explosions, OOM types, and RAM hogs across your codebase.

Setup

# Clone the gist into a directory
git clone https://gist.github.com/sladg/fa0fdb90ee1736a68ec3eeb503044145 type-profiler
cd type-profiler

# Run the setup script (moves main.rs → src/main.rs and builds)
bash setup.sh

Binary: target/release/type-profiler

Usage

# Profile a project (DB auto-cached to /tmp/type-profiler-<project>.db)
./type-profiler /path/to/project

# Human-readable output
./type-profiler /path/to/project --format text

# Re-run only the 10 worst files from last run
./type-profiler /path/to/project --format text --rerun-worst 10

# Skip files whose previous instantiation count was below 50k
./type-profiler /path/to/project --format text --threshold 50000

# Diff two runs
./type-profiler /path/to/project --output current.db --compare previous.db --format text

# Incremental (skip already-profiled files)
./type-profiler /path/to/project --incremental

Output (--format text)

  • Per-symbol table — top 50 by instantiations: ms | inst | rss_delta | file:line symbol
  • Per-file RSS — peak/start/delta RAM per file
  • UNIQUE TYPES — deduped across files, flags known explosion constructors (DeepKey<, SyncPair<, etc.) with [!]
  • DIRECTORY ROLLUP — aggregated instantiations + peak RSS per directory
  • DIFF — regression/improvement markers vs --compare baseline

OOM detection

  • <<V8_OOM>> — Node.js heap exhausted (catches Last few GCs crashes)
  • <<RSS_OOM>> — process RSS exceeded --max-rss-mb (default 2048 MB)
  • <<TIMEOUT>> — symbol took longer than --timeout-ms (default 15 000 ms)

Requirements

  • Rust (stable)
  • Node.js ≥ 18
  • The target project must have TypeScript installed in its own node_modules
[package]
name = "type-profiler"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "type-profiler"
path = "src/main.rs"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
indicatif = "0.17"
rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sysinfo = "0.30"
tokio = { version = "1", features = ["full"] }
ignore = "0.4"
tempfile = "3"
[dev-dependencies]
use anyhow::{bail, Result};
use clap::Parser;
use indicatif::{ProgressBar, ProgressStyle};
use rusqlite::{params, Connection};
use serde::Deserialize;
use std::{
io::Write,
path::{Path, PathBuf},
sync::Arc,
};
use sysinfo::{Pid, System};
use tokio::{
io::{AsyncBufReadExt, BufReader},
process::Command,
sync::{Notify, Semaphore},
time::{timeout, Duration},
};
const WORKER_SRC: &str = include_str!("../worker.cjs");
fn default_output_path(root: &Path) -> PathBuf {
let sanitized = root
.to_string_lossy()
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'_'
}
})
.collect::<String>();
std::env::temp_dir().join(format!("type-profiler-{sanitized}.db"))
}
// Walk up from a file's directory to find the nearest tsconfig.json, bounded by root.
fn find_nearest_tsconfig(file: &Path, root: &Path, fallback: &Path) -> PathBuf {
let mut dir = file.parent().unwrap_or(file);
loop {
let candidate = dir.join("tsconfig.json");
if candidate.exists() {
return candidate;
}
if dir == root || dir.parent().is_none_or(|p| p == dir) {
break;
}
dir = dir.parent().unwrap();
}
fallback.to_path_buf()
}
#[derive(Parser)]
#[command(
name = "type-profiler",
about = "Profile TypeScript type resolution — per file, per symbol"
)]
struct Cli {
/// Directory containing tsconfig.json
#[arg(default_value = ".")]
root: PathBuf,
/// SQLite output file [default: /tmp/type-profiler-<sanitized-root>.db]
#[arg(long)]
output: Option<PathBuf>,
/// Parallel Node.js workers (default: logical CPUs)
#[arg(long)]
workers: Option<usize>,
/// Kill worker if no new symbol output for this many ms (per-symbol timeout)
#[arg(long, default_value_t = 15_000)]
timeout_ms: u64,
/// Kill worker if its RSS exceeds this (MB)
#[arg(long, default_value_t = 2048)]
max_rss_mb: u64,
/// Skip files already recorded in the DB (re-run only changed/new files)
#[arg(long)]
incremental: bool,
/// Directory names to exclude (can be repeated). node_modules and .claude are always excluded.
#[arg(long, default_values = &["dist", "out", "build", ".git"])]
exclude: Vec<String>,
/// Output format: sqlite (default), text (stdout), json (NDJSON to stdout)
#[arg(long, default_value = "sqlite")]
format: String,
/// Re-run only the N files with highest MAX(instantiations) from the existing DB
#[arg(long)]
rerun_worst: Option<u32>,
/// Skip files where the previous MAX(instantiations) < N (unknown files always included)
#[arg(long)]
threshold: Option<u64>,
/// Path to a previous results.db to diff against
#[arg(long)]
compare: Option<PathBuf>,
}
#[derive(Deserialize)]
struct WorkerRecord {
#[serde(default)]
line: u32,
#[serde(default)]
col: u32,
#[serde(default)]
symbol: String,
#[serde(default)]
hover: String,
#[serde(default)]
ms: f64,
#[serde(default, rename = "heapDeltaKb")]
heap_delta_kb: i64,
#[serde(default)]
instantiations: u64,
#[serde(default)]
done: bool,
#[serde(default, rename = "startRssKb")]
start_rss_kb: u64,
#[serde(default, rename = "peakRssKb")]
peak_rss_kb: u64,
}
struct DbRecord {
file: String,
line: u32,
col: u32,
symbol: String,
hover: String,
ms: f64,
heap_delta_kb: i64,
instantiations: u64,
timed_out: bool,
oom: bool,
}
struct FileStats {
file: String,
peak_rss_kb: u64,
start_rss_kb: u64,
duration_ms: f64,
symbol_count: u32,
}
async fn process_file(
file: PathBuf,
worker_js: PathBuf,
tsconfig: PathBuf,
timeout_ms: u64,
max_rss_bytes: u64,
tx: std::sync::mpsc::Sender<DbRecord>,
) -> FileStats {
let t_start = std::time::Instant::now();
let file_str = file.to_string_lossy().into_owned();
let project_root = tsconfig.parent().unwrap_or(&tsconfig);
// --max-old-space-size caps V8 heap so it OOMs fast instead of swapping
let heap_mb = (max_rss_bytes / 1_000_000).to_string();
let mut child = match Command::new("node")
.arg(format!("--max-old-space-size={heap_mb}"))
.arg(&worker_js)
.arg(&file)
.arg(&tsconfig)
.current_dir(project_root)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(c) => c,
Err(e) => {
eprintln!("spawn failed for {file_str}: {e}");
return FileStats {
file: file_str,
peak_rss_kb: 0,
start_rss_kb: 0,
duration_ms: 0.0,
symbol_count: 0,
};
}
};
let pid = child.id();
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
// RSS monitor — separate task polls every 500ms
let oom_notify = Arc::new(Notify::new());
if let Some(pid_u32) = pid {
let notify = Arc::clone(&oom_notify);
tokio::spawn(async move {
let mut sys = System::new();
let pid = Pid::from_u32(pid_u32);
loop {
tokio::time::sleep(Duration::from_millis(500)).await;
sys.refresh_process(pid);
match sys.process(pid) {
Some(p) if p.memory() > max_rss_bytes => {
notify.notify_one();
return;
}
None => return,
_ => {}
}
}
});
}
// Pin the OOM future so it survives across select! iterations
let oom_fut = oom_notify.notified();
tokio::pin!(oom_fut);
let mut timed_out = false;
let mut rss_oom = false;
let mut done_received = false;
let mut start_rss_kb = 0u64;
let mut peak_rss_kb = 0u64;
let mut symbol_count = 0u32;
loop {
tokio::select! {
result = timeout(Duration::from_millis(timeout_ms), lines.next_line()) => {
match result {
Ok(Ok(Some(line))) => {
if let Ok(rec) = serde_json::from_str::<WorkerRecord>(&line) {
if rec.done {
done_received = true;
start_rss_kb = rec.start_rss_kb;
peak_rss_kb = rec.peak_rss_kb;
break;
}
symbol_count += 1;
let _ = tx.send(DbRecord {
file: file_str.clone(),
line: rec.line,
col: rec.col,
symbol: rec.symbol,
hover: rec.hover,
ms: rec.ms,
heap_delta_kb: rec.heap_delta_kb,
instantiations: rec.instantiations,
timed_out: false,
oom: false,
});
}
}
Ok(Ok(None)) => break,
Ok(Err(_)) => break,
Err(_) => { timed_out = true; break; }
}
}
_ = &mut oom_fut => { rss_oom = true; break; }
}
}
let _ = child.kill().await;
let _ = child.wait().await;
let duration_ms = t_start.elapsed().as_secs_f64() * 1000.0;
let v8_oom = !done_received && !timed_out && !rss_oom;
if timed_out || rss_oom || v8_oom {
let label = if timed_out {
"<<TIMEOUT>>"
} else if rss_oom {
"<<RSS_OOM>>"
} else {
"<<V8_OOM>>"
};
let _ = tx.send(DbRecord {
file: file_str.clone(),
line: 0,
col: 0,
symbol: label.into(),
hover: String::new(),
ms: -1.0,
heap_delta_kb: 0,
instantiations: 0,
timed_out,
oom: rss_oom || v8_oom,
});
}
FileStats {
file: file_str,
peak_rss_kb,
start_rss_kb,
duration_ms,
symbol_count,
}
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let workers = cli.workers.unwrap_or_else(|| {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
});
let timeout_ms = cli.timeout_ms;
let max_rss_bytes = cli.max_rss_mb * 1_000_000;
let root = cli.root.canonicalize()?;
let tsconfig = root.join("tsconfig.json");
if !tsconfig.exists() {
bail!("tsconfig.json not found in {:?}", root);
}
let format = cli.format.clone();
let output = cli
.output
.clone()
.unwrap_or_else(|| default_output_path(&root));
// Write embedded worker to a temp file; keep handle alive for the duration of the run.
let mut _worker_temp = tempfile::Builder::new()
.prefix("type-profiler-worker-")
.suffix(".cjs")
.tempfile()?;
_worker_temp.write_all(WORKER_SRC.as_bytes())?;
let worker_js = _worker_temp.path().to_path_buf();
if cli.incremental && cli.rerun_worst.is_some() {
bail!("--incremental and --rerun-worst are mutually exclusive");
}
// Snapshot previous-run instantiation counts before any table setup (which may drop them).
let prev_max_inst: std::collections::HashMap<String, u64> =
if cli.threshold.is_some() && output.exists() {
let conn = Connection::open(&output)?;
let mut stmt = conn
.prepare("SELECT file, MAX(instantiations) FROM results GROUP BY file")
.unwrap_or_else(|_| conn.prepare("SELECT '' as file, 0 as m WHERE 0").unwrap());
stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))
.unwrap_or_else(|_| unreachable!())
.filter_map(|r| r.ok())
.map(|(f, inst)| (f, inst as u64))
.collect()
} else {
std::collections::HashMap::new()
};
// Always collect into SQLite, format controls what's printed at the end
let setup_conn = Connection::open(&output)?;
if cli.incremental || cli.rerun_worst.is_some() {
setup_conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS results (
file TEXT NOT NULL, line INTEGER NOT NULL, col INTEGER NOT NULL,
symbol TEXT, hover TEXT, ms REAL,
heap_delta_kb INTEGER DEFAULT 0, instantiations INTEGER DEFAULT 0,
timed_out INTEGER DEFAULT 0, oom INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_ms ON results (ms DESC);
CREATE INDEX IF NOT EXISTS idx_file ON results (file);
CREATE INDEX IF NOT EXISTS idx_inst ON results (instantiations DESC);
",
)?;
} else {
// Fresh run — drop stale data so results are never duplicated
setup_conn.execute_batch(
"
DROP TABLE IF EXISTS results;
DROP TABLE IF EXISTS file_stats;
CREATE TABLE results (
file TEXT NOT NULL, line INTEGER NOT NULL, col INTEGER NOT NULL,
symbol TEXT, hover TEXT, ms REAL,
heap_delta_kb INTEGER DEFAULT 0, instantiations INTEGER DEFAULT 0,
timed_out INTEGER DEFAULT 0, oom INTEGER DEFAULT 0
);
CREATE INDEX idx_ms ON results (ms DESC);
CREATE INDEX idx_file ON results (file);
CREATE INDEX idx_inst ON results (instantiations DESC);
",
)?;
}
drop(setup_conn);
// Always-excluded directory names
let mut excluded: std::collections::HashSet<String> = ["node_modules", ".claude", ".git"]
.iter()
.map(|s| s.to_string())
.collect();
excluded.extend(cli.exclude.into_iter());
// Discover .ts/.tsx files
let files: Vec<PathBuf> = if let Some(n) = cli.rerun_worst {
if !output.exists() {
bail!("--rerun-worst requires an existing DB at {:?}", output);
}
let conn = Connection::open(&output)?;
let mut stmt = conn.prepare(
"SELECT DISTINCT file FROM results \
ORDER BY (SELECT MAX(instantiations) FROM results r2 WHERE r2.file = results.file) DESC \
LIMIT ?1"
)?;
let selected: Vec<PathBuf> = stmt
.query_map([n], |r| r.get::<_, String>(0))?
.filter_map(|r| r.ok())
.map(PathBuf::from)
.filter(|p| p.exists())
.collect();
eprintln!("--rerun-worst: selected {} files", selected.len());
for f in &selected {
let f_str = f.to_string_lossy();
conn.execute("DELETE FROM results WHERE file = ?1", params![f_str])?;
conn.execute("DELETE FROM file_stats WHERE file = ?1", params![f_str])?;
}
selected
} else {
let mut fs: Vec<PathBuf> = ignore::WalkBuilder::new(&root)
.standard_filters(true)
.require_git(false) // respect .gitignore even outside git repos (monorepos, temp dirs)
.hidden(false)
.build()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_some_and(|t| t.is_file()))
.filter(|e| {
let p = e.path();
let ext = p.extension().and_then(|s| s.to_str());
matches!(ext, Some("ts") | Some("tsx"))
&& !p
.components()
.any(|c| excluded.contains(c.as_os_str().to_string_lossy().as_ref()))
})
.map(|e| e.path().to_path_buf())
.collect();
if cli.incremental {
let conn = Connection::open(&output)?;
let mut stmt = conn.prepare("SELECT DISTINCT file FROM results")?;
let done: std::collections::HashSet<String> = stmt
.query_map([], |r| r.get(0))?
.filter_map(|r| r.ok())
.collect();
fs.retain(|f| !done.contains(f.to_string_lossy().as_ref()));
}
if let Some(threshold) = cli.threshold {
let before = fs.len();
fs.retain(|f| {
let key = f.to_string_lossy();
match prev_max_inst.get(key.as_ref()) {
Some(&max_inst) => max_inst >= threshold,
None => true,
}
});
let skipped = before - fs.len();
if skipped > 0 {
eprintln!("--threshold {threshold}: skipped {skipped} files below threshold");
}
}
fs
};
let total = files.len();
if total == 0 {
eprintln!("No files to process.");
return Ok(());
}
eprintln!(
"Files: {total} Workers: {workers} Timeout: {timeout_ms}ms Max RSS: {}MB",
cli.max_rss_mb
);
// Single blocking writer — rusqlite Connection is !Send
let (tx, rx) = std::sync::mpsc::channel::<DbRecord>();
let db_path = output.clone();
let writer = tokio::task::spawn_blocking(move || {
let conn = Connection::open(&db_path).unwrap();
conn.execute_batch("PRAGMA journal_mode=WAL;").unwrap();
while let Ok(rec) = rx.recv() {
conn.execute(
"INSERT INTO results \
(file,line,col,symbol,hover,ms,heap_delta_kb,instantiations,timed_out,oom) \
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
params![
rec.file,
rec.line,
rec.col,
rec.symbol,
rec.hover,
rec.ms,
rec.heap_delta_kb,
rec.instantiations as i64,
rec.timed_out as i32,
rec.oom as i32
],
)
.ok();
}
});
let pb = ProgressBar::new(total as u64);
pb.set_style(
ProgressStyle::default_bar()
.template("[{elapsed_precise}] {bar:50} {pos}/{len} ETA {eta} {msg}")
.unwrap()
.progress_chars("=> "),
);
let sem = Arc::new(Semaphore::new(workers));
let mut handles = Vec::with_capacity(total);
for file in files {
let permit = Arc::clone(&sem).acquire_owned().await?;
let tx = tx.clone();
let wjs = worker_js.clone();
let tc = find_nearest_tsconfig(&file, &root, &tsconfig);
let pb = pb.clone();
let fname = file
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
handles.push(tokio::spawn(async move {
pb.set_message(fname);
let stats = process_file(file, wjs, tc, timeout_ms, max_rss_bytes, tx).await;
pb.inc(1);
drop(permit);
stats
}));
}
let mut file_stats: Vec<FileStats> = Vec::with_capacity(total);
for h in handles {
if let Ok(stats) = h.await {
file_stats.push(stats);
}
}
drop(tx);
pb.finish_with_message("done");
writer.await?;
// Write file-level stats
{
let stats_conn = Connection::open(&output)?;
stats_conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS file_stats (
file TEXT PRIMARY KEY,
peak_rss_kb INTEGER DEFAULT 0,
start_rss_kb INTEGER DEFAULT 0,
duration_ms REAL,
symbol_count INTEGER DEFAULT 0
);
",
)?;
for s in &file_stats {
stats_conn.execute(
"INSERT OR REPLACE INTO file_stats (file,peak_rss_kb,start_rss_kb,duration_ms,symbol_count) \
VALUES (?1,?2,?3,?4,?5)",
rusqlite::params![s.file, s.peak_rss_kb as i64, s.start_rss_kb as i64, s.duration_ms, s.symbol_count],
)?;
}
}
// Diff against a previous DB if --compare was supplied
if let Some(compare_path) = &cli.compare {
let compare_abs = compare_path
.canonicalize()
.unwrap_or_else(|_| compare_path.clone());
let diff_conn = Connection::open(&output)?;
diff_conn.execute_batch(&format!(
"ATTACH DATABASE '{}' AS prev;",
compare_abs.to_string_lossy().replace('\'', "''")
))?;
let prev_name = compare_abs
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("previous.db");
println!("\nDIFF vs {prev_name}");
println!("{}", "\u{2500}".repeat(80));
println!(
"{:>13} {:>13} {:>10} sym (file:line)",
"inst_before", "inst_after", "delta"
);
let mut sym_stmt = diff_conn.prepare(
"SELECT
r.file,
r.line,
substr(r.symbol, 1, 80) as sym,
prev_r.instantiations as inst_before,
r.instantiations as inst_after,
CAST(r.instantiations AS INTEGER) - CAST(prev_r.instantiations AS INTEGER) as inst_delta,
prev_r.ms as ms_before,
r.ms as ms_after,
CAST(r.ms AS REAL) - CAST(prev_r.ms AS REAL) as ms_delta
FROM results r
JOIN prev.results prev_r
ON r.file = prev_r.file
AND substr(r.symbol,1,80) = substr(prev_r.symbol,1,80)
WHERE r.instantiations != prev_r.instantiations
OR r.ms != prev_r.ms
ORDER BY ABS(inst_delta) DESC
LIMIT 40"
)?;
let sym_rows = sym_stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, String>(2)?,
r.get::<_, i64>(3)?,
r.get::<_, i64>(4)?,
r.get::<_, i64>(5)?,
))
})?;
for row in sym_rows.filter_map(|r| r.ok()) {
let (file, line, sym, inst_before, inst_after, inst_delta) = row;
let sym_first = sym
.lines()
.next()
.unwrap_or("")
.chars()
.take(80)
.collect::<String>();
let fname = std::path::Path::new(&file)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(&file);
let marker = if inst_delta < 0 {
" \u{2713} improved"
} else if inst_delta > 0 {
" \u{26a0} regression"
} else {
""
};
println!(
"{:>13} {:>13} {:>+10} {} ({}:{}){}",
inst_before, inst_after, inst_delta, sym_first, fname, line, marker
);
}
println!(
"\n{:>12} {:>12} {:>10} file",
"rss_before", "rss_after", "delta"
);
println!("{}", "\u{2500}".repeat(80));
let mut rss_stmt = diff_conn.prepare(
"SELECT
fs.file,
prev_fs.peak_rss_kb as rss_before,
fs.peak_rss_kb as rss_after,
CAST(fs.peak_rss_kb AS INTEGER) - CAST(prev_fs.peak_rss_kb AS INTEGER) as rss_delta
FROM file_stats fs
JOIN prev.file_stats prev_fs ON fs.file = prev_fs.file
WHERE ABS(fs.peak_rss_kb - prev_fs.peak_rss_kb) > 1024
ORDER BY ABS(rss_delta) DESC
LIMIT 20",
)?;
let rss_rows = rss_stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, i64>(3)?,
))
})?;
let rss_rows: Vec<_> = rss_rows.filter_map(|r| r.ok()).collect();
if rss_rows.is_empty() {
println!(" (no file-level RSS changes > 1 MB)");
} else {
for (file, rss_before, rss_after, rss_delta) in &rss_rows {
let fname = std::path::Path::new(file)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(file);
println!(
"{:>10}MB {:>10}MB {:>+8}MB {}",
rss_before / 1024,
rss_after / 1024,
rss_delta / 1024,
fname
);
}
}
}
// Read results back from SQLite and format
let out_conn = Connection::open(&output)?;
match format.as_str() {
"sqlite" => {
eprintln!(
"\nSaved → {}\n\nBy instantiations (explosions):\n sqlite3 {} 'SELECT file,line,substr(symbol,1,60),instantiations,ms FROM results ORDER BY instantiations DESC LIMIT 30'\n\nBy time:\n sqlite3 {} 'SELECT file,line,substr(symbol,1,60),ms FROM results WHERE ms > 0 ORDER BY ms DESC LIMIT 30'",
output.display(), output.display(), output.display()
);
}
"json" => {
let mut stmt = out_conn.prepare(
"SELECT file,line,col,symbol,hover,ms,heap_delta_kb,instantiations,timed_out,oom \
FROM results ORDER BY instantiations DESC",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, u32>(1)?,
r.get::<_, u32>(2)?,
r.get::<_, String>(3)?,
r.get::<_, String>(4)?,
r.get::<_, f64>(5)?,
r.get::<_, i64>(6)?,
r.get::<_, i64>(7)?,
r.get::<_, i32>(8)?,
r.get::<_, i32>(9)?,
))
})?;
for row in rows.filter_map(|r| r.ok()) {
println!(
"{}",
serde_json::json!({
"file": row.0, "line": row.1, "col": row.2,
"symbol": row.3, "hover": row.4,
"ms": row.5, "rssDeltaKb": row.6, "instantiations": row.7,
"timedOut": row.8 != 0, "oom": row.9 != 0,
})
);
}
// File stats as a second stream
let mut fstmt = out_conn.prepare(
"SELECT file,peak_rss_kb,start_rss_kb,duration_ms,symbol_count FROM file_stats",
)?;
let frows = fstmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, f64>(3)?,
r.get::<_, i64>(4)?,
))
})?;
for row in frows.filter_map(|r| r.ok()) {
println!(
"{}",
serde_json::json!({
"type": "file_stats",
"file": row.0, "peakRssKb": row.1, "startRssKb": row.2,
"durationMs": row.3, "symbolCount": row.4,
})
);
}
}
"csv" => {
println!("file,line,col,ms,instantiations,heap_delta_kb,timed_out,oom,symbol");
let mut stmt = out_conn.prepare(
"SELECT file,line,col,ms,instantiations,heap_delta_kb,timed_out,oom,symbol \
FROM results ORDER BY instantiations DESC",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, u32>(1)?,
r.get::<_, u32>(2)?,
r.get::<_, f64>(3)?,
r.get::<_, i64>(4)?,
r.get::<_, i64>(5)?,
r.get::<_, i32>(6)?,
r.get::<_, i32>(7)?,
r.get::<_, String>(8)?,
))
})?;
for row in rows.filter_map(|r| r.ok()) {
let sym = row.8.lines().next().unwrap_or("").replace(',', ";");
println!(
"{},{},{},{},{},{},{},{},{}",
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, sym
);
}
}
_ => {
// Per-type table: top 50 by instantiations
let mut stmt = out_conn.prepare(
"SELECT file,line,ms,instantiations,heap_delta_kb,symbol \
FROM results WHERE ms > 0 ORDER BY instantiations DESC LIMIT 50",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, u32>(1)?,
r.get::<_, f64>(2)?,
r.get::<_, i64>(3)?,
r.get::<_, i64>(4)?,
r.get::<_, String>(5)?,
))
})?;
println!(
"{:>9} {:>8} {:>8} file:line symbol",
"ms", "inst", "rss_kb"
);
println!("{}", "-".repeat(80));
for row in rows.filter_map(|r| r.ok()) {
let sym = row
.5
.lines()
.next()
.unwrap_or("")
.chars()
.take(60)
.collect::<String>();
println!(
"{:>9.1} {:>8} {:>+7}kb {}:{} {}",
row.2, row.3, row.4, row.0, row.1, sym
);
}
// Per-file summary: peak RSS and duration
let mut fstmt = out_conn.prepare(
"SELECT file, peak_rss_kb, start_rss_kb, duration_ms, symbol_count \
FROM file_stats WHERE peak_rss_kb > 0 ORDER BY peak_rss_kb DESC LIMIT 30",
)?;
let frows = fstmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, f64>(3)?,
r.get::<_, i64>(4)?,
))
});
if let Ok(frows) = frows {
let frows: Vec<_> = frows.filter_map(|r| r.ok()).collect();
if !frows.is_empty() {
println!(
"\n{:>10} {:>9} {:>9} {:>6} file",
"peak_rss", "start_rss", "delta_rss", "syms"
);
println!("{}", "-".repeat(80));
for row in &frows {
let fname = std::path::Path::new(&row.0)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(&row.0);
let delta = row.1 - row.2;
println!(
"{:>8}MB {:>7}MB {:>+8}MB {:>6} {}",
row.1 / 1024,
row.2 / 1024,
delta / 1024,
row.4,
fname
);
}
}
}
// Unique types aggregated across all files
let flagged_substrings = [
"DeepKey<",
"SyncPair<",
"PathsWithSameValueAs<",
"DeepKeyFiltered<",
"ArrayOfChanges<",
"ComputationPair<",
"AggregationPair<",
"FlipPair<",
];
let mut ustmt = out_conn.prepare(
"SELECT \
substr(symbol, 1, 80) AS type_expr, \
COUNT(DISTINCT file) AS file_count, \
SUM(instantiations) AS total_inst, \
ROUND(SUM(ms), 1) AS total_ms, \
MAX(heap_delta_kb) AS max_rss_kb, \
MIN(file) || ':' || MIN(line) AS example_loc \
FROM results \
WHERE ms > 0 AND instantiations > 100 \
GROUP BY substr(symbol, 1, 80) \
HAVING COUNT(DISTINCT file) > 0 \
ORDER BY SUM(instantiations) DESC \
LIMIT 30",
)?;
let urows: Vec<(String, i64, i64, f64, i64, String)> = ustmt
.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, f64>(3)?,
r.get::<_, i64>(4)?,
r.get::<_, String>(5)?,
))
})?
.filter_map(|r| r.ok())
.collect();
if !urows.is_empty() {
println!("\nUNIQUE TYPES (by total instantiations across all files)");
println!("{}", "\u{2500}".repeat(80));
println!(
"{:>12} {:>5} {:>10} flag type_expr",
"total_inst", "files", "total_ms"
);
for (type_expr, file_count, total_inst, total_ms, _max_rss_kb, _example_loc) in
&urows
{
let flag = if flagged_substrings.iter().any(|s| type_expr.contains(s)) {
"[!]"
} else {
" "
};
let sym = type_expr
.lines()
.next()
.unwrap_or("")
.chars()
.take(60)
.collect::<String>();
println!(
"{:>12} {:>5} {:>8.0}ms {} {}",
total_inst, file_count, total_ms, flag, sym
);
}
}
// Directory rollup
let mut dstmt = out_conn.prepare(
"SELECT \
rtrim(file, replace(file, '/', '')) AS dir, \
COUNT(DISTINCT file) AS file_count, \
SUM(instantiations) AS total_inst, \
ROUND(SUM(ms), 1) AS total_ms, \
MAX(fs.peak_rss_kb) AS peak_rss_kb \
FROM results \
LEFT JOIN file_stats fs USING (file) \
GROUP BY dir \
ORDER BY SUM(instantiations) DESC \
LIMIT 20",
)?;
let drows: Vec<(String, i64, i64, f64, i64)> = dstmt
.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, f64>(3)?,
r.get::<_, i64>(4)?,
))
})?
.filter_map(|r| r.ok())
.collect();
if !drows.is_empty() {
let common_prefix: String = {
let first = &drows[0].0;
let prefix_len = first
.char_indices()
.find(|(i, c)| drows.iter().any(|(d, ..)| d.chars().nth(*i) != Some(*c)))
.map(|(i, _)| i)
.unwrap_or(first.len());
let prefix = &first[..prefix_len];
match prefix.rfind('/') {
Some(p) => prefix[..=p].to_string(),
None => String::new(),
}
};
println!("\nDIRECTORY ROLLUP (by total instantiations)");
println!("{}", "\u{2500}".repeat(80));
println!(
"{:>12} {:>5} {:>10} {:>9} directory",
"total_inst", "files", "total_ms", "peak_rss"
);
for (dir, file_count, total_inst, total_ms, peak_rss_kb) in &drows {
let short_dir = dir
.strip_prefix(&common_prefix)
.map(|s| format!(".../{s}"))
.unwrap_or_else(|| dir.clone());
let peak_mb = peak_rss_kb / 1024;
println!(
"{:>12} {:>5} {:>8.0}ms {:>7}MB {}",
total_inst, file_count, total_ms, peak_mb, short_dir
);
}
}
}
}
Ok(())
}
#!/usr/bin/env bash
set -e
mkdir -p src
mv main.rs src/main.rs
cargo build --release
echo "Built: $(pwd)/target/release/type-profiler"
// Per-file TypeScript type profiler worker.
// Spawned by the Rust orchestrator for each .ts file.
// Streams NDJSON to stdout: one line per resolved type.
'use strict';
const { performance } = require('perf_hooks');
const path = require('path');
const { createRequire } = require('module');
const [,, filePath, tsconfigPath] = process.argv;
if (!filePath) { process.stderr.write('Usage: node worker.js <file> <tsconfig>\n'); process.exit(1); }
const startRssKb = Math.round(process.memoryUsage().rss / 1024);
let peakRssKb = startRssKb;
const absFile = path.resolve(filePath);
// Resolve typescript from the target project so we get its version, not ours
const requireFromProject = createRequire(path.resolve(tsconfigPath));
const ts = requireFromProject('typescript');
const { config } = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(tsconfigPath));
// Override options for speed and isolation
const opts = {
...parsed.options,
noEmit: true,
skipLibCheck: true,
// Don't report errors — we only care about type resolution time
noUnusedLocals: false,
noUnusedParameters: false,
};
const program = ts.createProgram([absFile], opts);
const checker = program.getTypeChecker();
const src = program.getSourceFile(absFile);
if (!src) { process.stderr.write(`Could not load: ${absFile}\n`); process.exit(1); }
const FLAGS = ts.TypeFormatFlags.NoTruncation |
ts.TypeFormatFlags.WriteArrayAsGenericType |
ts.TypeFormatFlags.UseFullyQualifiedType;
// TypeScript exposes getInstantiationCount() on the checker in some versions,
// or instantiationCount as a direct property in others. Fall back to 0 if neither.
const getInstantiations = (() => {
const raw = checker;
if (typeof raw.getInstantiationCount === 'function') return () => raw.getInstantiationCount();
if (typeof raw.instantiationCount === 'number') return () => raw.instantiationCount;
return () => 0;
})();
function emit(line, col, symbol, hover, ms, heapDeltaKb, instantiations) {
process.stdout.write(JSON.stringify({ line, col, symbol, hover, ms, heapDeltaKb, instantiations }) + '\n');
}
function nodeText(node) {
return src.text.slice(node.getStart(src), node.getEnd()).slice(0, 120);
}
function timeNode(node) {
const { line, character } = src.getLineAndCharacterOfPosition(node.getStart(src));
const instBefore = getInstantiations();
const rssBefore = process.memoryUsage().rss;
const t0 = performance.now();
try {
const type = checker.getTypeAtLocation(node);
const hover = checker.typeToString(type, undefined, FLAGS).slice(0, 1000);
const ms = +(performance.now() - t0).toFixed(3);
const rssNow = process.memoryUsage().rss;
const rssDeltaKb = Math.round((rssNow - rssBefore) / 1024);
const rssKb = Math.round(rssNow / 1024);
if (rssKb > peakRssKb) peakRssKb = rssKb;
const instantiations = getInstantiations() - instBefore;
emit(line + 1, character + 1, nodeText(node), hover, ms, rssDeltaKb, instantiations);
} catch (_) {
// skip unresolvable nodes
}
}
function visit(node) {
if (ts.isTypeAliasDeclaration(node)) {
// Time the full alias — this is where expensive types live
timeNode(node);
// Don't recurse into node.type separately to avoid double-counting
ts.forEachChild(node, visitNonType);
return;
}
if (ts.isInterfaceDeclaration(node)) {
timeNode(node);
} else if (ts.isTypeReferenceNode(node)) {
// A reference like `Foo<T>` — see how long it takes to resolve
timeNode(node);
} else {
// Nodes with explicit type annotations: variables, params, properties, return types
const typed = /** @type {any} */ (node);
if (typed.type && ts.isTypeNode(typed.type)) {
timeNode(typed.type);
}
}
ts.forEachChild(node, visit);
}
// Walk inside a type alias body without timing top-level constructs again
function visitNonType(node) {
if (ts.isTypeReferenceNode(node)) {
timeNode(node);
}
ts.forEachChild(node, visitNonType);
}
visit(src);
process.stdout.write(JSON.stringify({ done: true, startRssKb, peakRssKb }) + '\n');
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment