diff --git a/README.md b/README.md index ff865b5a..96684140 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,13 @@ callback, syntax alone cannot decide the target, and every wrong guess is a miss edge is a call the program actually makes. That matters because an agent follows edges several hops deep, and one missed link loses everything beyond it. +

+ axiomcode graph on an open-source Spring application: one service method selected, with every resolved call into and out of it drawn across the packages of the codebase +

+ +*`axiomcode graph` on an open-source Spring application. Selecting a method shows every resolved call into and +out of it, across packages and modules; the dashed line is a call into a library.* + AI agents work from an incomplete picture of a codebase, and the reason is structural: what a call reaches is usually decided somewhere else. The type comes from another file, the implementation from another module, the binding from a dependency or a configuration key. Reading the file in front of you cannot show any of that, so a diff --git a/docs/images/graph-selected-method.png b/docs/images/graph-selected-method.png new file mode 100644 index 00000000..4e078f6f Binary files /dev/null and b/docs/images/graph-selected-method.png differ diff --git a/plugins/axiomcode/skills/axiomcode/scripts/viewer.html b/plugins/axiomcode/skills/axiomcode/scripts/viewer.html index a6ab05c3..4ca6ef0f 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/viewer.html +++ b/plugins/axiomcode/skills/axiomcode/scripts/viewer.html @@ -139,7 +139,7 @@

focus depth // ── what is shown: type toggles · only-flags · edge toggles · focus depth · search filter ────────────────────── const only = { u: false, e: false, t: false }, edges = { known: true, inf: true, lib: true, ext: true, tree: true }; -let hops = 0, filter = null /* Set of ids from search or focus depth */, sel = null, hover = null, focus = null, layoutSet = null; +let K0 = 1 /* the zoom fitAll chose */, hops = 0, filter = null /* Set of ids from search or focus depth */, sel = null, hover = null, focus = null, layoutSet = null; const isEntry = n => n.e && !(n.e.length === 1 && n.e[0] === 'test'); const passes = n => show[KIND(n)] && (show.test || !n.t) && (!only.u || n.u) && (!only.e || isEntry(n)) && (!only.t || n.t) && (!filter || filter.has(n.id)); let VIS = DATA.nodes, VISSET = null; @@ -169,7 +169,8 @@

focus depth function centre(n, k) { view.k = k || Math.max(view.k, 3); view.x = wrap.clientWidth / 2 - n.x * view.k; view.y = wrap.clientHeight / 2 - n.y * view.k; mark(); } function pick(px, py) { const x = (px - view.x) / view.k, y = (py - view.y) / view.k, gx = (x / CELL) | 0, gy = (y / CELL) | 0; let best = null, bd = 1e9; const tol = 7 / view.k; for (let i = -1; i <= 1; i++) for (let j = -1; j <= 1; j++) for (const n of (grid.get((gx + i) + ',' + (gy + j)) || [])) { const d = Math.hypot(n.x - x, n.y - y) - rad(n); if (d < tol && d < bd) { bd = d; best = n; } } return best; } const neighbours = n => { const s = new Set([n.id]); for (const e of (OUT.get(n.id) || [])) s.add(e.t); for (const e of (IN.get(n.id) || [])) s.add(e.s); for (const e of (LOUT.get(n.id) || [])) s.add(e.t); for (const e of (LIN.get(n.id) || [])) s.add(e.s); for (const t of (XOUT.get(n.id) || [])) s.add(t); for (const t of (XIN.get(n.id) || [])) s.add(t); if (n.p) s.add(n.p); for (const c of (KIDS.get(n.id) || [])) s.add(c.id); return s; }; -function chip(n, k, colour, big) { const t = short(n); ctx.font = `${big ? '600 ' : ''}${(big ? 11.5 : 10.5) / k}px ui-monospace, Menlo, monospace`; const w = ctx.measureText(t).width + 12 / k, h = 16 / k, x = n.x - w / 2, y = n.y - rad(n) - 14 / k - h; +function chip(n, k, colour, big, taken, rim) { const t = short(n); ctx.font = `${big ? '600 ' : ''}${(big ? 11.5 : 10.5) / k}px ui-monospace, Menlo, monospace`; const w = ctx.measureText(t).width + 12 / k, h = 16 / k, x = n.x - w / 2, y = n.y - rad(n) - 14 / k - h; + const g = 3 / k; const hit = r => x < r[2] + g && r[0] < x + w + g && y < r[3] + g && r[1] < y + h + g; if (!big && (taken.some(hit) || (rim && rim.some(hit)))) return; taken.push([x, y, x + w, y + h]); // a label that would cover another is not drawn: the hover and the selection always are, and only ambient chips give way to the rim ctx.strokeStyle = colour; ctx.lineWidth = 1 / k; ctx.beginPath(); ctx.moveTo(n.x, n.y - rad(n)); ctx.lineTo(n.x, y + h); ctx.stroke(); ctx.fillStyle = 'rgba(11,13,20,.92)'; ctx.beginPath(); ctx.roundRect(x, y, w, h, 4 / k); ctx.fill(); ctx.stroke(); ctx.fillStyle = colour; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(t, n.x, y + h / 2); } @@ -200,6 +201,7 @@

focus depth else { ctx.fillStyle = colour; ctx.fill(); } } ctx.globalAlpha = 1; // directory names around the rim of the full disc + const rim = []; if (!filter) { const R = (libs.length ? R4 : R3) + 90, MODS = new Set(dirs.map(d => MOD(d.l))), placed = []; const name = d => { const s = d.vsegs, m = MOD(d.l), rest = m ? s.slice(1) : s; return [MODS.size > 1 && m ? m + ' · ' : '', (rest.slice(-2).join('/') || m || d.l) + (show.test && /\/src\/test/.test('/' + d.l) ? ' (test)' : '')]; }; const cand = dirs.filter(d => on(d.id) && (d.span * R * k >= 12 || d === hover || d === sel)).sort((a, b) => (b === sel || b === hover) - (a === sel || a === hover) || W(b) - W(a)); @@ -207,16 +209,16 @@

focus depth for (const d of cand) { const a = Math.atan2(d.dy, d.dx); const right = Math.cos(a) >= 0, y = R * Math.sin(a) * k; if (placed.some(p => p.right === right && Math.abs(p.y - y) < 14)) continue; let [pre, main] = name(d); const x = R * Math.cos(a) + (right ? 4 : -4) / k, dim = F && !F.has(d.id), sx = view.x + x * k, CW = wrap.clientWidth, fits = t => right ? sx + ctx.measureText(t).width * k < CW - 6 : sx - ctx.measureText(t).width * k > 6; if (!fits(pre + main)) { if (fits(main)) pre = ''; else continue; } placed.push({ right, y }); const wp = ctx.measureText(pre).width, wm = ctx.measureText(main).width; ctx.textAlign = 'left'; - const x0 = right ? x : x - wp - wm; ctx.fillStyle = dim ? 'rgba(129,140,248,.18)' : 'rgba(129,140,248,.45)'; ctx.fillText(pre, x0, R * Math.sin(a)); ctx.fillStyle = dim ? 'rgba(129,140,248,.3)' : 'rgba(165,180,252,.9)'; ctx.fillText(main, x0 + wp, R * Math.sin(a)); } } + const x0 = right ? x : x - wp - wm; ctx.fillStyle = dim ? 'rgba(129,140,248,.18)' : 'rgba(129,140,248,.45)'; ctx.fillText(pre, x0, R * Math.sin(a)); ctx.fillStyle = dim ? 'rgba(129,140,248,.3)' : 'rgba(165,180,252,.9)'; ctx.fillText(main, x0 + wp, R * Math.sin(a)); rim.push([x0, R * Math.sin(a) - 7 / k, x0 + wp + wm, R * Math.sin(a) + 7 / k]); } } // chips: only what matters — the selection and its neighbours, matches, hover, and the big things when zoomed in const chips = []; const TOPLIB = new Set(libs.filter(b => on(b.id)).sort((a, b) => b.sites - a.sites).slice(0, 4)); if (hover && on(hover.id)) chips.push([hover, '#e5e7eb', true]); if (sel && on(sel.id) && sel !== hover) chips.push([sel, '#f59e0b', true]); if (F && (mode === 'select' || mode === 'chain' || (mode === 'impact' && F.size <= 60))) for (const id of F) { if (chips.length > 70) break; const n = N.get(id); if (n !== sel && n !== hover && on(id)) chips.push([n, mode === 'impact' ? '#fb7185' : mode === 'chain' ? '#f59e0b' : '#22d3ee', false]); } if (search && !F) for (const id of search.matches) { if (chips.length > 70) break; const n = N.get(id); if (on(id) && n !== hover) chips.push([n, '#22d3ee', false]); } - if (!F && !search) for (const n of VIS) { if (chips.length > 90) break; if ((n.k === 'dir' && k > 1.2) || (n.k === 'file' && k > 2.2) || (n.k === 'type' && k > 3.2) || (n.k === 'method' && k > 7) || (n.k === 'lib' && (k > 1.6 || TOPLIB.has(n)))) chips.push([n, n.k === 'lib' ? LIBC : colOf(n), false]); } - if (few && !F && !search) for (const n of VIS) { if (chips.length > 120) break; if (!chips.some(c => c[0] === n)) chips.push([n, colOf(n), false]); } - for (const [n, c, big] of chips) chip(n, k, c, big); + const z = k / K0; // zoom relative to the fitted view: absolute thresholds labelled every node of a small repo on open + if (!F && !search) for (const n of VIS) { if (chips.length > 90) break; if ((n.k === 'dir' && z > 1.2) || (n.k === 'file' && z > 2.2) || (n.k === 'type' && z > 3.2) || (n.k === 'method' && z > 7) || (n.k === 'lib' && (z > 1.6 || TOPLIB.has(n)))) chips.push([n, n.k === 'lib' ? LIBC : colOf(n), false, true]); } + const taken = []; for (const [n, c, big, ambient] of chips) chip(n, k, c, big, taken, ambient ? rim : null); status(); } function status() { const S = document.getElementById('status'); if (!focus || focus.mode === 'hover' || focus.mode === 'select') { S.textContent = ''; return; } const n = N.get(focus.id); S.textContent = focus.mode === 'impact' ? `impact of ${n.l}: ${focus.set.size - 1} methods in ${focus.files} files can reach it through resolved calls` + (n.u ? ` · ${n.u} unresolved sites in its body make that a lower bound` : '') : `chain from ${n.l}: ${focus.path.length} hops, the heaviest resolved callee at each`; } function counts() { const c = {}; for (const n of VIS) { c[KIND(n)] = (c[KIND(n)] || 0) + 1; if (n.t) c.test = (c.test || 0) + 1; } document.getElementById('counts').textContent = `${VIS.length} of ${DATA.nodes.length} nodes shown` + (filter ? ' · filtered' : ''); document.querySelectorAll('#types .tog').forEach(t => t.querySelector('.n').textContent = c[t.dataset.k] || 0); } @@ -310,7 +312,7 @@

called by (${ins.length})

    ${ins.slice().sort((a, b) => b.w - a.w).sl ED.querySelectorAll('.tog').forEach(t => t.onclick = () => { edges[t.dataset.e] = !edges[t.dataset.e]; t.classList.toggle('off', !edges[t.dataset.e]); mark(); }); const H = document.getElementById('hops'); H.innerHTML = ['all', 1, 2, 3].map(h => `${h === 'all' ? 'all' : h + ' hop' + (h > 1 ? 's' : '')}`).join(''); H.querySelectorAll('span').forEach(s => s.onclick = () => { hops = +s.dataset.h; H.querySelectorAll('span').forEach(x => x.classList.toggle('on', x === s)); applyFilter(); if (!filter) fitAll(); panel(); }); -function fitAll() { for (const n of DATA.nodes) { n.x = n.dx; n.y = n.dy; } rebuildGrid(); const w = wrap.clientWidth, h = wrap.clientHeight, ext = (libs.length ? R4 : R3) + 90; view.k = Math.max(.02, Math.min((w / 2 - Math.min(150, w * .12)) / ext, (h / 2 - 24) / ext)); /* room for the rim labels, drawn at a fixed screen size */ view.x = w / 2; view.y = h / 2; mark(); } +function fitAll() { for (const n of DATA.nodes) { n.x = n.dx; n.y = n.dy; } rebuildGrid(); const w = wrap.clientWidth, h = wrap.clientHeight, ext = (libs.length ? R4 : R3) + 90; view.k = Math.max(.02, Math.min((w / 2 - Math.min(150, w * .12)) / ext, (h / 2 - 24) / ext)); /* room for the rim labels, drawn at a fixed screen size */ view.x = w / 2; view.y = h / 2; K0 = view.k; mark(); } // ── search (right): type to see matches; Enter or a click shows ONLY the matches (and their hops) ───────────── const q = document.getElementById('q'), hits = document.getElementById('hits'), sbox = document.getElementById('search'); let cur = [];