Created
May 22, 2026 22:54
-
-
Save twall/d83e28a430b2b60acc529a4905dbe33a to your computer and use it in GitHub Desktop.
Python to generate html of a makefile dependency DAG, including stale dependencies
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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">← 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