A TypeScript library that builds a file/symbol dependency graph for a git pull request — which files changed, how they reference each other, and which symbols were added, modified, or removed — plus an optional local D3 force-graph viewer to look at the result in a browser.
All parsing is deterministic (git + tree-sitter AST). No AI involved.
- File-level graph — every changed file as a node, import/reference edges between them
- Symbol-level detail — changed symbols per file (function/class/type/etc.), with call edges between specific symbols (including
new X()), plus file-level reference edges for named imports that couldn't be attributed to a specific caller - Change status — added / modified / removed / unchanged, per file and per symbol
- Angular signal support —
computed(),signal(),input()class properties correctly traced - Sibling links —
.ts/.html/.scsscomponent triplets grouped together - Go support — package import suffix matching
- Optional D3 viewer — a local HTTP server + browser UI with no external network access required
Not published to a registry. Install directly from this repo as a git dependency:
npm install github:JeVaughan/copilot-dep-graph
# or pin to a tag/commit:
npm install github:JeVaughan/copilot-dep-graph#v0.1.0npm runs this package's prepare script on install, which builds the TypeScript source — there's nothing to build yourself.
import { parsePr, startViewer } from "dep-graph-core";
const { nodes, edges } = parsePr({
repoPath: "/path/to/repo", // absolute path to a local git checkout
prRef: "FETCH_HEAD", // any git ref / commit SHA
baseRef: "main", // optional: override the base branch (default "HEAD")
exclude: ["migrations"], // optional: path substrings to exclude
});
// Look at it in a browser:
const viewer = await startViewer({ title: "My PR", nodes, edges });
console.log(viewer.url); // http://127.0.0.1:PORT/
// Push an updated graph to any open browser tab:
viewer.setGraph({ title: "My PR (updated)", nodes: newNodes, edges: newEdges });
// Shut the server down:
await viewer.close();parsePr has no dependency on startViewer — use it standalone if you just want the graph data (e.g. to render with your own UI, or serialize to JSON).
parsePr takes a prRef — any git ref or SHA already present in the local checkout — and deliberately never fetches anything itself, so it stays usable against any git repo, not just GitHub's. If all you have is a PR number against a GitHub remote, use resolveGitHubPr to turn that into a prRef:
import { parsePr, resolveGitHubPr } from "dep-graph-core";
const prRef = resolveGitHubPr(repoPath, 123); // fetches refs/pull/123/head, returns "FETCH_HEAD"
const { nodes, edges } = parsePr({ repoPath, prRef });This matters for PRs that were squash- or rebase-merged: after a merge like that, the PR's original commits are no longer reachable from main at all, so a local checkout's HEAD has no ancestry relationship with them. resolveGitHubPr fetches the PR's head commit directly via GitHub's refs/pull/<n>/head convention (which stays valid even after the PR is merged, until the ref is eventually garbage-collected), so parsePr's merge-base logic has something real to diff against.
Nodes and edges are a deliberately minimal, generic graph shape — no visualization-specific fields, so the parser and any viewer stay decoupled. The canonical definitions live in src/types.mts, which has zero imports of its own (no Node built-ins, no treesitter) specifically so it's safe to pull into the browser build without dragging anything else along — parse.mts, aggregate.mts, and graph-client.ts all import from there rather than each declaring their own copy:
interface GraphNode {
id: string; // e.g. "src/foo.ts" (file) or "src/foo.ts:::bar" (symbol)
label: string; // display name, e.g. "foo.ts" or "bar" — id without the file-prefix/"::: " plumbing
type: string; // "file" for file nodes, or a symbol kind: function/method/class/interface/type/enum/property/const
parent?: string; // for symbol nodes, the id of their containing file
status?: string; // "added" | "modified" | "removed" | "unchanged"
}
interface GraphEdge {
src: string; // source node id
tar: string; // target node id
type: string; // "import" | "call" | "reference" | "sibling"
status?: string | null;
count: number; // always 1 from parsePr; > 1 only after aggregate.mts collapses several edges into one
}A symbol is just a node like any other, flattened alongside its file rather than nested inside it — parent is what ties it back to its containing file. There's still no separate field for a file's full repo path or a symbol's source-line signature; those are dropped for now rather than carried as opaque payload.
The viewer collapses a file's symbols into the file node itself when it isn't expanded. aggregate.mts (pure, dependency-free, tested independently of the DOM) decides which nodes are visible for a given expand state and re-resolves every edge against that — several raw edges landing on the same visible (src, tar) pair, regardless of their original type, merge into a single GraphEdge with count set to the total and type/status each collapsed to one representative value. It's imported directly by the visualisation/ modules (served as /aggregate.mjs alongside /graph-client.js and /visualisation/*.js, all loaded as real ES modules) so the browser and the test suite run the exact same logic — no separate, hand-verified copy.
Each file has an expand level, not just an on/off state: 0 (collapsed) → 1 (changed symbols only) → 2 (every symbol, including unchanged) → back to 0. aggregate.mts's nextExpandLevel skips any level that wouldn't add anything new — an all-added file has no unchanged symbols, so level 1 goes straight back to 0; a file with no changed symbols (the diff didn't touch anything tree-sitter treats as a symbol) skips straight from 0 to 2.
If you're wiring this into something like a GitHub Copilot CLI canvas extension, keep that wiring in a separate, shallow package: import parsePr/startViewer from this library and adapt their plain return values to whatever the host expects. This library has no knowledge of any specific host.
| Action | Result |
|---|---|
| Double-click file node | Cycles changed symbols only → all symbols (incl. unchanged) → collapsed, skipping any step that wouldn't show anything new. The file node's own size never changes; a small badge on it shows +N while there's more to reveal, or - once double-clicking would only collapse it |
| Hover a reference | Shows its type, endpoints, and status — collapsing a file aggregates every underlying reference between two visible nodes into one line, regardless of the original type (a call and a reference edge landing on the same pair merge into one). Status is the merged result (unchanged mixed with a real status just becomes that status; two or more different real statuses become modified), type similarly picks one representative (call > reference > import > sibling), and the tooltip shows the total count merged in |
| Drag node | Re-position (layout re-stabilises) |
| Scroll | Zoom in/out |
| Click background + drag | Pan |
| Shape | Meaning |
|---|---|
| ● Circle | File node / property / const |
| ▲ Triangle | Function / method |
| ■ Square | Class / interface / type / enum |
Edge type (import / call / reference / sibling) is not shown as a distinct line style — hover an edge to see it. Colour is status only:
| Colour | Meaning |
|---|---|
| Green | Added |
| Yellow | Modified |
| Red | Removed |
| Grey | Unchanged |
| Language | Symbols | Call edges | Import edges |
|---|---|---|---|
| TypeScript / TSX | ✓ | ✓ | ✓ |
| JavaScript / JSX | ✓ | ✓ | ✓ |
| Go | ✓ | ✓ | ✓ |
| Other | — | — | regex fallback |
parsePr resolves @alias/*-style imports via a hardcoded alias map in src/parser/lang/typescript.mts (TS_ALIASES), tailored to one specific frontend monorepo layout. It is not read from the target repo's tsconfig.json — if you're pointing this at a different repo, edit that map (or open an issue/PR to make it configurable).
dep-graph-core/
├── src/
│ ├── index.mts # Public API barrel
│ ├── types.mts # Shared GraphNode/GraphEdge/GraphData shape — zero imports of its own
│ ├── github.mts # resolveGitHubPr: GitHub PR number → prRef (fetch, refs/pull/<n>/head)
│ ├── github.test.mts
│ ├── parser/ # Everything git-diff/AST-parsing related, decoupled from the viewer:
│ │ ├── parse.mts # parsePr: git diff + per-filetype parsers → graph data
│ │ ├── parse.test.mts # node:test suite for parse.mts, runs against the built dist/
│ │ ├── treesitter.mts # public parseSource() dispatcher, delegating to lang/
│ │ ├── treesitter.test.mts
│ │ ├── graph-diff.mts # diffEdges: structural added/removed/unchanged edge diffing
│ │ ├── graph-diff.test.mts
│ │ └── lang/ # one file per language's parsing + import resolution:
│ │ ├── shared.mts native tree-sitter bindings + AST-walking helpers
│ │ ├── typescript.mts TS/JS/TSX/JSX parsing + relative/alias import resolution
│ │ └── go.mts Go parsing + package-path suffix import resolution
│ ├── viewer.mts # Local HTTP/SSE server serving the D3 UI
│ ├── viewer.test.mts
│ ├── aggregate.mts # Pure edge-collapsing logic, shared by the visualisation modules and its tests
│ ├── aggregate.test.mts
│ ├── graph-client.ts # Browser entry point (real ES module) — page bootstrapping only
│ └── visualisation/ # The actual renderer, split by concern, each a real ES module:
│ ├── state.ts # shared mutable render state (one object — see its own comment on why)
│ ├── dom.ts # top-level D3/DOM handles (svg, root, zoom, tooltip)
│ ├── colors.ts # status colour/opacity, node/hull colour helpers
│ ├── sizing.ts # depth scaling, hull geometry, link width/strength curves
│ ├── expand-state.ts # what's expanded, what double-click does next
│ ├── hover.ts # the three hover-focus modes and their shared dimming logic
│ ├── tooltip.ts # tooltip content and positioning
│ └── render.ts # the render() loop that ties the above together
├── samples/ # Checked-in base/pr fixtures for the dev harness (see below)
├── dev/serve.mts # Manual dev harness (not published)
├── graph.html # D3 force graph UI shell (loads /graph-client.js as a module)
├── d3.min.js # Bundled D3 v7 (no CDN)
├── package.json
├── tsconfig.json # Compiles src/*.mts + src/parser/*.mts → dist/ (Node ESM library)
└── tsconfig.browser.json # Compiles src/graph-client.ts + src/visualisation/ (+ aggregate.mts) → dist/ (browser ESM)
dist/ and node_modules/ are gitignored — they're produced by npm run build (or automatically via prepare when installed as a dependency).
npm install
npm run build # or: npm run watch
npm test # runs the node:test suite against the built dist/npm run dev starts the D3 viewer with sample data and prints a URL to open. Pass --repo to parse a real PR diff instead, or --sample <name> to run against a checked-in fixture under samples/ (e.g. typescript-small, which exercises added/modified/removed files and nested class methods, or typescript-medium, which adds removed edges and mixed changed/unchanged children). It binds a stable port (4500) by default, so a port-forward set up once (e.g. in a remote/devcontainer session) keeps working across restarts — pass --port to use a different one:
npm run dev
npm run dev -- --repo /path/to/repo --pr HEAD --base main
npm run dev -- --sample typescript-small
npm run dev -- --port 4501A sample is a plain base//pr/ pair of file snapshots (no nested .git — embedding a real git repo inside this one is a recognized anti-pattern). dev/serve.mts materializes it into a scratch git repo with two real commits on the fly and diffs it exactly like any other --repo.
MIT