diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dc76083..ef535366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed +- **Unified codebase explorer**: Replace separate architecture/network/symbol views with a categorized module directory and one module → exact file → symbol drill-down. Stable caller → focus → callee layouts use paged neighbors, direct navigation, and inspectable original callsites, including self-calls. Refine typography, category cards, snapshot totals, metadata density, and evidence panels; show module-anchored display paths while preserving original locations and add sticky narrow-layout section jumps. Keep whole-snapshot Changes, Hotspots, and Cycles in Analyze without losing exploration state. Preserve offline HTML, public export/tool arguments, safe snapshot data, search/history, responsive dialogs, and dark/light themes; remove draggable-network layout code. - **Node.js runtime floor and Chokidar 6**: Raise both package identities' minimum from Node.js 22.13.0 to 22.22.0 to satisfy Chokidar 6's declared engine requirement. Existing platform, binary, and host contracts remain unchanged. - **Complete history sync scheduling**: Reconcile up to four entities concurrently through one sync-local request budget of four active calls and eight starts per rolling second, without skipping historical edits, deletions, or conditional page validation. Drain real requests before publication, cancellation settlement, or cleanup; retain resumable checkpoints on transient failures and let late access denials take precedence. @@ -15,7 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Concurrent branch materialization**: Use invocation-unique worktree registration names and a common-Git-directory lease around registration/deregistration, preventing overlapping calls from reading or recycling partially initialized peer metadata. Keep fetches and indexing callbacks concurrent, preserve cancellation cleanup and fail-closed retention, and cover peer preservation plus real multiprocess overlap. - **Directory watcher permission recovery**: Recover readable, policy-selected directory and project-root registrations rejected with `EPERM` or `EACCES`, including empty directories that later gain source files. Reuse the one-shot polling cutover and catch-up reconciliation without broadening ignored, excluded, restricted, protected-index, unreadable, or external-configuration scope. Exercise actual Chokidar 6 directory resources in the watcher/Indexer acceptance checks. +- **Visualizer cyclic groups and graph scale**: Detect module and symbol strongly connected components iteratively, including direct recursion and loops longer than five symbols. Preserve distinct symbol IDs and page search, hotspot, relationship, and cyclic-group lists instead of silently capping results. ## [0.38.2] - 2026-10-26 diff --git a/commands/visualize.md b/commands/visualize.md index e06c099a..b35dffe4 100644 --- a/commands/visualize.md +++ b/commands/visualize.md @@ -1,8 +1,8 @@ --- -description: Generate temporal HTML call graph visualization +description: Generate an offline HTML codebase explorer --- -Generate an interactive temporal call graph visualization that starts with recent movement/onboarding context, then supports module overview, symbol exploration, hotspots, and cycles. +Generate a self-contained interactive HTML codebase explorer. Start with the categorized Overview directory, drill into modules, exact files and symbols, and inspect directed caller → focus → callee relationships and original callsites. Use Analyze for snapshot activity, hotspots and cyclic groups. User input: $ARGUMENTS @@ -10,12 +10,12 @@ Parse input for optional parameters: - Plain text → directory filter (e.g., `/visualize src/services`) - `max=N` or "limit N" → sets maxNodes - `orphans` or `include-orphans` → sets includeOrphans=true -- No input → visualize recent code movement plus entire call graph +- No input → explore the entire exported call graph, starting with Overview Call `index_visualize` with parsed parameters. Examples: -- `/visualize` → temporal onboarding view with changes, modules, symbols, hotspots, and cycles +- `/visualize` → Overview by default, module/file/symbol drill-down, and whole-snapshot Changes, Hotspots and Cycles under Analyze - `/visualize src/tools` → only symbols in src/tools/ - `/visualize max=1000` → limit to 1000 nodes - `/visualize src/indexer orphans` → include disconnected nodes @@ -27,4 +27,23 @@ Return the generated file path and open instructions. Terminology: - Modules are path-based code areas, such as `src/tools` or `native`. - Symbols are indexed named code units, such as functions, methods, classes, or Rust modules. -- Cycles are loops where code can eventually call or depend back on itself. +- Cyclic groups contain mutually reachable modules or symbols; a group is not an ordered cycle path. Single-symbol groups indicate direct recursion. + +The generated file works offline without a server or external assets. Open the returned HTML path in a browser. The equivalent CLI is `npm run visualize -- --project /path/to/project`; its directory, max-node, and orphan options are unchanged. + +Interaction: +- Search names, full paths, and kinds; `/` focuses search. Results are paginated, not capped. +- Overview: browse Code, Tests & fixtures, and Supporting files. Category checkboxes affect the directory only, not global search or snapshot totals. +- Open a module for incoming/outgoing module relationships and Files and symbols. Filter within the module, open an exact file disclosure, then select a symbol. File and symbol lists page at 50. +- Symbol focus shows callers, the selected definition and callees. Neighbor lanes page at 12; selecting a neighbor replaces the focus rather than growing a network. Self-calls appear once on the focus card. +- Each neighbor has separate navigation and **Inspect N calls** actions. Inspect retains the focus and lists original callsites in Details. Back/Forward and parent breadcrumbs stay within the artifact; URL fragments restore scope and evidence. +- Analyze opens whole-snapshot Changes, Hotspots or Cycles without losing the underlying scope. **Back to exploration** restores it. Use normal scrolling and browser zoom; narrow layouts provide sticky section jumps. +- On smaller screens, Search and Details open keyboard-accessible dialogs; Escape closes them and restores focus. + +Scope: +- Counts describe the exported resolved-call snapshot, not all dependencies or live state. Input counts precede filtering. A persistent **Partial graph** warning indicates node-limit truncation. +- Modules use existing path-based grouping. Distinct symbol definitions remain individually navigable. +- Directed relationships represent exported calls. Module relationships aggregate cross-module records; internal calls remain available at symbol level. Original callsite lines are distinct from definition lines. Counts across module and symbol levels overlap and are not additive. +- Git activity covers the existing 90-day window and represented indexed files. **Structural context** is call-graph-derived, not recent history. Attention labels are heuristic and file paths are sampled. +- Hotspots rank incoming plus outgoing call-edge records. Missing relationships or cyclic groups do not prove their absence outside this snapshot. +- Copy location copies the definition path and line; source bodies are not embedded. diff --git a/docs/tools.md b/docs/tools.md index df4bd2a5..88fbfb2a 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -137,7 +137,7 @@ OpenCode adds one extra tool beyond the shared MCP and OpenCode set: - `index_visualize` -`index_visualize` generates an interactive HTML view of recent code movement and the call graph. +`index_visualize` generates a self-contained offline HTML codebase explorer. Browse categorized path-based modules, drill into exact files and symbols, and inspect directed call relationships and original callsites. Analyze offers whole-snapshot Changes, Hotspots, and Cycles without losing the underlying exploration scope. OpenCode also exposes slash commands (see [OpenCode slash commands](#opencode-slash-commands)). @@ -317,13 +317,27 @@ Output includes community summaries, hub node rows, and `Community couplings` en Analyzes changed files, affected symbols, transitive dependencies, graph communities, hub nodes, conflicts, and merge risk for a branch or pull request. +Branch materialization uses invocation-unique temporary worktrees. A cross-process lease scoped to the common Git directory protects registration and deregistration, including linked-worktree callers; fetches and indexing callbacks remain concurrent. Cancellation while waiting for registration removes the unused temporary checkout, and unverified cleanup preserves its path rather than deleting potentially registered data. + ## OpenCode-native tools OpenCode registers the portable tools, the three shared knowledge-base tools, and one OpenCode-only tool: - `index_visualize` -`index_visualize` generates an interactive HTML view of recent code movement and the call graph. +`index_visualize` generates a self-contained offline HTML codebase explorer. Open the returned file in a browser; no server, CDN, or external assets are required. The CLI equivalent is `npm run visualize -- --project /path/to/project`. + +Overview is the initial module directory, grouped into **Code**, **Tests & fixtures**, and **Supporting files**. Category checkboxes affect only this directory, not global search or snapshot totals. Cards show exported-symbol, represented-file, and distinct neighboring-module counts. These are path-based groups, not authoritative architecture. + +Open a module for **Incoming modules → selected module → Outgoing modules**, followed by **Files and symbols**. The independent module-local filter matches full file paths, symbol names, and kinds. File disclosures create symbol rows only when opened. File and symbol lists use 50-item pages; focused neighbor lanes use 12-item pages, replacing rather than accumulating rows. Open a symbol for **Caller → focus → callee**, exact definition location, Module navigation, and Copy location. Self-calls appear once on the center card. Module edges represent cross-module calls only. + +Each neighbor has separate navigation and **Inspect N calls** buttons. Inspect keeps the focus and shows navigable endpoint definitions, original caller file/callsite lines, call types, and confidence in Details. Horizontal connectors offer the same action by pointer; the native Inspect buttons remain keyboard-accessible. Narrow focus containers stack center, incoming, and outgoing sections, with sticky **Focus / Callers / Callees** jump controls (or **Incoming / Outgoing / Files** for modules). Use normal scrolling and browser zoom; there is no draggable global network. + +Global search matches names, full paths, and kinds with 50-item pages; `/` focuses it. Typing highlights visible matches without rebuilding the focused scene. Back/Forward and parent breadcrumbs navigate within the artifact. **Analyze** replaces the main surface with whole-snapshot Changes, Hotspots, or Cycles; **Back to exploration** restores the same scope. Search, category choices, filters, disclosure state, pagination, and scoped scroll positions remain document-local; reload restores only the hash-selected scope and evidence. Smaller screens expose Search and Details as keyboard-accessible dialogs; Escape restores their opener. + +Cards display shorter paths anchored to their exported module prefix where possible; full original locations remain in path tooltips, the symbol inspector's **Original definition path** disclosure, and **Copy location**. File-group symbol rows show definition lines without repeating the containing path. The header shows exported totals; **Export details** exposes pre-filter input counts and scope. Single-page lists omit inactive pagination controls; larger lists retain all pages. + +The view describes an exported resolved-call snapshot, not all dependencies or live state. Input counts precede filtering; **Partial graph** means the node limit omitted symbols/relationships. Modules retain path-based grouping. Recent Git activity retains the 90-day/indexed-file scope; call-graph fallback records appear separately as **Structural context**. Attention is heuristic and provided file paths are sampled. Hotspots rank incoming plus outgoing edge records. Cycles show mutually reachable groups (including self-recursion), not ordered paths or a complete-program guarantee. Pi also provides native integration for knowledge bases using `knowledge_base_list`, `knowledge_base_add`, and `knowledge_base_remove` tool names. diff --git a/src/adapters/mcp/cli.ts b/src/adapters/mcp/cli.ts index a76a6796..d20a9f06 100644 --- a/src/adapters/mcp/cli.ts +++ b/src/adapters/mcp/cli.ts @@ -198,9 +198,9 @@ async function handleVisualizeCommand(argv: string[], cwd: string): Promise n.filePath)).size}\n`; result += `Directories: ${new Set(vizData.nodes.map(n => n.directory)).size}`; if (vizData.metadata.truncated) { diff --git a/src/git/branch-materialization.ts b/src/git/branch-materialization.ts index d99c9da2..76986eca 100644 --- a/src/git/branch-materialization.ts +++ b/src/git/branch-materialization.ts @@ -1,7 +1,9 @@ import { promises as fsPromises } from "fs"; import * as os from "os"; import * as path from "path"; +import { setTimeout as delay } from "node:timers/promises"; +import { isIndexLockContentionError, withIndexLock } from "../indexer/index-lock.js"; import { canonicalizePathForComparison } from "../utils/canonical-path.js"; import { throwIfOperationAborted } from "../utils/operation-control.js"; @@ -43,6 +45,39 @@ export { resolveLocalPullRequestRefs, resolveGitCommit, }; + +async function withWorktreeRegistrationLease( + lockDirectory: string, + callback: () => Promise, + signal?: AbortSignal, +): Promise { + const deadline = Date.now() + 30_000; + for (;;) { + throwIfOperationAborted(signal); + // Do not recreate a Git directory removed or renamed while the callback was running. + try { + await fsPromises.mkdir(lockDirectory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + let acquired = false; + try { + return await withIndexLock(lockDirectory, "worktree", async () => { + acquired = true; + throwIfOperationAborted(signal); + return callback(); + }); + } catch (error) { + if (acquired || !isIndexLockContentionError(error) || error.reason === "legacy-lock") throw error; + // Release/republication can hide the owner briefly. Wait, but never reclaim an unknown owner. + if (Date.now() >= deadline) { + throw new Error("Timed out waiting for the Git worktree registration lease.", { cause: error }); + } + } + await delay(25); + } +} + async function pathExists(targetPath: string): Promise { return fsPromises.stat(targetPath).then(() => true, () => false); } @@ -213,8 +248,12 @@ export async function withMaterializedBranch( ); } + const commonDir = await fsPromises.realpath(await runGit(request.projectRoot, + ["rev-parse", "--path-format=absolute", "--git-common-dir"], { signal: request.signal })); + const registrationLockDirectory = path.join(commonDir, "codebase-index-worktrees"); const temporaryRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), "codebase-index-branch-")); - const worktreePath = path.join(temporaryRoot, "worktree"); + // Git derives registration IDs from this basename and can recycle an incomplete same-name peer. + const worktreePath = path.join(temporaryRoot, path.basename(temporaryRoot)); const hooksPath = path.join(temporaryRoot, "hooks"); const info: BranchMaterializationInfo = { branch: request.branch, @@ -225,27 +264,31 @@ export async function withMaterializedBranch( let result: { value: T; info: BranchMaterializationInfo } | undefined; let operationError: unknown; + let registrationAttempted = false; try { throwIfOperationAborted(request.signal); await fsPromises.mkdir(hooksPath); throwIfOperationAborted(request.signal); - await runGit(request.projectRoot, [ - "-c", - `core.hooksPath=${hooksPath}`, - "worktree", - "add", - "--detach", - "--", - worktreePath, - resolved.commit, - ], { signal: request.signal }); - - const materializedHead = await tryResolveCommit(worktreePath, "HEAD", request.signal); - if (materializedHead !== resolved.commit) { - throw new Error( - `Materialized worktree HEAD ${materializedHead ?? "did not resolve"}; expected ${resolved.commit}.`, - ); - } + await withWorktreeRegistrationLease(registrationLockDirectory, async () => { + registrationAttempted = true; + await runGit(request.projectRoot, [ + "-c", + `core.hooksPath=${hooksPath}`, + "worktree", + "add", + "--detach", + "--", + worktreePath, + resolved.commit, + ], { signal: request.signal }); + + const materializedHead = await tryResolveCommit(worktreePath, "HEAD", request.signal); + if (materializedHead !== resolved.commit) { + throw new Error( + `Materialized worktree HEAD ${materializedHead ?? "did not resolve"}; expected ${resolved.commit}.`, + ); + } + }, request.signal); throwIfOperationAborted(request.signal); const value = await callback(worktreePath, info); @@ -257,9 +300,18 @@ export async function withMaterializedBranch( let cleanupError: unknown; try { - await cleanupTemporaryWorktree(request.projectRoot, worktreePath, temporaryRoot); + if (registrationAttempted) { + await withWorktreeRegistrationLease(registrationLockDirectory, + () => cleanupTemporaryWorktree(request.projectRoot, worktreePath, temporaryRoot)); + } else { + await fsPromises.rm(temporaryRoot, { recursive: true, force: true }); + } } catch (error) { - cleanupError = error; + cleanupError = error instanceof AggregateError ? error : new AggregateError( + [asError(error)], + `Could not safely clean up temporary worktree; preserved ${temporaryRoot}`, + { cause: error }, + ); } if (operationError !== undefined && cleanupError !== undefined) { diff --git a/src/indexer/index-lock.ts b/src/indexer/index-lock.ts index e5e27ae9..9b6134f9 100644 --- a/src/indexer/index-lock.ts +++ b/src/indexer/index-lock.ts @@ -20,7 +20,8 @@ export type IndexMutationOperation = | "clear" | "health-check" | "retry-failed-batches" - | "recovery"; + | "recovery" + | "worktree"; export interface IndexLockOwner { pid: number; @@ -81,6 +82,7 @@ const VALID_OPERATIONS = new Set([ "health-check", "retry-failed-batches", "recovery", + "worktree", ]); let temporaryCounter = 0; diff --git a/src/tools/visualize/client.ts b/src/tools/visualize/client.ts new file mode 100644 index 00000000..d886b29f --- /dev/null +++ b/src/tools/visualize/client.ts @@ -0,0 +1,888 @@ +export const VISUALIZATION_SCRIPT: string = String.raw` +(() => { + "use strict"; + const { data, analysis } = JSON.parse(document.getElementById("visualization-data").textContent); + const $ = (id) => document.getElementById(id); + const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0; + const nodeOrder = (a, b) => compare(a.name, b.name) || compare(a.filePath, b.filePath) || a.line - b.line || compare(a.id, b.id); + const nodes = new Map(data.nodes.map((node) => [node.id, node])); + const modules = new Map(data.modules.map((item) => [item.id, item])); + const changes = new Map((data.changes || []).map((item) => [item.id, item])); + const incoming = new Map(), outgoing = new Map(), members = new Map(), filesByModule = new Map(); + const moduleIncoming = new Map(), moduleOutgoing = new Map(), pairs = new Map(); + function append(map, key, value) { if (!map.has(key)) + map.set(key, []); map.get(key).push(value); } + for (const node of nodes.values()) { + append(members, node.moduleId, node); + if (!filesByModule.has(node.moduleId)) filesByModule.set(node.moduleId, new Map()); + append(filesByModule.get(node.moduleId), node.filePath, node); + } + for (const list of members.values()) list.sort(nodeOrder); + for (const files of filesByModule.values()) for (const list of files.values()) list.sort(nodeOrder); + for (const edge of data.edges) { + const source = nodes.get(edge.source), target = nodes.get(edge.target); + if (!source || !target) + continue; + append(outgoing, edge.source, edge); + append(incoming, edge.target, edge); + if (source.moduleId === target.moduleId || !modules.has(source.moduleId) || !modules.has(target.moduleId)) continue; + if (!pairs.has(source.moduleId)) + pairs.set(source.moduleId, new Map()); + append(pairs.get(source.moduleId), target.moduleId, edge); + } + for (const edge of data.moduleEdges) { + if (!modules.has(edge.source) || !modules.has(edge.target)) + continue; + append(moduleOutgoing, edge.source, edge); + append(moduleIncoming, edge.target, edge); + } + const sortedNodes = [...nodes.values()].sort(nodeOrder); + const sortedModules = [...modules.values()].sort((a, b) => compare(a.label, b.label) || compare(a.pathPrefix, b.pathPrefix) || compare(a.id, b.id)); + const hotspots = sortedNodes.map((node) => ({ node, inCount: (incoming.get(node.id) || []).length, outCount: (outgoing.get(node.id) || []).length })) + .sort((a, b) => (b.inCount + b.outCount) - (a.inCount + a.outCount) || nodeOrder(a.node, b.node)); + const searchIndex = [ + ...sortedModules.map((item) => ({ type: "module", id: item.id, name: item.label, path: item.pathPrefix, text: [item.label, item.pathPrefix, item.category].join(" ") })), + ...sortedNodes.map((item) => ({ type: "symbol", id: item.id, name: item.name, path: item.filePath, text: [item.name, item.filePath, item.kind].join(" ") })), + ...[...changes.values()].map((item) => ({ type: "change", id: item.id, name: item.title, path: item.source, text: [item.title, item.summary, item.source, ...item.filePaths].join(" ") })) + ].map((item) => ({ ...item, lowerName: item.name.toLowerCase(), haystack: item.text.toLowerCase() })); + const pages = new Map(), pageActions = new Map(); + const analyses = ["changes", "hotspots", "cycles"]; + const scrollPositions = new Map(), openFiles = new Set(), moduleFilters = new Map(); + let route, query = "", resultType = "all", searchPage = 0, restoreFrame = 0; + let activeDialog = null, dialogOpener = null, focusObserver = null; + function selection() { + return route.analysis === "changes" ? { type: "change", id: route.change } : { type: route.view, id: route.id }; + } + function revealOwner(node) { + const files = [...(filesByModule.get(node.moduleId) || [])].sort(([a], [b]) => compare(a, b)); + moduleFilters.delete(node.moduleId); + pages.set("files:" + node.moduleId, Math.floor(files.findIndex(([path]) => path === node.filePath) / 50)); + const key = JSON.stringify([node.moduleId, node.filePath]); + openFiles.add(key); + pages.set("symbols:" + key, Math.floor((filesByModule.get(node.moduleId)?.get(node.filePath) || []).findIndex((item) => item.id === node.id) / 50)); + } + function selectEntity(type, id) { + if (type === "change") navigate({ view: route.view, id: route.id, analysis: "changes", change: id }); + else { + if (type === "module" && route.view === "symbol") { + const node = nodes.get(route.id); + if (node?.moduleId === id) revealOwner(node); + } + navigate({ view: type, id }); + } + } + function el(tag, className, text) { + const item = document.createElement(tag); + if (className) + item.className = className; + if (text !== undefined) + item.textContent = String(text); + return item; + } + function button(text, action) { + const item = el("button", "", text); + item.type = "button"; + if (action) + item.dataset.action = action; + return item; + } + function entityButton(type, id, title, subtitle) { + const item = button(""); + item.className = "card"; + item.dataset.entity = type; + item.dataset.id = id; + item.append(el("strong", "card-title", title)); + if (subtitle) + item.append(el("span", "meta", subtitle)); + const selected = selection(); + if (selected.type === type && selected.id === id) + item.setAttribute("aria-current", "true"); + return item; + } + function displayPath(filePath, moduleId) { + const normalized = filePath.replace(/\\/g, "/"), prefix = modules.get(moduleId)?.pathPrefix.replace(/\\/g, "/"); + if (!prefix || normalized === prefix || normalized.startsWith(prefix + "/")) return filePath; + const index = normalized.indexOf("/" + prefix + "/"); + if (index >= 0) return normalized.slice(index + 1); + if (normalized.endsWith("/" + prefix)) return prefix; + return filePath; + } + function pathLabel(filePath, moduleId, line) { + const suffix = line === undefined ? "" : ":" + line; + const label = el("span", "location mono", displayPath(filePath, moduleId) + suffix); + label.title = filePath + suffix; + return label; + } + function readableKind(kind) { return kind.replace(/_(declaration|definition)$/, "").replace(/_/g, " "); } + function symbolButton(node, extra) { + const item = entityButton("symbol", node.id, node.name); + const kind = el("span", "kind-label", readableKind(node.kind)); + kind.title = node.kind; + item.append(kind, pathLabel(node.filePath, node.moduleId, node.line)); + if (extra) item.append(el("span", "meta", extra)); + item.setAttribute("aria-label", node.name + ", " + node.kind + ", " + node.filePath + ":" + node.line + (extra ? ", " + extra : "")); + return item; + } + function moduleButton(item) { + const link = entityButton("module", item.id, item.label); + link.append(pathLabel(item.pathPrefix, item.id)); + return link; + } + function originalLocation(node) { + const disclosure = el("details", "original-location"); + disclosure.append(el("summary", "", "Original definition path"), el("p", "mono wrap", node.filePath + ":" + node.line)); + return disclosure; + } + function empty(text) { return el("div", "empty", text); } + function heading(text) { return el("h3", "group-heading", text); } + function kv(label, value) { + const row = el("div", "kv"); + row.append(el("dt", "", label), el("dd", "", value)); + return row; + } + function facts(entries) { const list = el("dl"); for (const [label, value] of entries) + list.append(kv(label, value)); return list; } + function paged(items, key, renderItem, emptyText, pageSize = 50) { + const root = el("div", "stack"); + function paint() { + const count = Math.max(1, Math.ceil(items.length / pageSize)); + const page = Math.min(Math.max(0, pages.get(key) || 0), count - 1); + pages.set(key, page); + const list = el("div", "list"); + list.dataset.list = key; + for (const item of items.slice(page * pageSize, (page + 1) * pageSize)) + list.append(renderItem(item)); + if (!items.length) + list.append(empty(emptyText || "No matches in this snapshot.")); + const bar = el("div", "pager"); + const previous = button("Previous", "page"), next = button("Next", "page"); + previous.dataset.page = next.dataset.page = key; + previous.dataset.delta = "-1"; + next.dataset.delta = "1"; + previous.disabled = page === 0; + next.disabled = page === count - 1; + bar.append(previous, el("span", "", (items.length ? page * pageSize + 1 : 0) + "–" + Math.min(items.length, (page + 1) * pageSize) + " of " + items.length), next); + bar.hidden = count === 1; + root.replaceChildren(list, bar); + } + pageActions.set(key, (delta) => { + const focused = document.activeElement; + pages.set(key, (pages.get(key) || 0) + delta); + paint(); + if (focused && focused.dataset.page === key) { + const controls = [...root.querySelectorAll("[data-action=page]")]; + (controls.find((item) => item.dataset.delta === String(delta) && !item.disabled) || controls.find((item) => !item.disabled))?.focus(); + } + updateDirectoryCount(); + updateSearchHighlights(); + scheduleFocusEdges(); + }); + paint(); + return root; + } + function relationRecords(value) { + return value.view === "module" ? pairs.get(value.from)?.get(value.to) || [] : + (outgoing.get(value.from) || []).filter((edge) => edge.target === value.to); + } + function normalize(hash) { + const params = new URLSearchParams(hash.replace(/^#/, "")); + const view = params.get("view"), id = params.get("id"); + const primary = view === "module" && modules.has(id) || view === "symbol" && nodes.has(id); + const value = primary ? { view, id } : { view: "overview" }; + if (analyses.includes(params.get("analysis"))) { + value.analysis = params.get("analysis"); + if (value.analysis === "changes") { + const change = params.get("change"); + if (changes.size) value.change = changes.has(change) ? change : changes.keys().next().value; + } + } else if (primary) { + const from = params.get("from"), to = params.get("to"), index = view === "module" ? modules : nodes; + const aggregate = view === "module" && (moduleOutgoing.get(from) || []).some((edge) => edge.target === to); + if (index.has(from) && index.has(to) && (from === id || to === id) && + (aggregate || relationRecords({ view, from, to }).length)) Object.assign(value, { from, to }); + } + return value; + } + function fragment(value) { + const params = new URLSearchParams({ view: value.view }); + for (const key of ["id", "analysis", "change", "from", "to"]) if (value[key]) params.set(key, value[key]); + return "#" + params; + } + const historyKey = "explorer-" + Math.random().toString(36).slice(2); + let entry = 0, lastEntry = 0; + function historyState() { return { explorer: historyKey, entry }; } + function historyControls() { $("history-back").disabled = entry <= 0; $("history-forward").disabled = entry >= lastEntry; } + function scopeKey() { return JSON.stringify([route.view, route.id || "", route.analysis || ""]); } + function saveScroll() { + if (!route) return; + scrollPositions.set(scopeKey(), { main: $("main").scrollTop, + lanes: [...document.querySelectorAll(".lane")].map((lane) => [lane.dataset.direction, lane.scrollTop]) }); + } + function restoreScroll() { + cancelAnimationFrame(restoreFrame); + const saved = scrollPositions.get(scopeKey()); + restoreFrame = requestAnimationFrame(() => { + $("main").scrollTop = saved?.main || 0; + for (const lane of document.querySelectorAll(".lane")) lane.scrollTop = saved?.lanes.find(([direction]) => direction === lane.dataset.direction)?.[1] || 0; + scheduleFocusEdges(); + }); + } + function navigate(value, focus = "main") { + const next = normalize(fragment(value)); + saveScroll(); + if (fragment(next) !== fragment(route)) { + entry++; + lastEntry = entry; + history.pushState(historyState(), "", fragment(next)); + } + route = next; + closeDialog(); + render(); + if (focus === "inspector") { + if (innerWidth < 1280) openDialog("inspector", $("open-details")); + else $("evidence-title")?.focus({ preventScroll: true }); + } else $("main").focus({ preventScroll: true }); + } + function restoreRoute() { + saveScroll(); + const state = history.state; + if (state?.explorer === historyKey) entry = state.entry; + else { entry++; lastEntry = entry; } + route = normalize(location.hash); + history.replaceState(historyState(), "", fragment(route)); + closeDialog(); + render(); + } + function openDialog(id, opener) { + closeDialog(); + const pane = $(id); + activeDialog = pane; + dialogOpener = opener; + pane.hidden = false; + pane.setAttribute("role", "dialog"); + pane.setAttribute("aria-modal", "true"); + pane.setAttribute("aria-labelledby", id === "navigator" ? "navigator-title" : "inspector-title"); + for (const region of [document.querySelector(".header"), $("main"), $(id === "navigator" ? "inspector" : "navigator")]) + region.inert = true; + (id === "navigator" ? $("search") : pane.querySelector("button, input, summary"))?.focus(); + } + function closeDialog() { + if (!activeDialog) + return; + activeDialog.removeAttribute("role"); + activeDialog.removeAttribute("aria-modal"); + activeDialog.removeAttribute("aria-labelledby"); + for (const region of [document.querySelector(".header"), $("main"), $("navigator"), $("inspector")]) + region.inert = false; + activeDialog = null; + dialogOpener?.focus(); + dialogOpener = null; + } + function renderSearch() { + const q = query.trim().toLowerCase(); + const priority = { module: 0, symbol: 1, change: 2 }; + const matched = searchIndex.filter((item) => (resultType === "all" ? q || item.type === "module" : item.type === resultType) && (!q || item.haystack.includes(q))) + .map((item) => ({ item, rank: item.lowerName === q ? 0 : item.lowerName.startsWith(q) ? 1 : 2 })) + .sort((a, b) => a.rank - b.rank || priority[a.item.type] - priority[b.item.type] || compare(a.item.name, b.item.name) || compare(a.item.path, b.item.path) || compare(a.item.id, b.item.id)); + const count = Math.max(1, Math.ceil(matched.length / 50)); + searchPage = Math.min(searchPage, count - 1); + $("result-count").textContent = matched.length + (!q && resultType === "all" ? " modules in this export" : " results"); + const items = matched.slice(searchPage * 50, (searchPage + 1) * 50).map(({ item }) => { + if (item.type === "module") return moduleButton(modules.get(item.id)); + if (item.type === "symbol") return symbolButton(nodes.get(item.id)); + return entityButton("change", item.id, item.name, item.path); + }); + $("results").replaceChildren(...(items.length ? items : [empty("No matches. Try a full path, name, or kind.")])); + const bar = el("div", "pager"), prev = button("Previous", "search-prev"), next = button("Next", "search-next"); + prev.disabled = searchPage === 0; + next.disabled = searchPage === count - 1; + bar.append(prev, el("span", "", (searchPage + 1) + "/" + count), next); + bar.hidden = count === 1; + $("search-pages").replaceChildren(bar); + updateSearchHighlights(); + } + function updateSearchHighlights() { + const q = query.trim().toLowerCase(); + for (const card of $("surface").querySelectorAll("[data-entity], [data-file-path]")) { + let text = card.dataset.filePath || ""; + const item = card.dataset.entity === "module" ? modules.get(card.dataset.id) : nodes.get(card.dataset.id); + if (item) text = card.dataset.entity === "module" ? [item.label, item.pathPrefix, item.category].join(" ") : [item.name, item.filePath, item.kind].join(" "); + card.classList.toggle("search-match", Boolean(q && text.toLowerCase().includes(q))); + } + } + function svg(tag, attributes, text) { + const item = document.createElementNS("http://www.w3.org/2000/svg", tag); + for (const [key, value] of Object.entries(attributes || {})) + item.setAttribute(key, String(value)); + if (text !== undefined) + item.textContent = text; + return item; + } + const categoryBuckets = [ + ["Code", ["source", "native"]], + ["Tests & fixtures", ["test", "fixture"]], + ["Supporting files", ["command", "script", "doc", "benchmark", "other"]] + ]; + const activeCategories = new Set(categoryBuckets.map(([label]) => label)); + function moduleCounts(item) { + return { + symbols: (members.get(item.id) || []).length, + files: filesByModule.get(item.id)?.size || 0, + incoming: new Set((moduleIncoming.get(item.id) || []).map((edge) => edge.source)).size, + outgoing: new Set((moduleOutgoing.get(item.id) || []).map((edge) => edge.target)).size + }; + } + function metric(label, value) { + const item = el("span", "metric"); + item.append(el("strong", "", value.toLocaleString("en")), document.createTextNode(" "), el("span", "", label)); + return item; + } + function moduleStats(item) { + const counts = moduleCounts(item), stats = el("span", "card-stats"); + stats.append(metric("symbols", counts.symbols), metric("files", counts.files)); + return stats; + } + function updateDirectoryCount() { + const count = $("overview-count"); + if (count) count.textContent = $("surface").querySelectorAll("[data-entity=module]").length + " of " + count.dataset.total + " exported modules shown"; + } + function renderOverview() { + if (!modules.size) { + $("surface").append(empty("No exported modules in this snapshot. No symbols or relationships have been invented.")); + return; + } + const root = el("div", "stack directory"), controls = el("div", "row category-filters"); + const groups = categoryBuckets.map(([label, categories]) => ({ label, items: sortedModules.filter((item) => categories.includes(item.category)) })); + for (const { label, items } of groups) { + const control = el("label", "category-control"), input = el("input"); + input.type = "checkbox"; + input.checked = activeCategories.has(label); + input.disabled = !items.length; + input.addEventListener("change", () => { + if (input.checked) activeCategories.add(label); else activeCategories.delete(label); + paint(); + }); + control.append(input, document.createTextNode(label), el("span", "category-count", items.length)); + controls.append(control); + } + const content = el("div", "stack"); + function paint() { + const enabled = groups.filter(({ label, items }) => activeCategories.has(label) && items.length); + content.replaceChildren(); + const count = el("p", "meta"); + count.id = "overview-count"; + count.dataset.total = enabled.reduce((sum, { items }) => sum + items.length, 0); + content.append(count); + if (!enabled.length) { + content.append(empty("No categories selected")); + const show = button("Show all categories"); + show.addEventListener("click", () => { + for (const { label } of groups) activeCategories.add(label); + for (const input of controls.querySelectorAll("input")) input.checked = true; + paint(); + }); + content.append(show); + } + for (const { label, items } of enabled) { + const section = el("section", "overview-section"); + const title = el("div", "section-heading"); + title.append(heading(label), el("span", "count-badge", items.length)); + section.append(title, paged(items, "overview:" + label, (item) => { + const card = moduleButton(item), counts = moduleCounts(item); + card.classList.add("directory-card"); + card.append(el("span", "badge category-badge", item.category), moduleStats(item), + el("span", "card-footer", counts.incoming + " incoming · " + counts.outgoing + " outgoing modules")); + return card; + })); + content.append(section); + } + updateDirectoryCount(); + updateSearchHighlights(); + } + root.append(controls, el("p", "scope-note", "Path-based groups · Counts describe the exported slice. Category filters apply only to this directory."), content); + $("surface").append(root); + paint(); + } + function edgeRecord(edge) { + const source = nodes.get(edge.source), target = nodes.get(edge.target); + const row = el("div", "edge-record"); + const meta = el("div", "row callsite-meta"); + meta.append(el("span", "eyebrow", "Callsite"), el("span", "badge", edge.callType), el("span", "meta", edge.confidence)); + row.append(meta, pathLabel(source.filePath, source.moduleId, edge.line), + symbolButton(source, "Caller definition"), symbolButton(target, "Callee definition")); + return row; + } + function groupedNeighbors(records, field) { + const groups = new Map(); + for (const edge of records) + append(groups, edge[field], edge); + return [...groups].map(([id, edges]) => ({ node: nodes.get(id), edges })).sort((a, b) => b.edges.length - a.edges.length || nodeOrder(a.node, b.node)); + } + function filteredFiles(id) { + const q = (moduleFilters.get(id) || "").trim().toLowerCase(); + return [...(filesByModule.get(id) || [])].sort(([a], [b]) => compare(a, b)).map(([path, definitions]) => { + const matched = !q || path.toLowerCase().includes(q) ? definitions : + definitions.filter((node) => (node.name + " " + node.kind).toLowerCase().includes(q)); + return { path, definitions, matched }; + }).filter(({ matched }) => matched.length); + } + function renderModule() { + const id = route.id; + renderFocusGraph("module", id); + const section = el("section", "section files-section stack"), title = heading("Files and symbols"); + title.id = "files-title"; + title.tabIndex = -1; + const label = el("label", "", "Filter files or symbols"), input = el("input"), clear = button("Clear filter"); + input.type = "search"; + input.id = "module-filter"; + input.value = moduleFilters.get(id) || ""; + label.htmlFor = input.id; + const content = el("div", "stack"); + function paint() { + content.replaceChildren(paged(filteredFiles(id), "files:" + id, ({ path, definitions, matched }) => { + const key = JSON.stringify([id, path]), disclosure = el("details", "file-group"), summary = el("summary"); + summary.dataset.filePath = path; + summary.append(pathLabel(path, id), el("span", "meta file-count", matched.length === definitions.length ? + definitions.length + (definitions.length === 1 ? " symbol" : " symbols") : matched.length + " of " + definitions.length + " symbols")); + disclosure.append(summary); + let materialized = false; + function populate() { + if (!materialized) { + disclosure.append(paged(matched, "symbols:" + key, (node) => { + const link = symbolButton(node); + link.querySelector(".location").textContent = "Line " + node.line; + return link; + })); + materialized = true; + updateSearchHighlights(); + } + } + disclosure.addEventListener("toggle", () => { + if (disclosure.open) { openFiles.add(key); populate(); } else openFiles.delete(key); + scheduleFocusEdges(); + }); + if (openFiles.has(key)) { disclosure.open = true; populate(); } + return disclosure; + }, (members.get(id) || []).length ? "No files or symbols match this module-local filter." : "No exported symbols in this module.")); + updateSearchHighlights(); + } + function filter(value) { + moduleFilters.set(id, value); + pages.delete("files:" + id); + for (const path of filesByModule.get(id)?.keys() || []) pages.delete("symbols:" + JSON.stringify([id, path])); + paint(); + } + input.addEventListener("input", () => filter(input.value)); + clear.addEventListener("click", () => { input.value = ""; filter(""); input.focus(); }); + const filters = el("div", "local-filter"); + filters.append(label, input, clear); + section.append(title, filters, content); + $("surface").append(section); + paint(); + } + function renderModuleDetails(item, details) { + details.append(heading(item.label), el("p", "mono wrap", item.pathPrefix), + facts([["Category", item.category], ["Exported symbols", (members.get(item.id) || []).length], ["Represented files", filesByModule.get(item.id)?.size || 0]]), + heading("Symbol kinds"), facts(Object.entries(item.kinds).sort(([a], [b]) => compare(a, b)))); + } + function renderSymbolDetails(node, details) { + const ins = incoming.get(node.id) || [], outs = outgoing.get(node.id) || []; + details.append(heading(node.name), pathLabel(node.filePath, node.moduleId, node.line), + facts([["Kind", readableKind(node.kind)], ["Definition line", node.line]]), + heading("Call summary"), facts([ + ["Callers", new Set(ins.map((edge) => edge.source)).size], + ["Callees", new Set(outs.map((edge) => edge.target)).size], + ["Incoming calls", ins.length], ["Outgoing calls", outs.length]]), originalLocation(node)); + const module = modules.get(node.moduleId); + if (module) + details.append(heading("Module"), moduleButton(module)); + details.append(el("p", "scope-note", "Totals include self-calls. Source bodies are not included in this export.")); + } + function changeDetails(item, details) { + const structural = item.source === "call graph"; + details.append(heading(item.title), el("p", "wrap", item.summary), facts([["Source", structural ? "Structural context (call graph)" : item.source], + ["When", structural ? "Snapshot structure, not recent activity" : item.when], + ["Intent", item.intent], ["Attention (heuristic)", item.risk], + ["Represented call edges", item.calls], + ["Additions/deletions in represented indexed files", item.churn]]), el("p", "wrap", structural ? "No matching recent Git activity was included in this snapshot. This record describes call-graph structure, not recent changes." : item.why)); + const module = modules.get(item.moduleId), node = nodes.get(item.focusNodeId); + if (module) + details.append(heading("Related module"), moduleButton(module)); + if (node) + details.append(heading("Related symbol"), symbolButton(node)); + details.append(heading("Provided file paths"), el("p", "meta", "Sampled paths supplied by this activity record; not an exhaustive changed-file list.")); + for (const path of item.filePaths) + details.append(el("p", "mono wrap meta", path)); + } + function inspectRelationship(from, to) { + navigate({ view: route.view, id: route.id, from, to }, "inspector"); + } + function relationshipDetails(details) { + const isModule = route.view === "module", index = isModule ? modules : nodes; + const source = index.get(route.from), target = index.get(route.to), records = relationRecords(route); + const title = heading("Call relationship"); + title.id = "evidence-title"; + title.tabIndex = -1; + details.append(title); + const endpoints = el("div", "relationship-endpoints stack"); + for (const [label, item] of [["Caller", source], ["Callee", target]]) { + endpoints.append(el("span", "eyebrow", label), isModule ? moduleButton(item) : symbolButton(item)); + } + details.append(endpoints, facts([["Original call records", records.length]])); + for (const [field, label] of [["callType", "Call types"], ["confidence", "Confidence"]]) { + const counts = new Map(); + for (const record of records) counts.set(record[field], (counts.get(record[field]) || 0) + 1); + details.append(heading(label), facts([...counts].sort(([a], [b]) => compare(a, b)))); + } + details.append(paged(records, JSON.stringify(["evidence", route.view, route.from, route.to]), edgeRecord, "No contributing callsite records in this export"), + button("Back to " + route.view + " details", "clear-evidence")); + } + let inspectorSelection = ""; + function renderDetails() { + const details = $("details"), selected = selection(); + const key = JSON.stringify([selected.type, selected.id, route.from, route.to]); + if (inspectorSelection !== key) $("inspector").scrollTop = 0; + inspectorSelection = key; + if (route.from && route.to) relationshipDetails(details); + else if (selected.type === "module") renderModuleDetails(modules.get(selected.id), details); + else if (selected.type === "symbol") renderSymbolDetails(nodes.get(selected.id), details); + else if (selected.type === "change" && selected.id) changeDetails(changes.get(selected.id), details); + else details.append(empty("Select a module or symbol to inspect the exported snapshot. Source bodies are not included.")); + } + let focusFrame = 0; + function scheduleFocusEdges() { + if (!focusFrame) focusFrame = requestAnimationFrame(() => { focusFrame = 0; drawFocusEdges(); }); + } + function drawFocusEdges() { + const flow = document.querySelector(".flow"), root = document.querySelector(".focus-edges"), center = $("focus-node"); + if (!flow || !root || !center) return; + flow.classList.toggle("stacked", flow.clientWidth < 720); + root.replaceChildren(); + if (flow.classList.contains("stacked")) return; + const box = flow.getBoundingClientRect(), middle = center.getBoundingClientRect(); + root.setAttribute("viewBox", "0 0 " + box.width + " " + box.height); + const defs = svg("defs"), marker = svg("marker", { id: "focus-arrow", viewBox: "0 0 10 10", refX: 10, refY: 5, markerWidth: 6, markerHeight: 6, orient: "auto" }); + marker.append(svg("path", { d: "M0 0L10 5L0 10Z", fill: "var(--accent)" })); + defs.append(marker); + root.append(defs); + for (const lane of flow.querySelectorAll(".lane")) { + const laneBox = lane.getBoundingClientRect(); + for (const card of lane.querySelectorAll(".neighbor-link")) { + const rect = card.getBoundingClientRect(); + if (rect.bottom <= Math.max(0, laneBox.top) || rect.top >= Math.min(innerHeight, laneBox.bottom)) continue; + const isIncoming = lane.dataset.direction === "incoming"; + const y = Math.max(laneBox.top, Math.min(laneBox.bottom, rect.top + rect.height / 2)) - box.top; + const x1 = (isIncoming ? rect.right : middle.right) - box.left; + const x2 = (isIncoming ? middle.left : rect.left) - box.left; + const y1 = isIncoming ? y : middle.top + middle.height / 2 - box.top; + const y2 = isIncoming ? middle.top + middle.height / 2 - box.top : y; + const mid = (x1 + x2) / 2, from = card.dataset.from, to = card.dataset.to; + const d = "M" + x1 + "," + y1 + " C" + mid + "," + y1 + " " + mid + "," + y2 + " " + x2 + "," + y2; + const selected = route.from === from && route.to === to; + const line = svg("path", { d, class: "connection" + (selected ? " selected" : ""), "marker-end": "url(#focus-arrow)" }); + const hit = svg("path", { d, class: "connection-hit", "data-from": from, "data-to": to, "data-action": "inspect" }); + root.append(line, hit); + } + } + } + function inspectButton(from, to, count) { + const control = button("Inspect " + count + (count === 1 ? " call" : " calls"), "inspect"); + control.className = "inspect-control"; + control.dataset.from = from; + control.dataset.to = to; + control.classList.toggle("selected", route.from === from && route.to === to); + control.setAttribute("aria-label", control.textContent + ": " + (route.view === "module" ? modules.get(from)?.pathPrefix : nodes.get(from)?.name) + " → " + (route.view === "module" ? modules.get(to)?.pathPrefix : nodes.get(to)?.name)); + return control; + } + function renderFocusGraph(type, id) { + const isModule = type === "module", item = (isModule ? modules : nodes).get(id); + const scene = el("section", "focus-scene"), flow = el("div", "flow"), jumps = el("nav", "focus-jumps"); + jumps.setAttribute("aria-label", "Jump within " + type); + function jump(label, target) { + const control = button(label, "jump-section"); + control.dataset.target = target; + jumps.append(control); + } + jump("Focus", "focus-node"); + flow.append(svg("svg", { class: "focus-edges", "aria-hidden": "true" })); + function lane(direction) { + const isIncoming = direction === "incoming", section = el("section", "lane"), field = isIncoming ? "source" : "target"; + section.dataset.direction = direction; + section.id = direction + "-lane"; + section.tabIndex = -1; + let neighbors; + if (isModule) { + const grouped = new Map(); + for (const edge of (isIncoming ? moduleIncoming : moduleOutgoing).get(id) || []) { + if (edge[field] !== id) grouped.set(edge[field], (grouped.get(edge[field]) || 0) + edge.weight); + } + neighbors = [...grouped].map(([other, count]) => ({ node: modules.get(other), count })).sort((a, b) => b.count - a.count || + compare(a.node.label, b.node.label) || compare(a.node.pathPrefix, b.node.pathPrefix) || compare(a.node.id, b.node.id)); + } else neighbors = groupedNeighbors((isIncoming ? incoming : outgoing).get(id) || [], field) + .filter(({ node }) => node.id !== id).map(({ node, edges }) => ({ node, count: edges.length })); + const label = isModule ? (isIncoming ? "Incoming modules" : "Outgoing modules") : (isIncoming ? "Callers" : "Callees"); + jump((isModule ? (isIncoming ? "Incoming" : "Outgoing") : label) + " " + neighbors.length, section.id); + const title = el("div", "lane-heading"); + title.append(heading((isIncoming ? "" : "→ ") + label + (isIncoming ? " →" : "")), el("span", "count-badge", neighbors.length)); + const callCount = neighbors.reduce((sum, neighbor) => sum + neighbor.count, 0); + section.append(title, el("p", "lane-summary", callCount.toLocaleString("en") + " contributing " + (callCount === 1 ? "call" : "calls"))); + section.append(paged(neighbors, JSON.stringify([type, id, direction]), ({ node, count }) => { + const row = el("div", "neighbor-row"), link = isModule ? moduleButton(node) : symbolButton(node); + const from = isIncoming ? node.id : id, to = isIncoming ? id : node.id; + link.classList.add("neighbor-link"); + link.dataset.from = from; + link.dataset.to = to; + row.append(link, inspectButton(from, to, count)); + return row; + }, "No resolved " + direction + " neighbors in this exported snapshot.", 12)); + section.addEventListener("scroll", scheduleFocusEdges, { passive: true }); + return section; + } + const center = el("section", "focus-lane"), card = el("div", "focus-card stack"); + card.id = "focus-node"; + card.tabIndex = -1; + card.append(el("span", "eyebrow", "Selected " + type), heading(isModule ? item.label : item.name), + pathLabel(isModule ? item.pathPrefix : item.filePath, isModule ? id : item.moduleId, isModule ? undefined : item.line)); + if (isModule) { + card.append(el("span", "badge", item.category), moduleStats(item), button("Browse files", "browse-files")); + } else { + const kind = el("span", "kind-label", readableKind(item.kind)); + kind.title = item.kind; + card.append(kind); + const counts = el("span", "card-stats"); + counts.append(metric("callers", new Set((incoming.get(id) || []).map((edge) => edge.source).filter((other) => other !== id)).size), + metric("callees", new Set((outgoing.get(id) || []).map((edge) => edge.target).filter((other) => other !== id)).size)); + card.append(counts); + const self = (outgoing.get(id) || []).filter((edge) => edge.target === id); + if (self.length) { + const row = el("div", "self-calls"); + row.append(el("span", "meta", "Self-calls (" + self.length + ")"), inspectButton(id, id, self.length)); + card.append(row); + } + const actions = el("div", "focus-actions"); + if (modules.has(item.moduleId)) { + const owner = button("Module: " + modules.get(item.moduleId).label); + owner.dataset.entity = "module"; + owner.dataset.id = item.moduleId; + actions.append(owner); + } + actions.append(button("Copy location", "copy-location")); + const status = el("div", "stack"); + status.id = "copy-status"; + status.setAttribute("role", "status"); + card.append(actions, status, el("p", "scope-note", "Definition metadata only. No source bodies.")); + } + center.append(card); + flow.append(lane("incoming"), center, lane("outgoing")); + if (isModule) jump("Files", "files-title"); + scene.append(jumps, el("p", "scope-note scene-note", isModule ? + "Cross-module calls only. Browse files for internal symbol relationships." : "Caller → selected symbol → callee. Lane counts exclude self-calls."), + flow); + $("surface").append(scene); + focusObserver = new ResizeObserver(scheduleFocusEdges); + focusObserver.observe(flow); + for (const section of flow.querySelectorAll(".lane, .focus-card")) focusObserver.observe(section); + scheduleFocusEdges(); + } + function renderChanges() { + const root = el("div", "view-list"); + if (!changes.size) + root.append(empty("No activity records were included in this snapshot.")); + for (const structural of [false, true]) { + const records = [...changes.values()].filter((item) => (item.source === "call graph") === structural); + if (!records.length) + continue; + root.append(heading(structural ? "Structural context" : "Recent Git activity"), el("p", "meta", structural ? "Call-graph context, not recent history. No matching recent Git activity was included for these records." : "90-day history scope, limited to represented indexed files.")); + root.append(paged(records, structural ? "structural-changes" : "git-changes", (item) => entityButton("change", item.id, item.title, (structural ? "Call graph" : item.source + " \u00B7 " + item.when) + " \u00B7 Attention (heuristic): " + item.risk + " \u00B7 " + item.summary))); + } + $("surface").append(root); + } + function renderHotspots() { + $("surface").append(paged(hotspots, "hotspots", ({ node, inCount, outCount }) => symbolButton(node, (inCount + outCount) + " score = " + inCount + " incoming + " + outCount + " outgoing call edges"), "No symbols were exported. Hotspot scores require an exported call graph.")); + } + function renderCycles() { + const root = el("div", "view-list"); + root.append(el("p", "meta", "Members of a cyclic group are mutually reachable through resolved calls. This is not an ordered cycle path; a one-member group means a self-edge.")); + for (const [type, groups] of [["module", analysis.moduleCycles], ["symbol", analysis.symbolCycles]]) { + root.append(heading((type === "module" ? "Module" : "Symbol") + " cyclic groups (" + groups.length + ")")); + root.append(paged(groups, type + "-cycles", (group) => { + const details = el("details"); + const first = type === "module" ? modules.get(group[0]).label : nodes.get(group[0]).name; + details.append(el("summary", "", first + " \u00B7 " + group.length + " " + (type === "module" ? "modules" : "symbols") + (group.length === 1 ? " \u00B7 self-recursion" : " \u00B7 mutually reachable"))); + details.addEventListener("toggle", () => { + if (details.open && details.children.length === 1) + details.append(paged(group, type + "-group:" + group[0], (id) => type === "module" ? moduleButton(modules.get(id)) : symbolButton(nodes.get(id)))); + }); + return details; + }, "No " + type + " cyclic groups among resolved edges in this exported graph.")); + } + $("surface").append(root); + } + function renderContent() { + if (route.analysis) { + const bar = el("div", "row analysis-heading"); + bar.append(heading(route.analysis[0].toUpperCase() + route.analysis.slice(1)), el("span", "badge", "Whole exported snapshot"), button("Back to exploration", "explore")); + $("surface").append(bar); + if (route.analysis === "changes") renderChanges(); + else if (route.analysis === "hotspots") renderHotspots(); + else renderCycles(); + } else if (route.view === "module") renderModule(); + else if (route.view === "symbol") renderFocusGraph("symbol", route.id); + else renderOverview(); + } + function render() { + focusObserver?.disconnect(); + cancelAnimationFrame(focusFrame); + focusFrame = 0; + pageActions.clear(); + $("surface").replaceChildren(); + $("details").replaceChildren(); + const crumbs = [], overview = route.view === "overview" ? el("span", "", "Overview") : button("Overview"); + if (route.view !== "overview") overview.dataset.view = "overview"; + crumbs.push(overview); + let title = "Repository overview", description = "Choose a module to explore its files, definitions, and calls."; + if (route.view === "module") { + title = modules.get(route.id).label; + description = modules.get(route.id).pathPrefix; + } else if (route.view === "symbol") { + const node = nodes.get(route.id), owner = modules.get(node.moduleId); + if (owner) { + const parent = button(owner.label); + parent.dataset.entity = "module"; + parent.dataset.id = owner.id; + crumbs.push(el("span", "meta", "/"), parent); + } + title = node.name; + description = displayPath(node.filePath, node.moduleId) + ":" + node.line; + } + if (route.view !== "overview") crumbs.push(el("span", "meta", "/"), el("span", "wrap", title)); + $("breadcrumbs").replaceChildren(...crumbs); + $("view-title").textContent = title; + $("view-description").textContent = description; + $("view-description").title = route.view === "symbol" ? nodes.get(route.id).filePath + ":" + nodes.get(route.id).line : description; + document.querySelector("[data-view=overview]").setAttribute("aria-current", route.view === "overview" && !route.analysis ? "page" : "false"); + $("analyze").value = route.analysis || ""; + renderContent(); + renderDetails(); + renderSearch(); + historyControls(); + restoreScroll(); + $("announcement").textContent = title + (route.analysis ? " · " + route.analysis + " · Whole exported snapshot" : ""); + } + async function copyLocation() { + const node = nodes.get(route.id); + if (!node) + return; + const location = node.filePath + ":" + node.line, status = $("copy-status"); + try { + await navigator.clipboard.writeText(location); + if (status.isConnected) + status.replaceChildren(el("p", "status", "Location copied.")); + } + catch { + if (!status.isConnected) + return; + const label = el("label", "status", "Clipboard unavailable. Select this location and copy it manually."); + const field = el("input", "copy-field mono"); + field.readOnly = true; + field.value = location; + label.append(field); + status.replaceChildren(label); + field.focus(); + field.select(); + } + } + document.addEventListener("click", (event) => { + const target = event.target.closest("button, [data-action], [data-entity], [data-close-dialog]"); + if (!target) + return; + if (target.hasAttribute("data-close-dialog")) { + closeDialog(); + return; + } + if (target.dataset.entity) { + selectEntity(target.dataset.entity, target.dataset.id); + return; + } + if (target.dataset.view) { + navigate({ view: target.dataset.view }); + return; + } + const action = target.dataset.action; + if (action === "page") + pageActions.get(target.dataset.page)?.(Number(target.dataset.delta)); + else if (action === "search-prev" || action === "search-next") { + searchPage += action === "search-next" ? 1 : -1; + renderSearch(); + const next = $("search-pages").querySelector("[data-action=" + action + "]"); + (next.disabled ? $("search-pages").querySelector("button:not(:disabled)") : next)?.focus(); + $("navigator").scrollTop = 0; + } + else if (action === "inspect") inspectRelationship(target.dataset.from, target.dataset.to); + else if (action === "clear-evidence" || action === "explore") navigate({ view: route.view, id: route.id }); + else if (action === "browse-files" || action === "jump-section") { + const section = $(action === "browse-files" ? "files-title" : target.dataset.target); + section?.scrollIntoView({ block: "start" }); + section?.focus({ preventScroll: true }); + } + else if (action === "copy-location") + void copyLocation(); + }); + document.addEventListener("keydown", (event) => { + if (activeDialog && event.key === "Escape") { + event.preventDefault(); + closeDialog(); + return; + } + if (event.key === "Escape" && document.querySelector(".export-info[open]")) { + event.preventDefault(); + const disclosure = document.querySelector(".export-info"); + disclosure.open = false; + disclosure.querySelector("summary").focus(); + return; + } + if (activeDialog && event.key === "Tab") { + const focusable = [...activeDialog.querySelectorAll("button:not(:disabled), input, select, summary, [tabindex='0']")].filter((item) => item.getClientRects().length); + const first = focusable[0], last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last?.focus(); + } + else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first?.focus(); + } + } + if (event.key === "/" && !event.ctrlKey && !event.metaKey && !event.altKey && !event.target.closest("input, textarea, select, [contenteditable=true]")) { + event.preventDefault(); + if (innerWidth < 1024) + openDialog("navigator", $("open-search")); + else + $("search").focus(); + } + }); + $("search").addEventListener("input", (event) => { query = event.target.value; searchPage = 0; renderSearch(); }); + $("search-type").addEventListener("change", (event) => { resultType = event.target.value; searchPage = 0; renderSearch(); }); + $("clear-search").addEventListener("click", () => { query = ""; $("search").value = ""; searchPage = 0; renderSearch(); $("search").focus(); }); + $("analyze").addEventListener("change", (event) => navigate({ view: route.view, id: route.id, analysis: event.target.value })); + $("main").addEventListener("scroll", scheduleFocusEdges, { passive: true }); + $("open-search").addEventListener("click", (event) => openDialog("navigator", event.currentTarget)); + $("open-details").addEventListener("click", (event) => openDialog("inspector", event.currentTarget)); + $("history-back").addEventListener("click", () => { if (entry > 0) + history.back(); }); + $("history-forward").addEventListener("click", () => { if (entry < lastEntry) + history.forward(); }); + addEventListener("popstate", restoreRoute); + addEventListener("hashchange", () => { if (fragment(route) !== location.hash) + restoreRoute(); }); + addEventListener("resize", () => { if (activeDialog && ((activeDialog.id === "navigator" && innerWidth >= 1024) || (activeDialog.id === "inspector" && innerWidth >= 1280))) + closeDialog(); scheduleFocusEdges(); }); + $("partial").hidden = !data.metadata.truncated; + $("snapshot").append(metric("modules", modules.size), metric("symbols", nodes.size), metric("calls", data.edges.length)); + $("export-details").append(facts([["Input symbols", data.metadata.totalSymbols], ["Input call edges", data.metadata.totalEdges], + ["Scope", data.metadata.directory || "All exported directories"]]), el("p", "scope-note", "Offline resolved-call snapshot, not every program dependency or live state.")); + route = normalize(location.hash); + history.replaceState(historyState(), "", fragment(route)); + render(); +})(); +`; diff --git a/src/tools/visualize/graph.ts b/src/tools/visualize/graph.ts new file mode 100644 index 00000000..31a5250c --- /dev/null +++ b/src/tools/visualize/graph.ts @@ -0,0 +1,79 @@ +import type { VisualizationData } from "./types.js"; + +export interface VisualizationGraphAnalysis { + moduleCycles: string[][]; + symbolCycles: string[][]; +} + +interface Components { + groups: string[][]; + outgoing: Map>; +} + +// Iterative Kosaraju traversal: bounded by vertices + distinct edges, including deep chains. +function components(ids: string[], edges: Array<{ source: string; target: string }>): Components { + const ordered = [...new Set(ids)].sort(); + const outgoing = new Map(ordered.map((id) => [id, new Set()])); + const incoming = new Map(ordered.map((id) => [id, new Set()])); + for (const { source, target } of edges) { + if (!outgoing.has(source) || !outgoing.has(target)) continue; + outgoing.get(source)!.add(target); + incoming.get(target)!.add(source); + } + const adjacency = new Map([...outgoing].map(([id, targets]) => [id, [...targets].sort()])); + const visited = new Set(); + const finished: string[] = []; + for (const id of ordered) { + if (visited.has(id)) continue; + visited.add(id); + const stack = [{ id, next: 0 }]; + while (stack.length) { + const frame = stack[stack.length - 1]; + const targets = adjacency.get(frame.id)!; + if (frame.next === targets.length) { + finished.push(frame.id); + stack.pop(); + } else { + const target = targets[frame.next++]; + if (!visited.has(target)) { + visited.add(target); + stack.push({ id: target, next: 0 }); + } + } + } + } + visited.clear(); + const groups: string[][] = []; + for (let i = finished.length - 1; i >= 0; i--) { + const start = finished[i]; + if (visited.has(start)) continue; + const group: string[] = []; + const stack = [start]; + visited.add(start); + while (stack.length) { + const id = stack.pop()!; + group.push(id); + for (const predecessor of incoming.get(id)!) { + if (visited.has(predecessor)) continue; + visited.add(predecessor); + stack.push(predecessor); + } + } + groups.push(group.sort()); + } + groups.sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0); + return { groups, outgoing }; +} + +function cyclicGroups({ groups, outgoing }: Components): string[][] { + return groups.filter((group) => group.length > 1 || outgoing.get(group[0])!.has(group[0])); +} + +export function analyzeVisualizationGraph(data: VisualizationData): VisualizationGraphAnalysis { + const modules = components(data.modules.map((module) => module.id), data.moduleEdges); + const symbols = components(data.nodes.map((node) => node.id), data.edges); + return { + moduleCycles: cyclicGroups(modules), + symbolCycles: cyclicGroups(symbols), + }; +} diff --git a/src/tools/visualize/styles.ts b/src/tools/visualize/styles.ts new file mode 100644 index 00000000..3a1baede --- /dev/null +++ b/src/tools/visualize/styles.ts @@ -0,0 +1,305 @@ +export const VISUALIZATION_STYLES: string = String.raw` +:root { + color-scheme:dark; + --bg:#0c121c; + --surface:#131d2b; + --raised:#192638; + --fg:#edf2fa; + --muted:#a8b8cd; + --border:#2d3e54; + --accent:#b4a2ff; + --accent-soft:#24213d; + --shadow:0 12px 32px #0003; + font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif +} +@media(prefers-color-scheme:light) { + :root { + color-scheme:light; + --bg:#f6f8fc; + --surface:#fff; + --raised:#edf2f9; + --fg:#1b293d; + --muted:#52647c; + --border:#d4deeb; + --accent:#6943ca; + --accent-soft:#f0ebfc; + --shadow:0 12px 32px #263c5914 + } +} +* { box-sizing:border-box; scrollbar-width:thin; scrollbar-color:var(--border) transparent } +[hidden] { display:none!important } +body { margin:0; background:var(--bg); color:var(--fg) } +button,input,select { font:inherit; color:inherit } +button,select,summary { cursor:pointer } +button { + min-height:44px; + padding:8px 12px; + border:1px solid var(--border); + border-radius:8px; + background:var(--surface); + transition:background-color .12s,border-color .12s; + overflow-wrap:anywhere +} +button:hover { border-color:var(--accent); background:var(--raised) } +button:disabled { opacity:.4; cursor:default } +button[aria-current=true],button[aria-current=page],button.selected { + border-color:var(--accent); + background:var(--accent-soft) +} +:focus-visible { outline:2px solid var(--accent); outline-offset:3px } +input,select { + min-width:0; + min-height:44px; + padding:8px 10px; + background:var(--bg); + border:1px solid var(--border); + border-radius:8px +} +input[type=checkbox] { min-height:0; width:16px; height:16px; accent-color:var(--accent); margin:0 } +input::placeholder { color:var(--muted); opacity:.85 } +h1,h2,h3,p,dl { margin:0; overflow-wrap:anywhere } +h1 { font-size:17px; line-height:1.3; letter-spacing:-.025em; font-weight:650 } +h2 { font-size:26px; line-height:1.2; letter-spacing:-.035em; font-weight:650 } +h3 { font-size:15px; line-height:1.4; font-weight:650 } +small,.meta,.scope-note,.lane-summary,.location,.status { font-size:12px; color:var(--muted) } +.mono { font-family:ui-monospace,"SF Mono",Consolas,monospace; font-size:12px } +.wrap,.location { overflow-wrap:anywhere } +.location { display:block; line-height:1.6 } +.muted { color:var(--muted) } +.eyebrow,.kind-label { + font-size:12px; + font-weight:550; + color:var(--muted); + letter-spacing:.045em +} +.eyebrow { text-transform:uppercase; letter-spacing:.08em } +.kind-label { display:block; margin-top:3px } +.stack { display:grid; gap:10px; align-content:start; min-width:0 } +.row { display:flex; gap:8px; align-items:center; flex-wrap:wrap } +.spaced { justify-content:space-between } +.section { margin-top:28px } +.badge,.count-badge { + display:inline-flex; + align-items:center; + width:fit-content; + border:1px solid var(--border); + border-radius:5px; + padding:2px 7px; + font-size:12px; + color:var(--muted); + line-height:1.5 +} +.count-badge { font-variant-numeric:tabular-nums; border:0; background:var(--raised); border-radius:20px } +.scope-note { line-height:1.6; max-width:80ch } +.empty { padding:20px; border:1px dashed var(--border); border-radius:10px; color:var(--muted); font-size:13px } +.warning { grid-row:2; border-bottom:1px solid var(--accent); padding:10px 24px; background:var(--accent-soft); font-size:12px } +.card { + display:block; + width:100%; + min-width:0; + text-align:left; + padding:12px; + background:var(--surface) +} +.card-title { display:block; font-weight:600; line-height:1.4; overflow-wrap:anywhere } +.card>.meta,.card>.location { display:block; margin-top:6px; overflow-wrap:anywhere } +.card .kind-label+.location { margin-top:6px } +.metric { display:grid; gap:2px; font-size:12px; color:var(--muted) } +.metric strong { font-size:22px; font-weight:600; letter-spacing:-.025em; color:var(--fg); font-variant-numeric:tabular-nums; line-height:1.25 } +.card-stats { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; padding:14px 0 2px } +.card-footer { display:block; padding-top:12px; margin-top:12px; border-top:1px solid var(--border); font-size:12px; color:var(--muted) } +.list { display:grid; gap:10px; min-width:0; align-content:start; grid-auto-rows:max-content } +.pager { display:flex; gap:8px; align-items:center; justify-content:space-between; margin-top:8px; font-size:12px; color:var(--muted); font-variant-numeric:tabular-nums } +.pager button { font-size:12px; padding:8px 10px } +.kv { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:12px; padding:9px 0; border-bottom:1px solid var(--border); font-size:12px } +.kv dt { color:var(--muted) } +.kv dd { margin:0; text-align:right; overflow-wrap:anywhere; font-variant-numeric:tabular-nums } +details { min-width:0; padding:0 12px; border:1px solid var(--border); border-radius:8px; background:var(--surface) } +summary { min-height:44px; padding:12px 0; font-size:13px; overflow-wrap:anywhere } +details>.stack { margin:0 0 12px } +details>p { margin:0 0 12px } +.original-location summary { font-size:12px; color:var(--muted) } +.original-location p { font-size:12px } +.edge-record { display:grid; gap:10px; padding:14px; border:1px solid var(--border); border-radius:10px; background:var(--surface); overflow-wrap:anywhere } +.edge-record>.location { color:var(--fg) } +.edge-record .card { border:0; border-left:2px solid var(--border); border-radius:0; padding:6px 10px; background:transparent } +.edge-record .card-title { font-size:12px } +.edge-record .kind-label { display:none } +.edge-record .card>.meta { font-size:12px; margin-top:3px } +.callsite-meta { gap:6px } +.callsite-meta .eyebrow { margin-right:auto } +#app { height:100dvh; display:grid; grid-template-rows:auto auto minmax(0,1fr); overflow:hidden } +.header { grid-row:1; padding:12px 24px 4px; border-bottom:1px solid var(--border); background:var(--surface); z-index:5 } +.header-top { display:grid; grid-template-columns:minmax(180px,1fr) auto auto; gap:24px; align-items:center } +.brand { display:flex; align-items:center; gap:12px; min-width:0 } +.brand .eyebrow { font-size:12px; letter-spacing:.1em } +.brand-mark { width:30px; height:30px; flex-shrink:0; border:1px solid var(--accent); border-radius:8px; position:relative; background:var(--accent-soft) } +.brand-mark:before,.brand-mark:after { content:""; position:absolute; width:10px; height:10px; border:1px solid var(--accent); border-radius:2px } +.brand-mark:before { top:5px; left:5px } +.brand-mark:after { bottom:5px; right:5px; background:var(--accent-soft) } +.explorer-controls,.history-actions,.analyze-control { display:flex; align-items:center; gap:8px } +.analyze-control { padding-left:12px; margin-left:4px; border-left:1px solid var(--border); font-size:12px; color:var(--muted) } +.analyze-control select { min-height:44px; width:100px; font-size:13px; color:var(--fg); background:var(--surface) } +.home-button { border-color:transparent; font-weight:600 } +.history-actions button { width:44px; padding:4px; font-size:20px; background:transparent } +.snapshot-row { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:8px 16px; min-height:48px } +.snapshot { display:flex; align-items:center; gap:24px } +.snapshot .metric { display:flex; gap:6px; align-items:baseline } +.snapshot .metric strong { font-size:14px; letter-spacing:0 } +.export-info { position:relative; padding:0; border:0; border-radius:0; background:transparent; flex-shrink:0 } +.export-info summary { padding:12px 0; font-size:12px; color:var(--muted) } +#export-details { position:absolute; top:100%; right:0; width:min(340px,calc(100vw - 32px)); padding:16px; border:1px solid var(--border); background:var(--surface); border-radius:10px; box-shadow:var(--shadow); z-index:6 } +#export-details .scope-note { margin-top:12px } +.workspace { grid-row:3; display:grid; grid-template-columns:260px minmax(0,1fr) 300px; min-height:0 } +.pane { padding:20px 16px; min-width:0; min-height:0; overflow:auto; background:var(--surface) } +.navigator { display:flex; flex-direction:column; border-right:1px solid var(--border) } +.inspector { border-left:1px solid var(--border) } +.pane-head { display:flex; justify-content:space-between; align-items:center; gap:8px; margin-bottom:20px; flex-shrink:0 } +.pane-head h2 { font-size:12px; text-transform:uppercase; letter-spacing:.08em; color:var(--muted); font-weight:600 } +.navigator>.search-content { flex:1; min-height:0; grid-template-rows:auto auto auto auto minmax(0,1fr) auto } +.search-content>label { display:flex; align-items:center; justify-content:space-between; font-size:12px; color:var(--muted) } +kbd { font:12px ui-monospace,monospace; border:1px solid var(--border); border-radius:4px; min-width:22px; text-align:center; color:var(--muted) } +.searchbar { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:6px } +.searchbar input,.search-filter { width:100% } +.searchbar button { background:var(--bg); font-size:18px; width:44px; padding:4px } +.search-filter { font-size:12px } +#results { overflow:auto; padding:4px; margin:0 -4px } +#results .card { border-color:transparent; padding:10px; background:transparent; border-radius:7px } +#results .card:hover { border-color:var(--border); background:var(--raised) } +#results .card[aria-current=true] { background:var(--accent-soft); border-color:var(--accent) } +#results .card-title { font-size:13px } +#results .kind-label { font-size:12px } +#search-pages { min-width:0 } +.main { display:flex; flex-direction:column; gap:24px; min-width:0; min-height:0; padding:28px; overflow:auto } +.main-head { display:grid; gap:10px; min-width:0 } +.main-head>p { font-size:13px; line-height:1.6 } +.breadcrumbs { display:flex; flex-wrap:wrap; align-items:center; gap:6px; font-size:12px; color:var(--muted); min-width:0 } +.breadcrumbs button { background:transparent; border-color:transparent; padding:6px 8px; margin-left:-8px; font-size:12px; color:var(--muted) } +.breadcrumbs button:hover { color:var(--fg); background:var(--raised) } +.breadcrumbs>span:last-child { color:var(--fg) } +#surface { min-height:0; position:relative; flex:1 } +.directory { gap:12px } +.category-filters { gap:8px } +.category-control { display:flex; align-items:center; gap:7px; min-height:44px; padding:8px 10px; border:1px solid var(--border); border-radius:8px; font-size:12px; background:var(--surface); cursor:pointer } +.category-control:has(input:checked) { border-color:var(--accent); background:var(--accent-soft) } +.category-control:has(input:disabled) { opacity:.45; cursor:default } +.category-count { font-variant-numeric:tabular-nums; color:var(--muted) } +.overview-section { margin-top:12px } +.section-heading { display:flex; gap:8px; align-items:center; margin-bottom:12px } +.section-heading h3 { font-size:15px } +.overview-section .list { grid-template-columns:repeat(auto-fit,minmax(min(100%,220px),1fr)); gap:12px } +.directory-card { display:grid; grid-template-columns:minmax(0,1fr) auto; grid-template-rows:auto minmax(0,1fr) auto auto; align-content:start; column-gap:8px; padding:16px; border-radius:10px } +.directory-card>.card-title { grid-column:1; grid-row:1; padding-right:4px; font-size:15px } +.directory-card>.location { grid-column:1/-1; grid-row:2; margin-top:6px; align-self:start } +.directory-card>.category-badge { grid-column:2; grid-row:1; align-self:start; padding:1px 6px } +.directory-card>.card-stats,.directory-card>.card-footer { grid-column:1/-1 } +.search-match { outline:2px solid var(--accent); outline-offset:-2px } +.focus-scene { display:grid; gap:16px; position:relative } +.scene-note { margin-bottom:0 } +.flow { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(0,1fr); gap:24px; position:relative; isolation:isolate; min-width:0 } +.lane { min-width:0; min-height:0; max-height:min(640px,calc(100dvh - 280px)); overflow:auto; padding:0 4px 4px; z-index:1; position:relative; scroll-margin-top:64px } +.lane-heading { display:flex; justify-content:space-between; align-items:center; gap:6px } +.lane-heading h3 { font-size:13px } +.lane-summary { margin:6px 0 14px } +.neighbor-row { display:grid; min-width:0; border:1px solid var(--border); border-radius:9px; overflow:hidden; background:var(--surface) } +.neighbor-row:has(.selected) { border-color:var(--accent) } +.neighbor-link { border:0; border-radius:0; padding:12px } +.neighbor-row>.inspect-control { border:0; border-top:1px solid var(--border); border-radius:0; font-size:12px; background:transparent; color:var(--accent); padding:8px 12px; text-align:left } +.neighbor-row>.inspect-control:after { content:"→"; float:right } +.neighbor-row>.inspect-control:hover,.neighbor-row>.inspect-control.selected { background:var(--accent-soft) } +.focus-lane { align-self:start; min-width:0; padding-top:64px; z-index:1 } +.focus-card { display:grid; gap:10px; border:1px solid var(--accent); border-top:3px solid var(--accent); border-radius:10px; padding:16px; background:var(--surface); box-shadow:var(--shadow); overflow-wrap:anywhere; scroll-margin-top:64px } +.focus-card h3 { font-size:18px; letter-spacing:-.02em } +.focus-card .eyebrow { color:var(--accent) } +.focus-card .card-stats { border-top:1px solid var(--border); margin-top:2px; padding-top:12px } +.focus-actions { display:flex; flex-wrap:wrap; gap:8px } +.focus-actions button { flex:1; font-size:12px; padding:8px } +.focus-card>[data-action=browse-files] { background:var(--accent-soft); border-color:var(--accent); font-weight:600 } +.self-calls { display:grid; gap:6px; border-top:1px solid var(--border); padding-top:10px } +.self-calls .inspect-control { font-size:12px } +#copy-status:empty { display:none } +.focus-jumps { display:none } +.focus-edges { position:absolute; inset:0; width:100%; height:100%; pointer-events:none; overflow:hidden; z-index:0 } +.focus-edges .connection { fill:none; stroke:var(--accent); stroke-width:1.5; opacity:.3 } +.focus-edges .connection.selected { stroke-width:2.5; opacity:1 } +.focus-edges .connection-hit { fill:none; stroke:transparent; stroke-width:12; pointer-events:stroke; cursor:pointer } +.flow.stacked { grid-template-columns:minmax(0,1fr); gap:24px } +.flow.stacked .lane { max-height:440px; padding:0 4px 4px; scroll-margin-top:64px } +.flow.stacked .focus-lane { grid-row:1; padding:0 } +.flow.stacked .focus-edges { display:none } +.focus-scene:has(.flow.stacked) .focus-jumps { display:flex; flex-wrap:wrap; gap:6px; position:sticky; top:-1px; z-index:3; padding:8px 0; background:var(--bg); border-bottom:1px solid var(--border) } +.focus-jumps button { flex:1; padding:8px; font-size:12px; white-space:nowrap } +.files-section { padding-top:24px; border-top:1px solid var(--border); gap:16px } +#files-title { font-size:18px; scroll-margin-top:64px } +.local-filter { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:6px 8px; max-width:600px } +.local-filter label { grid-column:1/-1; font-size:12px; color:var(--muted) } +.local-filter button { font-size:12px } +.file-group summary { line-height:1.7 } +.file-group summary .location { display:inline; color:var(--fg) } +.file-count { display:block; padding-left:16px; margin-top:2px } +.file-group[open] { border-color:var(--muted) } +.file-group .card { background:var(--bg); display:grid; grid-template-columns:minmax(0,1fr) auto; gap:2px 12px } +.file-group .card-title { grid-column:1; grid-row:1 } +.file-group .kind-label { grid-column:1; grid-row:2; margin:0 } +.file-group .card>.location { grid-column:2; grid-row:1/3; align-self:center; margin:0 } +.group-heading { margin:8px 0 0 } +.lane-heading .group-heading,.section-heading .group-heading,.focus-card .group-heading { margin:0 } +#details { gap:16px } +#details>h3:first-child { font-size:18px; color:var(--fg); margin:0 } +.relationship-endpoints { gap:6px } +.relationship-endpoints .eyebrow:not(:first-child) { margin-top:8px } +.analysis-heading { margin-bottom:20px } +.analysis-heading h3 { margin:0; font-size:18px } +.analysis-heading [data-action=explore] { margin-left:auto; font-size:12px } +.view-list { display:grid; gap:16px; max-width:1000px } +.view-list>.group-heading { margin-top:16px } +.copy-field { width:100%; margin-top:8px } +.mobile-search,.drawer-details,.dialog-close { display:none } +.pane[role=dialog] { position:fixed; z-index:20; inset:0 0 0 auto; width:380px; max-width:100vw; border-left:1px solid var(--border); box-shadow:0 0 0 100vmax #0007, var(--shadow) } +.pane[role=dialog] .dialog-close { display:block } +.sr-only { position:absolute; top:0; left:0; width:1px; height:1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap } +@media(min-width:1024px) and (max-width:1279px) { + .workspace { grid-template-columns:240px minmax(0,1fr) } + .inspector:not([role=dialog]) { display:none } + .drawer-details { display:block } +} +@media(max-width:1023px) { + .workspace { grid-template-columns:minmax(0,1fr) } + .navigator:not([role=dialog]),.inspector:not([role=dialog]) { display:none } + .mobile-search,.drawer-details { display:block } + .pane[role=dialog].navigator { left:0; right:auto; border-left:0; border-right:1px solid var(--border) } + .header { padding:10px 16px 2px } + .header-top { grid-template-columns:minmax(0,1fr) auto; gap:8px } + .brand { gap:8px } + .brand-mark { width:28px; height:28px } + .brand h1 { font-size:16px } + .history-actions { grid-column:2; grid-row:1; gap:4px } + .explorer-controls { grid-row:2; grid-column:1/-1; justify-content:space-between; gap:6px } + .explorer-controls>button { padding:8px; font-size:12px } + .analyze-control { gap:6px; padding-left:8px; margin-left:0 } + .analyze-control select { width:86px; padding:6px; font-size:12px } + .main { padding:20px 16px; gap:20px } + h2 { font-size:24px } + .snapshot { gap:14px } + .snapshot .metric { gap:4px; font-size:12px } + .snapshot .metric strong { font-size:13px } + .snapshot-row { gap:8px } + .export-info summary { font-size:12px } + .warning { padding:8px 16px } +} +@media(max-width:420px) { + .brand-mark { display:none } + .analyze-control { border:0; padding-left:0 } + .analyze-control label { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0,0,0,0) } + .snapshot { gap:10px } + .snapshot-row { gap:6px } + .snapshot .metric { gap:3px } + .export-info summary { font-size:12px } + .category-filters { gap:6px } + .category-control { padding:8px; gap:6px } + .directory-card { padding:16px } +} +@media(prefers-reduced-motion:reduce) { + * { scroll-behavior:auto!important; transition:none!important } +} +`; diff --git a/src/tools/visualize/template.ts b/src/tools/visualize/template.ts index ecf4fd8c..b0bdee52 100644 --- a/src/tools/visualize/template.ts +++ b/src/tools/visualize/template.ts @@ -1,256 +1,64 @@ import type { VisualizationData } from "./types.js"; +import { VISUALIZATION_SCRIPT } from "./client.js"; +import { analyzeVisualizationGraph } from "./graph.js"; +import { VISUALIZATION_STYLES } from "./styles.js"; export function generateVisualizationHtml(data: VisualizationData): string { - const jsonData = JSON.stringify(data) - .replace(//g, "\\u003e"); - + const json = JSON.stringify({ data, analysis: analyzeVisualizationGraph(data) }) + .replace(//g, "\\u003e"); return ` - -Call Graph Visualization - + +Codebase explorer +
-
- -
-
Changes | 0 nodes | 0 edges | 0 change lenses
+
+
+

Codebase explorer

Offline snapshot
+
+ +
+ + +
+
+ + +
+
+
+
+
Export details
+
+
+ +
+ +
+

Overview

+
+
+
- -
Scroll to pan vertically inside focus mode
-
- +
+ + + `; } diff --git a/tests/branch-materialization-multiprocess.test.ts b/tests/branch-materialization-multiprocess.test.ts index 1d5ea899..b0350a0a 100644 --- a/tests/branch-materialization-multiprocess.test.ts +++ b/tests/branch-materialization-multiprocess.test.ts @@ -77,14 +77,14 @@ function runWorker( return { child, result }; } -async function waitForReadyFiles(barrierDir: string, expected: number): Promise { +async function waitForReadyFiles(barrierDir: string, expected: number, prefix = "ready."): Promise { const deadline = Date.now() + 15000; while (Date.now() < deadline) { - const ready = fs.readdirSync(barrierDir).filter((entry) => entry.startsWith("ready.")); + const ready = fs.readdirSync(barrierDir).filter((entry) => entry.startsWith(prefix)); if (ready.length >= expected) return; await new Promise((resolve) => setTimeout(resolve, 20)); } - throw new Error(`Timed out waiting for ${expected} concurrent fetches.`); + throw new Error(`Timed out waiting for ${expected} workers at ${prefix}.`); } const describeMultiprocess = process.platform === "win32" ? describe.skip : describe; @@ -159,6 +159,7 @@ exec "$REAL_GIT" "$@" const childEnv = { PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, BARRIER_DIR: barrierDir, + MATERIALIZATION_BARRIER_DIR: barrierDir, }; const alpha = runWorker(workerPath, { projectRoot: repo, @@ -174,6 +175,8 @@ exec "$REAL_GIT" "$@" try { await waitForReadyFiles(barrierDir, 2); fs.writeFileSync(path.join(barrierDir, "release"), "release\n"); + await waitForReadyFiles(barrierDir, 2, "materialized."); + fs.writeFileSync(path.join(barrierDir, "release-materialization"), "release\n"); const [alphaResult, betaResult] = await Promise.all([alpha.result, beta.result]); expect(alphaResult).toMatchObject({ @@ -192,6 +195,7 @@ exec "$REAL_GIT" "$@" }); } finally { fs.writeFileSync(path.join(barrierDir, "release"), "release\n"); + fs.writeFileSync(path.join(barrierDir, "release-materialization"), "release\n"); for (const child of [alpha.child, beta.child]) { if (child.exitCode === null) child.kill("SIGTERM"); } diff --git a/tests/branch-materialization.test.ts b/tests/branch-materialization.test.ts index 4d068730..f16a6732 100644 --- a/tests/branch-materialization.test.ts +++ b/tests/branch-materialization.test.ts @@ -9,6 +9,7 @@ import { isValidGitRemoteName, withMaterializedBranch, } from "../src/git/branch-materialization.js"; +import { acquireIndexLock, releaseIndexLock } from "../src/indexer/index-lock.js"; import { OperationCancelledError } from "../src/utils/operation-control.js"; function git(cwd: string, args: string[]): string { @@ -262,6 +263,66 @@ describe("branch materialization", () => { expect(git(repo, ["rev-parse", "HEAD"])).toBe(mainCommit); }); + it("preserves an initializing peer registration while materializing and cleaning its own worktree", async () => { + const peerRegistration = path.join(repo, ".git", "worktrees", "worktree"); + fs.mkdirSync(peerRegistration, { recursive: true }); + let materializedPath = ""; + + const result = await withMaterializedBranch( + { projectRoot: repo, branch: "feature" }, + async (worktreePath) => { + materializedPath = worktreePath; + expect(fs.existsSync(peerRegistration)).toBe(true); + return git(worktreePath, ["rev-parse", "HEAD"]); + }, + ); + + expect(result.value).toBe(featureCommit); + expect(fs.existsSync(peerRegistration)).toBe(true); + expect(fs.existsSync(path.dirname(materializedPath))).toBe(false); + }); + + it.each(["active", "unreadable"] as const)("cancels while the registration lease is %s without leaving a temporary checkout", async (state) => { + const temporaryParent = path.join(tempDir, "materializations"); + fs.mkdirSync(temporaryParent); + vi.stubEnv("TMPDIR", temporaryParent); + const beforeWorktrees = git(repo, ["worktree", "list", "--porcelain"]); + const lease = acquireIndexLock(path.join(repo, ".git", "codebase-index-worktrees"), "worktree"); + const ownerFile = path.join(lease.lockPath, "owner.json"); + const heldOwnerFile = path.join(lease.lockPath, "held-owner.json"); + if (state === "unreadable") fs.renameSync(ownerFile, heldOwnerFile); + const controller = new AbortController(); + let callbackInvoked = false; + let settled = false; + let failure: unknown; + const materialization = withMaterializedBranch( + { projectRoot: repo, branch: "feature", signal: controller.signal }, + async () => { callbackInvoked = true; }, + ).then( + () => { settled = true; }, + (error: unknown) => { settled = true; failure = error; }, + ); + + try { + await vi.waitFor(() => { + const prepared = fs.readdirSync(temporaryParent).find((name) => name.startsWith("codebase-index-branch-")); + expect(prepared && fs.existsSync(path.join(temporaryParent, prepared, "hooks"))).toBe(true); + }); + controller.abort(); + await vi.waitFor(() => expect(settled).toBe(true)); + expect(failure).toBeInstanceOf(OperationCancelledError); + expect(callbackInvoked).toBe(false); + expect(fs.readdirSync(temporaryParent)).toEqual([]); + expect(git(repo, ["worktree", "list", "--porcelain"])).toBe(beforeWorktrees); + if (state === "unreadable") expect(fs.existsSync(heldOwnerFile)).toBe(true); + } finally { + controller.abort(); + if (state === "unreadable") fs.renameSync(heldOwnerFile, ownerFile); + releaseIndexLock(lease); + await materialization; + } + }); + it("removes only the exact stale registration when the materialized directory disappears", async () => { const beforeWorktrees = git(repo, ["worktree", "list", "--porcelain"]); diff --git a/tests/fixtures/branch-materialization-oid-worker.ts b/tests/fixtures/branch-materialization-oid-worker.ts index ec5c2ad9..c1e1487c 100644 --- a/tests/fixtures/branch-materialization-oid-worker.ts +++ b/tests/fixtures/branch-materialization-oid-worker.ts @@ -1,6 +1,7 @@ import { execFileSync } from "child_process"; import * as fs from "fs"; import * as path from "path"; +import { setTimeout as delay } from "node:timers/promises"; import { withMaterializedBranch } from "../../src/git/branch-materialization.js"; @@ -27,16 +28,27 @@ async function main(): Promise { branch: request.branch, expectedCommit: request.expectedCommit, }, - async (worktreePath, info) => ({ - branch: request.branch, - commit: info.commit, - materializedHead: execFileSync("git", ["rev-parse", "HEAD"], { - cwd: worktreePath, - encoding: "utf8", - }).trim(), - content: fs.readFileSync(path.join(worktreePath, "branch.txt"), "utf8").trim(), - source: info.source, - }), + async (worktreePath, info) => { + const barrierDir = process.env.MATERIALIZATION_BARRIER_DIR; + if (barrierDir) { + fs.writeFileSync(path.join(barrierDir, `materialized.${process.pid}`), "ready\n"); + const deadline = Date.now() + 15000; + while (!fs.existsSync(path.join(barrierDir, "release-materialization"))) { + if (Date.now() >= deadline) throw new Error("Concurrent materialization callback barrier timed out"); + await delay(20); + } + } + return { + branch: request.branch, + commit: info.commit, + materializedHead: execFileSync("git", ["rev-parse", "HEAD"], { + cwd: worktreePath, + encoding: "utf8", + }).trim(), + content: fs.readFileSync(path.join(worktreePath, "branch.txt"), "utf8").trim(), + source: info.source, + }; + }, ); process.send?.({ ok: true, ...result.value } satisfies WorkerResult); diff --git a/tests/visualize.test.ts b/tests/visualize.test.ts index 595b92a5..04efffdf 100644 --- a/tests/visualize.test.ts +++ b/tests/visualize.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import { attachRecentActivity } from "../src/tools/visualize/activity.js"; import { transformForVisualization } from "../src/tools/visualize/transform.js"; import { generateVisualizationHtml } from "../src/tools/visualize/template.js"; +import { analyzeVisualizationGraph } from "../src/tools/visualize/graph.js"; +import type { VisualizationData } from "../src/tools/visualize/types.js"; import type { SymbolData, CallEdgeData } from "../src/native/index.js"; function makeSymbol(id: string, name: string, filePath: string, kind = "function"): SymbolData { @@ -192,136 +194,79 @@ describe("visualize - transform", () => { }); }); -describe("visualize - HTML template", () => { - it("generates valid HTML", () => { +describe("visualize - safe HTML data", () => { + it("keeps hostile names, paths and activity inert in the JSON data block", () => { + const hostile = ' & \'"'; const data = transformForVisualization( - [ - makeSymbol("s1", "foo", "src/a.ts"), - makeSymbol("s2", "bar", "src/b.ts"), - ], - [makeEdge("e1", "s1", "bar", "s2")], - { includeOrphans: true }, + [makeSymbol("s1", hostile, `src/${hostile}/a.ts`)], + [], { includeOrphans: true }, ); - - const html = generateVisualizationHtml(data); - expect(html).toContain(""); - expect(html).toContain("Call Graph Visualization"); - expect(html).toContain("Moving lately"); - expect(html).toContain("change lenses"); - expect(html).toContain('"name":"foo"'); - expect(html).toContain('"name":"bar"'); - expect(html).toContain('"modules"'); - expect(html).toContain('"moduleEdges"'); - }); - - it("handles empty data", () => { - const data = { - nodes: [], - edges: [], - modules: [], - moduleEdges: [], - metadata: { totalSymbols: 0, totalEdges: 0, truncated: false, moduleCount: 0 }, - }; + data.changes = [{ + id: "change", title: hostile, kind: "hot", when: "today", source: "git", + intent: hostile, summary: hostile, why: hostile, calls: 0, churn: 0, + risk: "low", moduleId: data.modules[0].id, filePaths: [hostile], + }]; const html = generateVisualizationHtml(data); - expect(html).toContain(""); - // Data is embedded as JSON, stats are computed at runtime from nodes.length - expect(html).toContain('"nodes":[]'); - expect(html).toContain('"modules"'); + const embedded = html.match(/', "src/a.ts"), - makeSymbol("s2", "normalFn", "src/b.ts"), - ], - [makeEdge("e1", "s1", "normalFn", "s2")], - ); - - const html = generateVisualizationHtml(data); - // JSON.stringify should escape the angle brackets - expect(html).not.toContain("