feat(graph): whole-repo LSP map (directory mode) beyond the call walk#14
feat(graph): whole-repo LSP map (directory mode) beyond the call walk#14burrows99 wants to merge 4 commits into
Conversation
…l walk `trace graph` could only walk calls out from one --entry function. Add a repo/directory mode that maps everything the language server reports: - Discovery (`sourceFiles.ts`): resolve a directory (root auto-detected from cwd/--root — nearest tsconfig/package.json/.git) and walk it for source files, skipping node_modules/dist/.git/hidden + .d.ts, bounded by --max-files. - Build (`LspCodeGraphProvider.repoGraph`): per file, `documentSymbol` for containment (file → class → method/property/…) and the full node kind set; per callable, `callHierarchy/outgoingCalls` for `calls` edges; per class/interface, `typeHierarchy/supertypes` for `extends`/`implements`. Each pass is capability-guarded. - One unified CodeGraph (`mode: "rooted" | "repo"`, new edge kinds, per-kind stats), so the schema + HTML force view are reused and the text view branches to a per-file outline (GraphView.repoMap). UX: `trace graph` (no --entry) or `--entry <dir>` → repo map; `--entry file:line`/`file@symbol` → the unchanged rooted call walk. New flags: --max-files, --include-external, --no-inheritance. Honest degradation: the bundled typescript-language-server advertises no typeHierarchyProvider, so extends/implements can't be derived on TS. That is surfaced as a GRAPH_DEGRADED warn diagnostic (not silently dropped); servers that do support type hierarchy (gopls/rust-analyzer/clangd) get the inheritance edges. A `references` pass is scaffolded but left off (heavy) — the first "out of bounds for now" candidate. Verified: tsc clean, full build green, 79/80 tests pass (1 Postgres skip), incl. new repo-map + discovery tests; manual runs over test/fixtures/codegraph and src/domain (text + HTML + JSON). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new repo/directory mode to trace graph that maps an entire directory/repo via LSP (symbols + containment + calls + optional inheritance), reusing the existing CodeGraph envelope/schema and renderers while introducing new edge kinds and diagnostics for capability-based degradation.
Changes:
- Introduces repo discovery utilities (
discoverSourceFiles,isDirectory,resolveRepoRoot) and wires them into a newLspCodeGraphProvider.repoGraph(...)build path. - Extends the
CodeGraphmodel to supportmode: "rooted" | "repo"plus new edge kinds and repo-mode stats, and updates text/HTML rendering accordingly. - Updates CLI/options + input normalization, adds
GRAPH_DEGRADED, and expands integration tests for repo mode.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| test/codegraph.test.js | Adds tests for directory discovery, repoGraph output, and repo-mode rendering. |
| src/shared/codes.ts | Adds GRAPH_DEGRADED diagnostic code for capability-based partial graphs. |
| src/io/InputManager.ts | Makes --entry optional and introduces repo-mode normalization logic. |
| src/io/descriptors.ts | Expands graph command input shape with repo-mode flags. |
| src/codegraph/sourceFiles.ts | New: repo root resolution and deterministic directory walking with caps/filters. |
| src/codegraph/LspCodeGraphProvider.ts | Adds repoGraph() and additional symbol-kind handling for repo maps. |
| src/codegraph/CodeGraphProvider.ts | Updates graph interfaces for repo mode, new edge kinds, and stats. |
| src/cli/commands/GraphView.ts | Adds repoMap() text renderer and adjusts HTML metadata for repo graphs. |
| src/cli/commands/GraphCommand.ts | Orchestrates rooted vs repo builds; emits truncation + degraded diagnostics. |
| src/cli/Cli.ts | Updates trace graph command flags/description for the new repo mode. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Repo map: resolve the directory to cover — an explicit --root/dir, else the detected project root of cwd | ||
| // (nearest tsconfig/package.json/.git), so a bare `trace graph` maps the project it's run in. | ||
| const root = resolveRepoRoot(request.root ?? process.cwd()); |
| maxFiles: raw.maxFiles, | ||
| includeExternal: raw.includeExternal, | ||
| inheritance: raw.inheritance, | ||
| server: raw.server, | ||
| args: { ...(entryString ? { entry: entryString } : {}), ...(raw.root ? { root: raw.root } : {}), ...(raw.server ? { server: raw.server } : {}), ...(raw.maxFiles ? { maxFiles: raw.maxFiles } : {}) }, | ||
| }; |
| maxDepth: raw.depth, | ||
| includeExternal: raw.includeExternal, | ||
| server: raw.server, | ||
| args: { entry: raw.entry, ...(raw.root ? { root: raw.root } : {}), ...(raw.server ? { server: raw.server } : {}), depth: raw.depth }, | ||
| args: { entry: entryString, ...(raw.root ? { root: raw.root } : {}), ...(raw.server ? { server: raw.server } : {}), depth: raw.depth }, | ||
| }; |
| const open = (uri: string): void => { | ||
| if (opened.has(uri)) return; | ||
| let text: string; | ||
| try { text = readFileSync(fileURLToPath(uri), "utf8"); } catch { return; } | ||
| client.notify(DidOpenTextDocumentNotification.type, { textDocument: { uri, languageId: languageIdFor(uri), version: 1, text } }); | ||
| opened.add(uri); | ||
| }; |
| test("discoverSourceFiles walks a directory (extension-filtered) and resolveRepoRoot detects the project root", () => { | ||
| const found = discoverSourceFiles(ROOT, { maxFiles: 100 }); | ||
| assert.deepEqual(found.files.map((f) => f.split("/").pop()).sort(), ["helper.ts", "sample.ts"]); | ||
| assert.equal(found.truncated, false); |
…, test portability - Repo root: bare `trace graph` (no --root/dir) now detects the nearest project root by walking UP from cwd via findProjectRootFrom, instead of mapping cwd as-is — so running in a subdir maps the whole project. An explicit --root/dir is still honored verbatim. - meta.args: include the flags that change the map — --include-external and --no-inheritance (repo), --include-external (rooted) — so a Trace is reproducible from its recorded invocation. - languageId: map non-TS extensions (go/py/rs/rb/c/cpp/java/…) and fall back to the bare extension, not "typescript" — a wrong id can stop a non-TS server from parsing files at all. - test: split discovered paths on [/\\] so the basename assertion is Windows-safe. Verified: tsc clean, 79/80 tests pass (1 Postgres skip). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ne export directory Generalize `export-skill` into `exports`: a single command that provisions a project's export directory (.claude/skills/trace) with everything trace-cli hands off — the bundled `trace` skill AND interactive HTML maps of that project, built right then: - graph.html — the whole-repo LSP map (GraphCommand repo mode) - deps.html — the module-import graph (DepsCommand / madge) ExportSkillCommand → ExportCommand (now async; orchestrates the skill copy plus the two analyses). The skill copy is the must-succeed step; each map is best-effort — a failed/empty analysis still writes a page and is reported via `ok`, never aborting the export. CLI prints the skill dest + each map path. Verified: tsc clean, 81/82 tests pass (1 Postgres skip), incl. new export integration tests (skill copied, graph.html + deps.html written as real HTML pages); manual `trace exports <dir>` end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Added in
Verified: tsc clean, 81/82 tests pass (1 Postgres skip), incl. new export integration tests + manual |
| /** Extensions the bundled TypeScript server understands — the default scan set for a repo map. */ | ||
| export const DEFAULT_SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]; |
| if (!IGNORED_DIRS.has(entry.name)) walk(fullPath); | ||
| } else if (entry.isFile() && !entry.name.endsWith(".d.ts") && extensions.has(extname(entry.name).toLowerCase())) { | ||
| if (files.length >= options.maxFiles) { truncated = true; return; } | ||
| files.push(fullPath); | ||
| } |
| const root = request.root ? resolveRepoRoot(request.root) : findProjectRootFrom(process.cwd()); | ||
| const graph = await provider.repoGraph({ | ||
| root, | ||
| maxFiles: request.maxFiles ?? MAX_FILES, | ||
| maxNodes: request.maxNodes ?? MAX_NODES, | ||
| includeExternal: request.includeExternal ?? false, | ||
| inheritance: request.inheritance, | ||
| server: request.server, | ||
| }); |
| if (graph.stats.truncated) { | ||
| diagnostics.push(Diagnostic.warn(Code.GRAPH_TRUNCATED, `repo map truncated (${graph.stats.files} files, ${graph.stats.nodes} symbols) — narrow with --entry <subdir>, or raise --max-files`)); | ||
| } |
| } catch (error) { | ||
| log.warn(`${kind} map failed`, { err: String((error as Error)?.message ?? error).split("\n")[0] }); | ||
| return { kind, path, ok: false }; | ||
| } |
| export { DoctorCommand } from "./cli/commands/DoctorCommand.js"; | ||
| export { ExportSkillCommand } from "./cli/commands/ExportSkillCommand.js"; | ||
| export { ExportCommand } from "./cli/commands/ExportCommand.js"; | ||
| export { Cli } from "./cli/Cli.js"; |
…d exports Running `exports` on this repo surfaced two issues: - the deps map scanned a git submodule's build/ output (798 modules) — default an --exclude for the export's deps map covering node_modules/dist/build/out/cache/coverage/vendor, mirroring the repo graph's source-discovery ignore set, so deps.html reflects the project. - the generated export directory (skill copy + graph.html/deps.html) is regenerable, not source — gitignore .claude/skills/trace/. Test extends the export integration test: a build/ module is excluded from deps.html (and never enters the repo graph). Verified: tsc clean, 81/82 tests pass (1 Postgres skip). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What
trace graphcould only walk calls out from one--entryfunction. This adds a repo/directory mode that maps everything the language server reports.trace graph(no--entry)trace graph --entry src/domaintrace graph --entry foo.ts@login/foo.ts:42How the map is built (everything the LSP supports)
sourceFiles.ts): resolve a directory — root auto-detected from cwd/--root(nearesttsconfig/package.json/.git) — and walk it for source files, skippingnode_modules/dist/.git/hidden +.d.ts, bounded by--max-files.LspCodeGraphProvider.repoGraph): per filedocumentSymbol→ containment (file → class → method/property/…) + the full node-kind set; per callablecallHierarchy/outgoingCalls→callsedges (incl.super()); per class/interfacetypeHierarchy/supertypes→extends/implements. Each pass is capability-guarded.CodeGraph—mode: "rooted" | "repo", new edge kinds (contains/extends/implements), per-kind stats — so the JSON schema and the HTML force-view are reused, and the text view branches to a per-file outline (GraphView.repoMap).New flags:
--max-files,--include-external,--no-inheritance.--entryis now optional.Honest degradation
The bundled
typescript-language-serveradvertises notypeHierarchyProvider, soextends/implementscan't be derived from the LSP on TypeScript. Rather than silently drop it, it surfaces as a visible diagnostic:Servers that do support type hierarchy (gopls, rust-analyzer, clangd) get inheritance edges automatically. A
referencespass is scaffolded in the options but left off (heavy) — the first deliberate "out of bounds for now" candidate.Sample (
trace graph --entry src/domain)Verification
tsc --noEmitclean; full build (CLI + UI) greenGraphCommandrepo-mode teststest/fixtures/codegraphandsrc/domain— text outline, HTML force view, and JSON envelopeNotes
master; independent of refactor(analysis): add an Analyzer parent for lineage + graph #13 (the Analyzer parent). Both touchGraphCommand— expect a trivial rebase if refactor(analysis): add an Analyzer parent for lineage + graph #13 lands first.textDocument/definitionfallback soextends/implementsalso work on the bundled TS server.🤖 Generated with Claude Code