monoripple 0.0.2

Symbol-aware affected target detection for JavaScript and TypeScript monorepos
Documentation
use serde::Serialize;

#[derive(Serialize)]
pub struct GraphView {
    pub package: Option<String>,
    pub base: String,
    pub task: String,
    pub scope: String,
    pub nodes: Vec<GraphNode>,
    pub links: Vec<GraphLink>,
}

#[derive(Serialize)]
pub struct GraphNode {
    pub id: usize,
    pub label: String,
    pub file: String,
    pub symbol: String,
    pub package: String,
    pub kind: NodeKind,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub details: Vec<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub path: Vec<usize>,
}

#[derive(Clone, Copy, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum NodeKind {
    Seed,
    Affected,
    Dependency,
    Target,
    Normal,
}

#[derive(Serialize)]
pub struct GraphLink {
    pub source: usize,
    pub target: usize,
    #[serde(rename = "type")]
    pub type_only: bool,
}

pub fn render_html(view: &GraphView) -> String {
    let data = serde_json::to_string(view).unwrap_or_else(|_| "{}".to_string());
    TEMPLATE.replace("/*__DATA__*/null", &data)
}

const TEMPLATE: &str = r##"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>monoripple graph</title>
<style>
  :root { color-scheme: dark; }
  * { box-sizing: border-box; }
  body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: #0f1117; color: #e6e6e6; }
  #app { display: grid; grid-template-columns: 1fr 340px; grid-template-rows: auto 1fr; height: 100vh; }
  header { grid-column: 1 / 3; padding: 10px 16px; border-bottom: 1px solid #232732; display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
  header h1 { font-size: 15px; margin: 0; font-weight: 600; }
  header .meta { color: #8b93a7; font-size: 12px; }
  header input { margin-left: auto; background: #171a22; border: 1px solid #2a2f3b; color: #e6e6e6; padding: 6px 10px; border-radius: 6px; width: 240px; }
  canvas { display: block; width: 100%; height: 100%; }
  #side { border-left: 1px solid #232732; padding: 14px 16px; overflow: auto; }
  #side h2 { font-size: 13px; margin: 0 0 6px; color: #9aa3b7; text-transform: uppercase; letter-spacing: .05em; }
  #side .value { font-size: 13px; word-break: break-word; margin-bottom: 12px; }
  #side ul { margin: 6px 0 12px; padding-left: 18px; }
  #side li { font-size: 12px; margin-bottom: 4px; }
  .legend { display: flex; gap: 14px; font-size: 12px; color: #b9c0d0; flex-wrap: wrap; }
  .dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
  .hint { color: #6b7280; font-size: 11px; }
  #side ol { margin: 6px 0 12px; padding-left: 18px; }
  #side ol li { font-size: 12px; margin-bottom: 4px; font-family: ui-monospace, monospace; }
  #side .targets a { color: #a78bfa; text-decoration: none; }
  #side .targets a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div id="app">
  <header>
    <h1>monoripple graph</h1>
    <span class="meta" id="summary"></span>
    <span class="legend">
      <span><span class="dot" style="background:#f97316"></span>changed</span>
      <span><span class="dot" style="background:#38bdf8"></span>affected</span>
      <span><span class="dot" style="background:#a78bfa"></span>target</span>
      <span><span class="dot" style="background:#34d399"></span>shared dependency</span>
      <span><span class="dot" style="background:#3f4759"></span>other</span>
    </span>
    <input id="filter" placeholder="filter nodes…" />
  </header>
  <canvas id="canvas"></canvas>
  <aside id="side">
    <h2>Selected node</h2>
    <div class="value" id="selected">Click a node to inspect it.</div>
    <div id="details"></div>
    <p class="hint">Drag nodes to reposition. Scroll to zoom. Drag background to pan.</p>
  </aside>
</div>
<script>
const DATA = /*__DATA__*/null;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const colors = { seed: "#f97316", affected: "#38bdf8", dependency: "#34d399", target: "#a78bfa", normal: "#3f4759" };

const nodes = DATA.nodes.map((n) => ({ ...n, x: 0, y: 0, vx: 0, vy: 0 }));
const links = DATA.links.map((l) => ({ ...l, source: nodes[l.source], target: nodes[l.target] }));
const adjacency = new Map(nodes.map((n) => [n.id, new Set()]));
for (const link of links) {
  adjacency.get(link.source.id).add(link.target.id);
  adjacency.get(link.target.id).add(link.source.id);
}

const targetNodes = nodes.filter((n) => n.kind === "target");
let width = 0, height = 0, dpr = window.devicePixelRatio || 1;
function layoutAnchors() {
  const r = Math.max(160, Math.min(width, height) * 0.34);
  targetNodes.forEach((n, i) => {
    const angle = (i / Math.max(1, targetNodes.length)) * Math.PI * 2 - Math.PI / 2;
    n.ax = width / 2 + Math.cos(angle) * r;
    n.ay = height / 2 + Math.sin(angle) * r;
    n.anchored = true;
  });
}
function resize() {
  const rect = canvas.getBoundingClientRect();
  width = rect.width; height = rect.height;
  canvas.width = width * dpr; canvas.height = height * dpr;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  layoutAnchors();
}
window.addEventListener("resize", resize);
resize();

nodes.forEach((n, i) => {
  if (n.anchored) { n.x = n.ax; n.y = n.ay; return; }
  const angle = (i / Math.max(1, nodes.length)) * Math.PI * 2;
  const spread = 120 + Math.random() * 160;
  n.x = width / 2 + Math.cos(angle) * spread;
  n.y = height / 2 + Math.sin(angle) * spread;
});

const view = { x: 0, y: 0, scale: 1 };
let selected = null;
let filterText = "";
let alpha = 1;
let dragging = null, panning = false, lastX = 0, lastY = 0;

function tick() {
  const repulsion = 6000;
  for (let i = 0; i < nodes.length; i++) {
    const a = nodes[i];
    for (let j = i + 1; j < nodes.length; j++) {
      const b = nodes[j];
      let dx = a.x - b.x, dy = a.y - b.y;
      let dist2 = dx * dx + dy * dy + 0.01;
      const force = repulsion / dist2;
      const dist = Math.sqrt(dist2);
      const fx = (dx / dist) * force, fy = (dy / dist) * force;
      a.vx += fx * alpha; a.vy += fy * alpha;
      b.vx -= fx * alpha; b.vy -= fy * alpha;
    }
  }
  for (const link of links) {
    let dx = link.target.x - link.source.x, dy = link.target.y - link.source.y;
    const dist = Math.sqrt(dx * dx + dy * dy) + 0.01;
    const force = (dist - 90) * 0.02;
    const fx = (dx / dist) * force, fy = (dy / dist) * force;
    link.source.vx += fx * alpha; link.source.vy += fy * alpha;
    link.target.vx -= fx * alpha; link.target.vy -= fy * alpha;
  }
  for (const n of nodes) {
    if (n.anchored) {
      n.vx += (n.ax - n.x) * 0.08 * alpha;
      n.vy += (n.ay - n.y) * 0.08 * alpha;
    } else {
      n.vx += (width / 2 - n.x) * 0.0005 * alpha;
      n.vy += (height / 2 - n.y) * 0.0005 * alpha;
    }
    if (n === dragging) continue;
    n.x += n.vx; n.y += n.vy;
    n.vx *= 0.86; n.vy *= 0.86;
  }
  alpha *= 0.995;
  if (alpha < 0.02) alpha = 0.02;
}

function draw() {
  ctx.clearRect(0, 0, width, height);
  ctx.save();
  ctx.translate(view.x, view.y);
  ctx.scale(view.scale, view.scale);

  const pathIds = selected ? pathNodeSet(selected) : null;
  const pathEdges = selected ? pathEdgeSet(selected) : null;
  const neighbors = selected ? adjacency.get(selected.id) : null;
  ctx.lineWidth = 1;
  for (const link of links) {
    const onPath = pathEdges && pathEdges.has(edgeKey(link.source.id, link.target.id));
    const touches = selected && (link.source.id === selected.id || link.target.id === selected.id);
    if (onPath) {
      ctx.strokeStyle = "#f8fafc"; ctx.lineWidth = 2.4;
    } else {
      ctx.strokeStyle = touches ? "#e2e8f0" : link.type ? "#2b3550" : "#232a38";
      ctx.lineWidth = 1;
    }
    ctx.beginPath();
    ctx.moveTo(link.source.x, link.source.y);
    ctx.lineTo(link.target.x, link.target.y);
    ctx.stroke();
  }
  ctx.lineWidth = 1;
  for (const n of nodes) {
    const matches = filterText && n.label.toLowerCase().includes(filterText);
    const onPath = pathIds ? pathIds.has(n.id) : false;
    const dim =
      (selected && !onPath && n !== selected && !neighbors.has(n.id)) ||
      (filterText && !matches);
    ctx.globalAlpha = dim ? 0.12 : 1;
    const r = n.kind === "target" ? 13 : n.kind === "dependency" ? 10 : n.kind === "seed" ? 7 : 5;
    if (n.kind === "target") {
      ctx.fillStyle = colors.target;
      ctx.strokeStyle = "#0f1117";
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
      ctx.fill();
      ctx.stroke();
    } else {
      ctx.fillStyle = colors[n.kind] || colors.normal;
      ctx.beginPath();
      ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
      ctx.fill();
    }
    if (n === selected) {
      ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 2; ctx.stroke();
    }
    if (n.kind === "target") {
      ctx.globalAlpha = dim ? 0.3 : 1;
      ctx.fillStyle = "#e9ddff";
      ctx.font = "bold 13px ui-sans-serif, system-ui, sans-serif";
      ctx.fillText(n.symbol, n.x + r + 4, n.y + 4);
    } else if (n.kind === "dependency") {
      ctx.globalAlpha = dim ? 0.3 : 1;
      ctx.fillStyle = "#a7f3d0";
      ctx.font = "bold 11px ui-monospace, monospace";
      ctx.fillText(n.label, n.x + r + 4, n.y + 3);
    } else if (view.scale > 1.1 || n === selected || matches) {
      ctx.globalAlpha = dim ? 0.2 : 0.9;
      ctx.fillStyle = "#c7ccd8";
      ctx.font = "10px ui-monospace, monospace";
      ctx.fillText(n.symbol === "<module>" ? shortFile(n.file) : n.symbol, n.x + 9, n.y + 3);
    }
  }
  ctx.globalAlpha = 1;
  ctx.restore();
}

function shortFile(file) {
  const parts = file.split("/");
  return parts.slice(Math.max(0, parts.length - 2)).join("/");
}

function edgeKey(a, b) { return a < b ? a + "|" + b : b + "|" + a; }
function pathNodeSet(node) {
  const set = new Set([node.id]);
  for (const id of node.path || []) set.add(id);
  return set;
}
function pathEdgeSet(node) {
  const set = new Set();
  const p = node.path || [];
  for (let i = 0; i + 1 < p.length; i++) set.add(edgeKey(p[i], p[i + 1]));
  return set;
}

function frame() { tick(); draw(); requestAnimationFrame(frame); }
frame();

function screenToWorld(px, py) {
  return { x: (px - view.x) / view.scale, y: (py - view.y) / view.scale };
}
function nodeAt(px, py) {
  const p = screenToWorld(px, py);
  let best = null, bestDist = 12 / view.scale;
  for (const n of nodes) {
    const d = Math.hypot(n.x - p.x, n.y - p.y);
    if (d < bestDist) { best = n; bestDist = d; }
  }
  return best;
}

canvas.addEventListener("mousedown", (e) => {
  const node = nodeAt(e.offsetX, e.offsetY);
  if (node) { dragging = node; select(node); }
  else { panning = true; }
  lastX = e.offsetX; lastY = e.offsetY;
});
window.addEventListener("mousemove", (e) => {
  const rect = canvas.getBoundingClientRect();
  const ox = e.clientX - rect.left, oy = e.clientY - rect.top;
  if (dragging) {
    const p = screenToWorld(ox, oy);
    dragging.x = p.x; dragging.y = p.y; dragging.vx = 0; dragging.vy = 0;
    alpha = Math.max(alpha, 0.3);
  } else if (panning) {
    view.x += ox - lastX; view.y += oy - lastY;
  }
  lastX = ox; lastY = oy;
});
window.addEventListener("mouseup", () => { dragging = null; panning = false; });
canvas.addEventListener("wheel", (e) => {
  e.preventDefault();
  const factor = e.deltaY < 0 ? 1.1 : 0.9;
  const p = screenToWorld(e.offsetX, e.offsetY);
  view.scale *= factor;
  view.x = e.offsetX - p.x * view.scale;
  view.y = e.offsetY - p.y * view.scale;
}, { passive: false });

function select(node) {
  selected = node;
  document.getElementById("selected").textContent = node.label;
  const details = document.getElementById("details");
  let html = `<h2>Package</h2><div class="value">${escapeHtml(node.package || "—")}</div>`;
  html += `<h2>Kind</h2><div class="value">${node.kind}</div>`;
  if (node.details && node.details.length) {
    html += `<h2>What changed</h2><ul>${node.details.map((d) => `<li>${escapeHtml(d)}</li>`).join("")}</ul>`;
  }
  const path = (node.path || []).map((id) => nodes[id]);
  if (path.length > 1) {
    const heading = node.kind === "target" ? "Why it deploys" : "Why it is affected";
    const steps = path
      .map((step, i) => {
        const arrow = i === 0 ? "" : "↓ ";
        return `<li>${arrow}${escapeHtml(step.symbol === "<module>" ? step.file : step.symbol)}</li>`;
      })
      .join("");
    html += `<h2>${heading}</h2><ol>${steps}</ol>`;
  } else if (node.kind === "seed") {
    html += `<h2>Why it deploys</h2><div class="value">This declaration changed.</div>`;
  }
  details.innerHTML = html;
}

function renderTargetList() {
  if (selected) return;
  const targetNodes = nodes.filter((n) => n.kind === "target");
  if (!targetNodes.length) {
    document.getElementById("details").innerHTML =
      `<div class="value">No deployment targets are affected by this change.</div>`;
    return;
  }
  const list = targetNodes
    .map((n) => `<li><a href="#" data-id="${n.id}">${escapeHtml(n.symbol)}</a></li>`)
    .join("");
  document.getElementById("details").innerHTML =
    `<h2>Affected targets</h2><ul class="targets">${list}</ul>`;
  for (const link of document.querySelectorAll("#details a[data-id]")) {
    link.addEventListener("click", (e) => {
      e.preventDefault();
      const node = nodes[Number(link.dataset.id)];
      focusNode(node);
      select(node);
    });
  }
}

function focusNode(node) {
  view.scale = Math.max(view.scale, 1.4);
  view.x = width / 2 - node.x * view.scale;
  view.y = height / 2 - node.y * view.scale;
}

function escapeHtml(s) {
  return s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
}

document.getElementById("filter").addEventListener("input", (e) => {
  filterText = e.target.value.trim().toLowerCase();
});

const seeds = nodes.filter((n) => n.kind === "seed").length;
const targets = nodes.filter((n) => n.kind === "target").length;
document.getElementById("summary").textContent =
  `${DATA.scope} scope · base ${DATA.base} · task ${DATA.task} · ${nodes.length} nodes · ${links.length} edges · ${seeds} changed · ${targets} targets`;
renderTargetList();
</script>
</body>
</html>
"##;