Skip to content

Instantly share code, notes, and snippets.

@twall
Created May 22, 2026 22:54
Show Gist options
  • Select an option

  • Save twall/d83e28a430b2b60acc529a4905dbe33a to your computer and use it in GitHub Desktop.

Select an option

Save twall/d83e28a430b2b60acc529a4905dbe33a to your computer and use it in GitHub Desktop.
Python to generate html of a makefile dependency DAG, including stale dependencies
#!/usr/bin/env python3
"""
Makefile dependency graph visualizer.
Parses make output to build a dependency graph, detects stale targets,
and generates an interactive HTML visualization.
"""
import argparse
import json
import os
import re
import subprocess
import sys
import tempfile
import webbrowser
from pathlib import Path
def parse_make_database(output):
"""
Parse `make -pnrR` output to extract targets, dependencies, and phony declarations.
Returns:
nodes: dict {target_name: {"phony": bool}}
edges: list of (target, dep) tuples
"""
nodes = {}
edges = []
phony_targets = set()
# Make-internal targets to skip
internal_targets = {
'.PHONY', '.SUFFIXES', '.DEFAULT', '.PRECIOUS', '.INTERMEDIATE',
'.SECONDARY', '.DELETE_ON_ERROR', '.IGNORE', '.SILENT',
'.EXPORT_ALL_VARIABLES', '.NOTPARALLEL', '.ONESHELL', '.POSIX',
'.FEATURES', '.INCLUDE_DIRS', '.VARIABLES', '.DEFAULT_GOAL',
'.LOADED', '.RECIPEPREFIX', '.SHELLFLAGS', '.SHELLSTATUS',
}
def is_junk_target(name):
"""Filter targets that are clearly not real build targets."""
if name in internal_targets:
return True
# Dot-prefixed Make internals
if name.startswith('.') and name.upper() == name:
return True
# Pattern rules (contain %)
if '%' in name:
return True
# Variable assignments leaked as targets (contain =)
if '=' in name:
return True
# Inline shell commands leaked as targets (contain pipes, awk, cat, etc.)
if '|' in name or '(' in name or '$' in name:
return True
if any(cmd in name for cmd in ['awk ', 'cat ', 'sed ', 'grep ', 'cut ', 'sort ', 'echo ']):
return True
# Quoted strings or escape sequences
if '"' in name or "'" in name or '\\' in name:
return True
# Too long to be a real target name (likely a command)
if len(name) > 80:
return True
return False
for line in output.splitlines():
# Skip empty lines, comments, indented lines (recipes)
if not line or line.startswith(('#', '\t', ' ')):
continue
# Skip variable assignments (upper or lowercase)
if re.match(r'^[\w]+\s*[:+?]?=', line):
continue
# Capture .PHONY declarations
phony_match = re.match(r'^\.PHONY:\s+(.+)', line)
if phony_match:
phony_targets.update(phony_match.group(1).split())
continue
# Match target: deps lines
target_match = re.match(r'^([^:]+?)\s*:\s*(.*)', line)
if target_match:
target = target_match.group(1).strip()
deps_raw = target_match.group(2).strip()
if is_junk_target(target):
continue
# Register target node
if target not in nodes:
nodes[target] = {"phony": False}
# Parse dependencies (strip order-only deps after |)
if deps_raw:
deps_part = deps_raw.split('|')[0].strip()
if deps_part:
deps = deps_part.split()
for dep in deps:
dep = dep.strip()
if dep and not is_junk_target(dep):
if dep not in nodes:
nodes[dep] = {"phony": False}
edges.append((target, dep))
# Mark phony targets
for target in phony_targets:
if target in nodes:
nodes[target]["phony"] = True
return nodes, edges
def parse_make_stale(output):
"""
Parse `make -nd` output to detect stale targets.
Returns:
set of stale target names
"""
stale = set()
# Pattern: Must remake target `name'
# Pattern: File `name' does not exist
remake_pattern = re.compile(r"`([^']+)'")
for line in output.splitlines():
if "Must remake target" in line or "does not exist" in line:
match = remake_pattern.search(line)
if match:
stale.add(match.group(1))
return stale
def generate_graph_json(nodes, edges, stale_targets):
"""
Generate JSON representation of the graph.
Format:
{
"nodes": [{"id": "name", "stale": bool, "phony": bool}],
"edges": [{"source": "target", "target": "dep"}]
}
"""
node_list = [
{
"id": node_id,
"stale": node_id in stale_targets,
"phony": attrs["phony"]
}
for node_id, attrs in nodes.items()
]
edge_list = [
{"source": source, "target": target}
for source, target in edges
]
return {
"nodes": node_list,
"edges": edge_list
}
def parse_remote_path(directory):
"""Parse host:/path syntax. Returns (host, path) or (None, directory)."""
if directory and ':' in directory and not directory.startswith('/'):
parts = directory.split(':', 1)
if '/' in parts[1]:
return parts[0], parts[1]
return None, directory
def run_ssh_make(host, remote_dir, make_vars, extra_flags, timeout=120):
"""Run make on a remote host via SSH, return stdout+stderr or None."""
make_cmd = f"cd {remote_dir} && make {' '.join(extra_flags)} {' '.join(make_vars)}"
ssh_cmd = ['ssh', '-o', 'BatchMode=yes', host, make_cmd]
try:
result = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=timeout)
return result.stdout + result.stderr
except subprocess.TimeoutExpired:
print(f"Timed out running make on {host} (>{timeout}s)", file=sys.stderr)
return None
except FileNotFoundError as e:
print(f"Error running ssh: {e}", file=sys.stderr)
return None
def main():
parser = argparse.ArgumentParser(
description="Visualize Makefile dependency graph",
epilog="Remote usage: make-graph -C host:/path/to/dir [VARS...]"
)
parser.add_argument(
'-C', '--directory',
metavar='DIR',
help='Change to directory before running make (supports host:/path for SSH)'
)
parser.add_argument(
'-f', '--file',
metavar='FILE',
help='Read FILE as a makefile'
)
parser.add_argument(
'-t', '--timeout',
type=int,
default=120,
metavar='SECS',
help='Timeout for make commands in seconds (default: 120)'
)
parser.add_argument(
'variables',
nargs='*',
help='Make variable assignments (KEY=VALUE)'
)
args = parser.parse_args()
# Detect remote host:/path syntax
remote_host, local_dir = parse_remote_path(args.directory)
if remote_host:
print(f"Connecting to {remote_host}:{local_dir}...", file=sys.stderr)
# Phase 1: extract full graph via SSH
print("Extracting dependency graph...", file=sys.stderr)
make_db_output = run_ssh_make(remote_host, local_dir, args.variables, ['-pnrR'], args.timeout)
if not make_db_output:
print("Error: could not read Makefile database from remote", file=sys.stderr)
sys.exit(1)
nodes, edges = parse_make_database(make_db_output)
# Phase 2: detect staleness via SSH (non-fatal)
print("Detecting stale targets...", file=sys.stderr)
stale_output = run_ssh_make(remote_host, local_dir, args.variables, ['-nd'], args.timeout)
stale_targets = parse_make_stale(stale_output) if stale_output else set()
else:
# Local execution
make_cmd_base = ['make']
if local_dir:
make_cmd_base.extend(['-C', local_dir])
if args.file:
make_cmd_base.extend(['-f', args.file])
make_cmd_base.extend(args.variables)
# Run make -pnrR to get dependency database
print("Extracting dependency graph...", file=sys.stderr)
try:
result = subprocess.run(
make_cmd_base + ['-pnrR'],
capture_output=True,
text=True,
check=True
)
make_db_output = result.stdout
except subprocess.CalledProcessError as e:
print(f"Error running make -pnrR: {e}", file=sys.stderr)
print(e.stderr, file=sys.stderr)
sys.exit(1)
nodes, edges = parse_make_database(make_db_output)
# Run make -nd to detect stale targets (non-fatal)
print("Detecting stale targets...", file=sys.stderr)
stale_targets = set()
try:
result = subprocess.run(
make_cmd_base + ['-nd'],
capture_output=True,
text=True
)
stale_targets = parse_make_stale(result.stdout + result.stderr)
except Exception as e:
print(f"Warning: could not detect stale targets: {e}", file=sys.stderr)
# Generate JSON
graph_data = generate_graph_json(nodes, edges, stale_targets)
graph_json = json.dumps(graph_data, indent=2)
# Read HTML template
script_dir = Path(__file__).parent
template_path = script_dir / 'make-graph-viewer.html'
try:
with open(template_path, 'r') as f:
html_template = f.read()
except FileNotFoundError:
print(f"Error: template not found at {template_path}", file=sys.stderr)
sys.exit(1)
# Inject graph data
html_output = html_template.replace('__GRAPH_DATA__', graph_json)
# Write to temp file
output_path = Path('/tmp/make-graph.html')
with open(output_path, 'w') as f:
f.write(html_output)
print(f"Graph written to {output_path}", file=sys.stderr)
print(f"Nodes: {len(nodes)}, Edges: {len(edges)}, Stale: {len(stale_targets)}", file=sys.stderr)
# Open in browser
webbrowser.open(f'file://{output_path}')
if __name__ == '__main__':
main()
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Makefile Dependency Graph</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dagre-d3@0.6.4/dist/dagre-d3.min.js"></script>
<style>
body {
margin: 0; padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a2e; color: #d4d4d4; overflow-x: hidden; overflow-y: auto;
}
#graph-container { width: 100vw; height: 100vh; }
svg { width: 100%; height: 100%; }
.node rect {
fill: #2d2d44; stroke: #5a5a7a; stroke-width: 1.5px;
rx: 5; ry: 5; cursor: pointer;
}
.node.phony rect { stroke-dasharray: 5, 3; }
.node.stale rect { fill: #3d3d00; stroke: #e6e600; }
.node.blob rect { fill: #2d2d44; stroke: #5a5a7a; stroke-width: 2px; rx: 12; ry: 12; }
/* blob stale coloring applied dynamically via getStaleFraction */
.node.selected rect { stroke: #4fc3f7; stroke-width: 2.5px; fill: #1a3a5c; }
.node text { fill: #c8c8e0; font-size: 12px; pointer-events: none; }
.node.selected text { fill: #4fc3f7; font-weight: bold; }
.node.blob text { fill: #c8c8e0; font-size: 11px; }
.edgePath path { stroke: #4a4a6a; stroke-width: 1.2px; fill: none; }
.edgePath marker { fill: #4a4a6a; }
#controls {
position: fixed; top: 12px; left: 12px; z-index: 1000;
display: flex; gap: 8px; align-items: center;
}
#search-input {
width: 220px; padding: 8px 12px; background: #252540; border: 1px solid #4a4a6a;
border-radius: 4px; color: #d4d4d4; font-size: 14px; outline: none;
}
#search-input:focus { border-color: #4fc3f7; }
#search-input::placeholder { color: #6a6a8a; }
#back-btn {
padding: 6px 12px; background: #333355; border: 1px solid #4a4a6a;
border-radius: 4px; color: #aaa; cursor: pointer; font-size: 12px; display: none;
}
#back-btn:hover { background: #444466; color: #ddd; }
#info-bar {
position: fixed; bottom: 12px; left: 12px;
background: #252540; border: 1px solid #4a4a6a;
padding: 8px 14px; border-radius: 4px; font-size: 12px;
color: #8a8aaa; z-index: 1000;
}
/* Target lists (root and dep) */
#dep-list {
padding: 60px 40px 40px; max-width: 900px; margin: 0 auto;
display: none;
}
#root-list {
padding: 60px 40px 40px; max-width: 900px; margin: 0 auto;
display: none;
}
.root-header { margin-bottom: 16px; }
.root-header h2 { font-size: 18px; color: #e0e0e0; margin: 0 0 12px; }
#root-filter {
width: 100%; padding: 10px 14px; background: #252540; border: 1px solid #4a4a6a;
border-radius: 4px; color: #d4d4d4; font-size: 14px; outline: none;
}
#root-filter:focus { border-color: #4fc3f7; }
#root-filter::placeholder { color: #6a6a8a; }
#root-items { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.root-item {
padding: 6px 12px; background: #2a2a44; border: 1px solid #4a4a6a;
border-radius: 4px; cursor: pointer; font-size: 13px;
display: flex; gap: 8px; align-items: center; transition: all 0.15s;
}
.root-item:hover { border-color: #4fc3f7; background: #1a3a5c; }
.root-item.stale { border-color: #e6e600; }
.root-item.stale .root-item-name { color: #e6e600; }
.root-item-name { color: #d4d4d4; }
.root-item-size { color: #6a6a8a; font-size: 11px; }
.root-group { width: 100%; }
.root-group-header {
padding: 8px 14px; background: #1e1e30; border: 1px dashed #5a5a7a;
border-radius: 4px; cursor: pointer; display: flex; gap: 10px;
align-items: center; transition: all 0.15s;
}
.root-group-header:hover { border-color: #7ab0d4; background: #1a2a3a; }
/* group stale coloring applied dynamically */
.root-group-header::before { content: '▶'; font-size: 10px; color: #6a6a8a; transition: transform 0.2s; }
.root-group-header.expanded::before { content: '▼'; }
.root-group-name { color: #8a8aaa; font-weight: 600; font-size: 14px; font-style: italic; }
.graph-all-btn {
margin-left: auto; font-size: 16px; color: #6a6a8a; cursor: pointer;
padding: 0 4px; border-radius: 3px; transition: color 0.15s;
}
.graph-all-btn:hover { color: #4fc3f7; }
.root-group-body {
display: none; flex-wrap: wrap; gap: 5px;
padding: 8px 0 8px 20px; margin-top: 4px;
}
#breadcrumb {
display: flex; gap: 4px; align-items: center; font-size: 12px; color: #8a8aaa;
}
.crumb { cursor: pointer; color: #7ab0d4; }
.crumb:hover { text-decoration: underline; }
.crumb-sep { color: #4a4a6a; }
</style>
</head>
<body>
<div id="controls">
<input type="text" id="search-input" placeholder="Search targets..." autocomplete="off">
<button id="back-btn">&#8592; Back</button>
<div id="breadcrumb"></div>
</div>
<div id="info-bar"></div>
<div id="graph-container">
<svg id="graph-svg"><g id="graph-group"></g></svg>
</div>
<script>
const graphData = __GRAPH_DATA__;
const BLOB_THRESHOLD = 30;
// Adjacency
const upstreamAdj = {};
const downstreamAdj = {};
const nodeIndex = {};
graphData.nodes.forEach(n => { upstreamAdj[n.id] = []; downstreamAdj[n.id] = []; nodeIndex[n.id] = n; });
graphData.edges.forEach(e => {
if (upstreamAdj[e.source]) upstreamAdj[e.source].push(e.target);
if (downstreamAdj[e.target]) downstreamAdj[e.target].push(e.source);
});
const subtreeSizeCache = {};
function getSubtreeSize(nodeId) {
if (nodeId in subtreeSizeCache) return subtreeSizeCache[nodeId];
const visited = new Set();
const queue = [nodeId];
let count = 0;
while (queue.length > 0) {
const id = queue.shift();
if (visited.has(id)) continue;
visited.add(id);
count++;
if (count > MAX_VISIBLE) break;
for (const dep of (upstreamAdj[id] || [])) {
if (!visited.has(dep)) queue.push(dep);
}
}
subtreeSizeCache[nodeId] = count;
return count;
}
function getTransitiveDeps(nodeId) {
const visited = new Set();
const queue = [nodeId];
visited.add(nodeId);
while (queue.length) {
const cur = queue.shift();
for (const dep of (upstreamAdj[cur] || [])) {
if (!visited.has(dep)) { visited.add(dep); queue.push(dep); }
}
}
return visited;
}
const hasStaleCache = {};
function hasStaleInSubtree(nodeId) {
if (nodeId in hasStaleCache) return hasStaleCache[nodeId];
const visited = new Set();
const queue = [nodeId];
while (queue.length > 0) {
const id = queue.shift();
if (visited.has(id)) continue;
visited.add(id);
if (nodeIndex[id] && nodeIndex[id].stale) { hasStaleCache[nodeId] = true; return true; }
for (const dep of (upstreamAdj[id] || [])) {
if (!visited.has(dep)) queue.push(dep);
}
}
hasStaleCache[nodeId] = false;
return false;
}
const staleFractionCache = {};
function getStaleFraction(nodeId) {
if (nodeId in staleFractionCache) return staleFractionCache[nodeId];
// Sample up to 200 nodes for large subtrees to avoid freezing
const MAX_SAMPLE = 200;
let count = 0, staleCount = 0;
const visited = new Set();
const queue = [nodeId];
while (queue.length > 0 && count < MAX_SAMPLE) {
const id = queue.shift();
if (visited.has(id)) continue;
visited.add(id);
count++;
if (nodeIndex[id] && nodeIndex[id].stale) staleCount++;
for (const dep of (upstreamAdj[id] || [])) {
if (!visited.has(dep)) queue.push(dep);
}
}
const frac = count > 0 ? staleCount / count : 0;
staleFractionCache[nodeId] = frac;
return frac;
}
function staleColor(fraction) {
// 0 = neutral (no styling needed), >0 = yellow-green to yellow
if (fraction === 0) return null;
const h = 90 - fraction * 30;
const s = 60 + fraction * 30;
const l = 20 + fraction * 25;
return `hsl(${h}, ${s}%, ${l}%)`;
}
function staleStrokeColor(fraction) {
if (fraction === 0) return null;
const h = 90 - fraction * 30;
const s = 50 + fraction * 40;
const l = 45;
return `hsl(${h}, ${s}%, ${l}%)`;
}
// Navigation state
let navStack = []; // stack of {type, data} for back navigation
let currentView = null; // {nodes: Set, rootId: string|null}
function getEndTargets() {
return graphData.nodes
.filter(n => {
if (downstreamAdj[n.id].length !== 0) return false;
// Hide targets with no dependencies (leaf-only, like "clean")
if ((upstreamAdj[n.id] || []).length === 0) return false;
// Hide *-clean targets by default
if (n.id.endsWith('-clean') || n.id === 'clean') return false;
return true;
})
.sort((a, b) => a.id.localeCompare(b.id));
}
// Rendering
const svg = d3.select('#graph-svg');
const inner = d3.select('#graph-group');
let currentZoom = null;
function renderView(visibleIds, expandedIds, rootId) {
inner.selectAll('*').remove();
const g = new dagreD3.graphlib.Graph()
.setGraph({ rankdir: 'LR', nodesep: 20, ranksep: 60, marginx: 20, marginy: 20 })
.setDefaultEdgeLabel(() => ({}));
// Determine which nodes to show as blobs vs expanded
const shownNodes = new Set(visibleIds);
const blobNodes = new Set();
// Nodes in visibleIds but not expanded (and having deps) are blobs
visibleIds.forEach(id => {
if (!expandedIds.has(id) && (upstreamAdj[id] || []).length > 0) {
blobNodes.add(id);
}
});
// Add nodes to dagre
shownNodes.forEach(id => {
const node = nodeIndex[id];
const isBlob = blobNodes.has(id);
const classes = [];
if (isBlob) {
classes.push('blob');
if (hasStaleInSubtree(id)) classes.push('stale');
} else {
if (node && node.phony) classes.push('phony');
if (node && node.stale) classes.push('stale');
}
if (id === rootId) classes.push('selected');
let label = id;
if (isBlob) {
const size = getSubtreeSize(id);
label = `${id} (${size})`;
}
g.setNode(id, { label, class: classes.join(' '), shape: 'rect', paddingLeft: 8, paddingRight: 8, paddingTop: 4, paddingBottom: 4 });
});
// Add edges between shown nodes (only check from shown sources)
shownNodes.forEach(id => {
for (const dep of (upstreamAdj[id] || [])) {
if (shownNodes.has(dep)) g.setEdge(id, dep);
}
});
// Render
const render = new dagreD3.render();
render(inner, g);
// Apply gradient colors to blob nodes based on stale fraction
inner.selectAll('.node.blob').each(function() {
const el = d3.select(this);
const nodeId = el.select('text').text().replace(/\s*\(\d+\)$/, '');
const frac = getStaleFraction(nodeId);
if (frac > 0) {
el.select('rect').style('fill', staleColor(frac)).style('stroke', staleStrokeColor(frac));
el.select('text').style('fill', frac > 0.5 ? '#e6e600' : '#c8b040');
}
});
// Zoom
const zoom = d3.zoom().scaleExtent([0.1, 4]).on('zoom', (e) => inner.attr('transform', e.transform));
svg.call(zoom);
currentZoom = zoom;
// Fit
const gw = g.graph().width || 100, gh = g.graph().height || 100;
const sw = svg.node().getBoundingClientRect().width;
const sh = svg.node().getBoundingClientRect().height;
const scale = Math.min(sw / (gw + 60), sh / (gh + 60), 1.0);
const tx = (sw - gw * scale) / 2, ty = (sh - gh * scale) / 2;
svg.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
// Event handlers
svg.selectAll('.node').each(function() {
const el = d3.select(this);
const nodeId = el.select('text').text().replace(/\s*\(\d+\)$/, '');
el.attr('data-id', nodeId);
el.attr('data-original-transform', el.attr('transform'));
el.on('click', (event) => {
event.stopPropagation();
expandNode(nodeId);
});
el.on('mouseover', () => {
// Only highlight among currently visible nodes (not full graph BFS)
const visible = new Set();
svg.selectAll('.node').each(function() { visible.add(d3.select(this).attr('data-id')); });
const deps = new Set([nodeId]);
const queue = [nodeId];
while (queue.length > 0) {
const cur = queue.shift();
for (const dep of (upstreamAdj[cur] || [])) {
if (visible.has(dep) && !deps.has(dep)) { deps.add(dep); queue.push(dep); }
}
}
const t = d3.transition().duration(250);
svg.selectAll('.node').each(function() {
const n = d3.select(this);
const nid = n.attr('data-id');
n.transition(t).style('opacity', deps.has(nid) ? 1 : 0.12);
});
svg.selectAll('.edgePath').each(function() {
const edge = d3.select(this);
const src = edge.attr('data-source'), dst = edge.attr('data-target');
edge.transition(t).style('opacity', (deps.has(src) && deps.has(dst)) ? 1 : 0.04);
});
});
el.on('mouseout', () => {
const t = d3.transition().duration(200);
svg.selectAll('.node').transition(t).style('opacity', 1);
svg.selectAll('.edgePath').transition(t).style('opacity', 1);
});
});
svg.selectAll('.edgePath').each(function() {
const edge = d3.select(this);
const edgeData = edge.datum();
if (edgeData) {
edge.attr('data-source', edgeData.v);
edge.attr('data-target', edgeData.w);
}
});
svg.on('click', (event) => {
if (event.target.tagName === 'svg' || event.target.closest('#graph-container') === event.target) {
goBack();
}
});
// Update info
document.getElementById('info-bar').textContent =
`${shownNodes.size} visible · ${graphData.nodes.length} total · Click node to expand · Click background to go back`;
updateBreadcrumb();
}
const MAX_GRAPH_DEPS = 40;
const MAX_VISIBLE = 100;
function expandNode(nodeId) {
const rootList = document.getElementById('root-list');
if (rootList) rootList.style.display = 'none';
// Push current state to stack
navStack.push(currentView);
document.getElementById('back-btn').style.display = 'inline-block';
const deps = upstreamAdj[nodeId] || [];
// If too many direct deps, show as a list (like root view) instead of graph
if (deps.length > MAX_GRAPH_DEPS) {
document.getElementById('graph-container').style.display = 'none';
currentView = { rootId: nodeId, listMode: true };
showDepList(nodeId, deps);
updateBreadcrumb();
return;
}
document.getElementById('graph-container').style.display = 'block';
// Build new view: nodeId + its direct deps
const expandedIds = new Set([nodeId]);
const visibleIds = new Set([nodeId]);
deps.forEach(dep => visibleIds.add(dep));
// Auto-expand small subtrees only if total stays manageable
for (const dep of deps) {
const subtreeSize = getSubtreeSize(dep);
if (subtreeSize <= BLOB_THRESHOLD) {
const depNodes = getTransitiveDeps(dep);
if (visibleIds.size + depNodes.size <= MAX_VISIBLE) {
expandedIds.add(dep);
depNodes.forEach(id => visibleIds.add(id));
}
}
}
currentView = { visibleIds, expandedIds, rootId: nodeId };
renderView(visibleIds, expandedIds, nodeId);
updateBreadcrumb();
}
function getPrefix(name) {
// Path-based: any/dir/file -> dir (use deepest directory)
const pathMatch = name.match(/^(?:.+\/)?([^/]+)\/[^/]+$/);
if (pathMatch) return pathMatch[1].toLowerCase();
// Dash-separated: foo-bar-baz -> foo
const dashMatch = name.match(/^([a-z][a-z0-9]*)-/i);
return dashMatch ? dashMatch[1].toLowerCase() : null;
}
function groupByPrefix(targets) {
const groups = {};
const ungrouped = [];
targets.forEach(t => {
const prefix = getPrefix(t.id);
if (prefix) {
if (!groups[prefix]) groups[prefix] = [];
groups[prefix].push(t);
} else {
ungrouped.push(t);
}
});
const grouped = {};
Object.entries(groups).forEach(([prefix, items]) => {
if (items.length >= 3) {
grouped[prefix] = items;
} else {
ungrouped.push(...items);
}
});
return { grouped, ungrouped: ungrouped.sort((a, b) => a.id.localeCompare(b.id)) };
}
function showDepList(parentId, deps) {
// Reuse or create a dep-list container (similar to root-list)
let depList = document.getElementById('dep-list');
if (!depList) {
depList = document.createElement('div');
depList.id = 'dep-list';
depList.innerHTML = `
<div class="root-header">
<h2 id="dep-list-title"></h2>
<input type="text" id="dep-filter" placeholder="Filter dependencies..." autocomplete="off">
</div>
<div id="dep-items"></div>
`;
document.body.appendChild(depList);
}
depList.style.display = 'block';
document.getElementById('dep-list-title').textContent = `${parentId} → ${deps.length} dependencies`;
const depTargets = deps.map(id => nodeIndex[id] || {id, stale: false, phony: false});
const itemsContainer = document.getElementById('dep-items');
function renderDepItems(filter) {
const filtered = filter
? depTargets.filter(t => t.id.toLowerCase().includes(filter))
: depTargets;
const { grouped, ungrouped } = groupByPrefix(filtered);
itemsContainer.innerHTML = '';
const sortedGroups = Object.keys(grouped).sort();
sortedGroups.forEach(prefix => {
const items = grouped[prefix];
const staleCount = items.filter(t => hasStaleInSubtree(t.id)).length;
const frac = staleCount / items.length;
const group = document.createElement('div');
group.className = 'root-group';
const header = document.createElement('div');
header.className = 'root-group-header';
if (frac > 0) {
header.style.borderColor = staleStrokeColor(frac);
}
header.innerHTML = `<span class="root-group-name" style="${frac > 0 ? 'color:' + staleStrokeColor(frac) : ''}">${prefix}</span><span class="root-item-size">${items.length} targets</span><span class="graph-all-btn" title="Graph all in group">⬡</span>`;
header.classList.add('expanded');
header.onclick = () => {
const body = group.querySelector('.root-group-body');
const expanding = body.style.display === 'none';
body.style.display = expanding ? 'flex' : 'none';
header.classList.toggle('expanded', expanding);
};
header.querySelector('.graph-all-btn').onclick = (e) => {
e.stopPropagation();
hideDepList();
navStack.push(currentView);
const visibleIds = new Set();
const expandedIds = new Set();
items.forEach(t => {
visibleIds.add(t.id);
const deps = getTransitiveDeps(t.id);
if (deps.size <= MAX_VISIBLE) {
deps.forEach(id => visibleIds.add(id));
expandedIds.add(t.id);
}
});
if (visibleIds.size > MAX_VISIBLE * 2) {
visibleIds.clear();
expandedIds.clear();
items.forEach(t => visibleIds.add(t.id));
}
currentView = { visibleIds, expandedIds, rootId: prefix + ' (group)' };
document.getElementById('graph-container').style.display = 'block';
document.getElementById('back-btn').style.display = 'inline-block';
renderView(visibleIds, expandedIds, null);
updateBreadcrumb();
};
group.appendChild(header);
const body = document.createElement('div');
body.className = 'root-group-body';
body.style.display = 'flex';
items.forEach(t => {
const size = getSubtreeSize(t.id);
const stale = hasStaleInSubtree(t.id);
const item = document.createElement('div');
item.className = 'root-item' + (stale ? ' stale' : '');
item.innerHTML = `<span class="root-item-name">${t.id}</span><span class="root-item-size">${size}</span>`;
item.onclick = (e) => { e.stopPropagation(); hideDepList(); expandNode(t.id); };
body.appendChild(item);
});
group.appendChild(body);
itemsContainer.appendChild(group);
});
ungrouped.forEach(t => {
const size = getSubtreeSize(t.id);
const stale = hasStaleInSubtree(t.id);
const item = document.createElement('div');
item.className = 'root-item' + (stale ? ' stale' : '');
item.innerHTML = `<span class="root-item-name">${t.id}</span><span class="root-item-size">${size} deps</span>`;
item.onclick = () => { hideDepList(); expandNode(t.id); };
itemsContainer.appendChild(item);
});
}
renderDepItems('');
const filterInput = document.getElementById('dep-filter');
filterInput.value = '';
filterInput.oninput = () => renderDepItems(filterInput.value.toLowerCase().trim());
filterInput.focus();
document.getElementById('info-bar').textContent =
`${deps.length} direct deps of "${parentId}" · ${graphData.nodes.length} total nodes`;
}
function hideDepList() {
const depList = document.getElementById('dep-list');
if (depList) depList.style.display = 'none';
}
function goBack() {
hideDepList();
if (navStack.length === 0) {
showRoots();
return;
}
currentView = navStack.pop();
if (currentView === null) {
showRoots();
} else if (currentView.listMode) {
document.getElementById('graph-container').style.display = 'none';
const deps = upstreamAdj[currentView.rootId] || [];
showDepList(currentView.rootId, deps);
updateBreadcrumb();
} else {
document.getElementById('graph-container').style.display = 'block';
renderView(currentView.visibleIds, currentView.expandedIds, currentView.rootId);
}
if (navStack.length === 0 && currentView === null) {
document.getElementById('back-btn').style.display = 'none';
}
}
function showRoots() {
currentView = null;
navStack = [];
document.getElementById('back-btn').style.display = 'none';
document.getElementById('graph-container').style.display = 'none';
// Show or create root list
let rootList = document.getElementById('root-list');
if (!rootList) {
rootList = document.createElement('div');
rootList.id = 'root-list';
rootList.innerHTML = `
<div class="root-header">
<h2>Top-level targets</h2>
<input type="text" id="root-filter" placeholder="Filter targets..." autocomplete="off">
</div>
<div id="root-items"></div>
`;
document.body.appendChild(rootList);
}
rootList.style.display = 'block';
const endTargets = getEndTargets();
const itemsContainer = document.getElementById('root-items');
function renderItems(filter) {
const filtered = filter
? endTargets.filter(t => t.id.toLowerCase().includes(filter))
: endTargets;
itemsContainer.innerHTML = '';
const { grouped, ungrouped } = groupByPrefix(filtered);
// Render groups
const sortedGroups = Object.keys(grouped).sort();
sortedGroups.forEach(prefix => {
const items = grouped[prefix];
const staleCount = items.filter(t => hasStaleInSubtree(t.id)).length;
const frac = staleCount / items.length;
const group = document.createElement('div');
group.className = 'root-group';
const header = document.createElement('div');
header.className = 'root-group-header';
if (frac > 0) {
header.style.borderColor = staleStrokeColor(frac);
}
header.innerHTML = `<span class="root-group-name" style="${frac > 0 ? 'color:' + staleStrokeColor(frac) : ''}">${prefix}</span><span class="root-item-size">${items.length} targets</span>`;
header.onclick = () => {
const body = group.querySelector('.root-group-body');
const expanding = body.style.display === 'none';
body.style.display = expanding ? 'flex' : 'none';
header.classList.toggle('expanded', expanding);
};
group.appendChild(header);
const body = document.createElement('div');
body.className = 'root-group-body';
body.style.display = 'none';
items.forEach(t => {
const size = getSubtreeSize(t.id);
const stale = hasStaleInSubtree(t.id);
const item = document.createElement('div');
item.className = 'root-item' + (stale ? ' stale' : '');
item.innerHTML = `<span class="root-item-name">${t.id}</span><span class="root-item-size">${size}</span>`;
item.onclick = (e) => {
e.stopPropagation();
rootList.style.display = 'none';
document.getElementById('graph-container').style.display = 'block';
expandNode(t.id);
};
body.appendChild(item);
});
group.appendChild(body);
itemsContainer.appendChild(group);
});
// Render ungrouped
ungrouped.forEach(t => {
const size = getSubtreeSize(t.id);
const stale = hasStaleInSubtree(t.id);
const item = document.createElement('div');
item.className = 'root-item' + (stale ? ' stale' : '');
item.innerHTML = `<span class="root-item-name">${t.id}</span><span class="root-item-size">${size} deps</span>`;
item.onclick = () => {
rootList.style.display = 'none';
document.getElementById('graph-container').style.display = 'block';
expandNode(t.id);
};
itemsContainer.appendChild(item);
});
}
renderItems('');
const filterInput = document.getElementById('root-filter');
filterInput.value = '';
filterInput.oninput = () => renderItems(filterInput.value.toLowerCase().trim());
filterInput.focus();
document.getElementById('info-bar').textContent =
`${endTargets.length} top-level targets · ${graphData.nodes.length} total nodes`;
updateBreadcrumb();
}
function updateBreadcrumb() {
const bc = document.getElementById('breadcrumb');
bc.innerHTML = '';
const items = [{label: 'root', action: showRoots}];
navStack.forEach((view, i) => {
if (view && view.rootId) {
items.push({label: view.rootId, action: () => {
navStack = navStack.slice(0, i);
currentView = navStack.pop() || null;
if (view) { currentView = view; renderView(view.visibleIds, view.expandedIds, view.rootId); }
else showRoots();
}});
}
});
if (currentView && currentView.rootId) {
items.push({label: currentView.rootId, action: null});
}
items.forEach((item, i) => {
if (i > 0) {
const sep = document.createElement('span');
sep.className = 'crumb-sep';
sep.textContent = ' > ';
bc.appendChild(sep);
}
const span = document.createElement('span');
span.textContent = item.label;
if (item.action && i < items.length - 1) {
span.className = 'crumb';
span.onclick = item.action;
}
bc.appendChild(span);
});
}
// Search
const searchInput = document.getElementById('search-input');
searchInput.addEventListener('input', () => {
const query = searchInput.value.toLowerCase().trim();
if (!query) return;
const match = graphData.nodes.find(n => n.id.toLowerCase().includes(query));
if (match) expandNode(match.id);
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { searchInput.value = ''; showRoots(); }
});
// Back button
document.getElementById('back-btn').onclick = goBack;
// Start
showRoots();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment