From adb2cfd7aa63623ff5cccb2141f43db698a71013 Mon Sep 17 00:00:00 2001 From: burrows99 Date: Fri, 19 Jun 2026 13:18:02 +0100 Subject: [PATCH 1/4] feat(graph): add a whole-repo LSP map (directory mode) beyond the call walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 ` → 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) --- src/cli/Cli.ts | 13 +- src/cli/commands/GraphCommand.ts | 77 +++++++--- src/cli/commands/GraphView.ts | 77 +++++++++- src/codegraph/CodeGraphProvider.ts | 46 +++++- src/codegraph/LspCodeGraphProvider.ts | 211 +++++++++++++++++++++++++- src/codegraph/sourceFiles.ts | 70 +++++++++ src/io/InputManager.ts | 35 ++++- src/io/descriptors.ts | 5 +- src/shared/codes.ts | 2 + test/codegraph.test.js | 41 +++++ 10 files changed, 528 insertions(+), 49 deletions(-) create mode 100644 src/codegraph/sourceFiles.ts diff --git a/src/cli/Cli.ts b/src/cli/Cli.ts index 9af1932..8da0da8 100644 --- a/src/cli/Cli.ts +++ b/src/cli/Cli.ts @@ -127,12 +127,15 @@ export class Cli { // static analysis — code structure without running the app. Each command shells out to one analyzer and // emits the same Trace envelope as the runtime `run` command (call graph · deps · complexity · symbols). program.command("graph") - .description("call graph rooted at an entry → the flow tree for a function/route, via LSP call hierarchy") - .requiredOption("--entry ", "where to start: file:line, file:line:column, or file@symbol (e.g. src/auth.service.ts:42:9 or src/auth.service.ts@exchangeToken)") - .option("--root ", "project root / LSP workspace (default: auto — nearest tsconfig/package.json/.git above the entry)") + .description("code map via the language server. With --entry file:line / file@symbol: a rooted call graph (the flow tree out of one function). With a directory --entry, or no --entry: a whole-repo map — every symbol with containment (file→class→method), calls, and inheritance (extends/implements).") + .option("--entry ", "rooted call walk: file:line, file:line:column, or file@symbol (e.g. src/auth.service.ts@exchangeToken). A directory — or omitting this — maps the whole repo instead.") + .option("--root ", "project root / LSP workspace (default: auto — nearest tsconfig/package.json/.git; repo mode maps this directory)") .option("--server ", "override the LSP server (default: auto by file extension; bundled typescript-language-server for TS/JS, e.g. \"gopls\", \"pyright --stdio\")") - .option("--depth ", "max call depth expanded from the entry", parseIntArg, 6) - .option("--html [path]", "also write an interactive call-graph diagram — nodes & edges, force-directed (to a file if a path is given, else a temp file)") + .option("--depth ", "rooted mode: max call depth expanded from the entry", parseIntArg, 6) + .option("--max-files ", "repo mode: cap on source files scanned (default 800)", parseIntArg) + .option("--include-external", "keep edges to external symbols (node_modules / outside the root) as leaf nodes") + .option("--no-inheritance", "repo mode: skip extends/implements edges (don't query the server's type hierarchy)") + .option("--html [path]", "also write an interactive graph diagram — nodes & edges, force-directed (to a file if a path is given, else a temp file)") .option("--json [path]", "envelope as JSON: to a file if a path is given, else to stdout") .action((options) => this.#runGraph(options)); diff --git a/src/cli/commands/GraphCommand.ts b/src/cli/commands/GraphCommand.ts index fb1bd8c..e1a3f59 100644 --- a/src/cli/commands/GraphCommand.ts +++ b/src/cli/commands/GraphCommand.ts @@ -7,29 +7,34 @@ import { Code } from "../../shared/codes.js"; import { findProjectRoot } from "../../shared/projectRoot.js"; import { createCodeGraphProvider } from "../../codegraph/createCodeGraphProvider.js"; import type { CodeGraph, EntryReference } from "../../codegraph/CodeGraphProvider.js"; +import { resolveRepoRoot } from "../../codegraph/sourceFiles.js"; import { TraceCommand } from "./TraceCommand.js"; import { GraphView } from "./GraphView.js"; const log = logger.child({ component: "graph" }); const MAX_NODES = 2000; // internal safety cap on graph size; --depth is the user-facing size knob +const MAX_FILES = 800; // internal safety cap on a repo map's breadth; --max-files is the user-facing knob export interface GraphRequest { - entry: EntryReference; + entry?: EntryReference; // rooted mode: where the call walk starts. Absent (or `repo`) → a whole-directory map. + repo?: boolean; // true → map a directory (root) instead of walking calls out from `entry` provider?: string; - root?: string; // optional: auto-detected from the entry file when absent + root?: string; // rooted: workspace root (auto-detected from entry when absent). repo: the dir to map. maxDepth: number; includeExternal?: boolean; // default false — externals (node_modules / outside-root) shown as leaves + maxFiles?: number; // repo: default MAX_FILES + inheritance?: boolean; // repo: default true (skipped if the server lacks type hierarchy) maxNodes?: number; // default MAX_NODES — internal safety cap, not a user knob server?: string; args?: Record; } /** - * GraphCommand — orchestrates a static call-graph build: pick the provider (factory), build the outgoing-call - * graph rooted at the entry, and normalize it into one Trace envelope (`data.graph`). The provider is the - * injected collaborator (Dependency Inversion); this class owns the use-case and the envelope, not the analysis. - * A resolution/analysis failure becomes an error diagnostic on a still-well-formed envelope, matching how the - * run command surfaces engine failures — an agent always gets back a Trace. + * GraphCommand — orchestrates a static code-graph build and normalizes it into one Trace envelope (`data.graph`). + * Two modes share the provider + envelope: a `rooted` outgoing-call walk from an `--entry` function, and a `repo` + * map of a whole directory (every symbol, with containment + calls + inheritance). The provider is the injected + * collaborator (Dependency Inversion); a resolution/analysis failure becomes an error diagnostic on a still- + * well-formed envelope, matching how the run command surfaces engine failures — an agent always gets a Trace. */ export class GraphCommand extends TraceCommand { async run(request: GraphRequest): Promise { @@ -39,25 +44,46 @@ export class GraphCommand extends TraceCommand { let data = new TraceData({}); try { - // Resolve the entry to an absolute path, then auto-detect the project root from it when --root is absent - // (nearest tsconfig/package.json/.git ancestor — what an IDE does), so the common case is just --entry. - const baseDirectory = request.root ? resolve(request.root) : process.cwd(); - const entryFile = isAbsolute(request.entry.file) ? request.entry.file : resolve(baseDirectory, request.entry.file); - const root = request.root ? resolve(request.root) : findProjectRoot(entryFile); - const graph = await provider.callGraph({ ...request.entry, file: entryFile }, { - root, - maxDepth: request.maxDepth, - includeExternal: request.includeExternal ?? false, - maxNodes: request.maxNodes ?? MAX_NODES, - server: request.server, - }); - data = new TraceData({ graph }); - if (graph.stats.truncated) { - diagnostics.push(Diagnostic.warn(Code.GRAPH_TRUNCATED, `graph truncated at depth ${request.maxDepth} — raise --depth for more, or pick a more specific entry`)); + if (request.repo || !request.entry) { + // 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()); + 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, + }); + data = new TraceData({ graph }); + 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 , or raise --max-files`)); + } + // A relationship the server couldn't provide (e.g. no type hierarchy → no extends/implements) → a warn, so + // the missing edges are legible in the envelope instead of looking like the repo simply has no inheritance. + for (const note of graph.notes ?? []) diagnostics.push(Diagnostic.warn(Code.GRAPH_DEGRADED, note)); + } else { + // Rooted call walk: resolve the entry to an absolute path, then auto-detect the project root from it when + // --root is absent (nearest tsconfig/package.json/.git ancestor), so the common case is just --entry. + const baseDirectory = request.root ? resolve(request.root) : process.cwd(); + const entryFile = isAbsolute(request.entry.file) ? request.entry.file : resolve(baseDirectory, request.entry.file); + const root = request.root ? resolve(request.root) : findProjectRoot(entryFile); + const graph = await provider.callGraph({ ...request.entry, file: entryFile }, { + root, + maxDepth: request.maxDepth, + includeExternal: request.includeExternal ?? false, + maxNodes: request.maxNodes ?? MAX_NODES, + server: request.server, + }); + data = new TraceData({ graph }); + if (graph.stats.truncated) { + diagnostics.push(Diagnostic.warn(Code.GRAPH_TRUNCATED, `graph truncated at depth ${request.maxDepth} — raise --depth for more, or pick a more specific entry`)); + } } } catch (error: any) { diagnostics.push(Diagnostic.error(Code.CODEGRAPH_FAILED, String(error?.message ?? error).split("\n")[0])); - log.error("call graph failed", { code: Code.CODEGRAPH_FAILED, provider: provider.name, err: error }); + log.error("code graph failed", { code: Code.CODEGRAPH_FAILED, provider: provider.name, err: error }); } // `ok` derives from the diagnostics: a CODEGRAPH_FAILED error flips it false, GRAPH_TRUNCATED (warn) doesn't. @@ -70,11 +96,12 @@ export class GraphCommand extends TraceCommand { }); } - /** Human view: the call graph unrolled into a flow tree, with shared callees, cycles and externals marked. */ + /** Human view: a rooted call walk renders as a flow tree; a repo map renders as a per-file symbol outline. */ render(trace: Trace): string { const graph = trace.data.graph as CodeGraph | undefined; const guard = this.emptyRender(trace, !!graph?.nodes?.length, "graph", "no nodes"); - return guard !== undefined ? guard : GraphView.tree(graph!); + if (guard !== undefined) return guard; + return graph!.mode === "repo" ? GraphView.repoMap(graph!) : GraphView.tree(graph!); } /** HTML view: the same call graph as an interactive node-and-edge diagram (see {@link GraphView.callGraphHtml}). */ diff --git a/src/cli/commands/GraphView.ts b/src/cli/commands/GraphView.ts index 0f81c20..16a86f4 100644 --- a/src/cli/commands/GraphView.ts +++ b/src/cli/commands/GraphView.ts @@ -70,6 +70,64 @@ export class GraphView { return headerLines.concat(lines).join("\n"); } + /** + * Text view of a repo map: a per-file outline. Each file is a section; its symbols nest by the `contains` + * relationship (class → method/field), in source order, and each symbol is annotated with its other edges — + * `→ calls`, `extends`, `implements`. The structural backbone is containment; calls/inheritance hang off it, + * so the output reads like a code outline with cross-references rather than a flat call tree. + */ + static repoMap(graph: CodeGraph): string { + const nodesById = new Map(graph.nodes.map((node) => [node.id, node])); + const containment = new Map(); // parent id → child ids (the `contains` edges) + const relations = new Map(); // node id → its non-containment outgoing edges + for (const edge of graph.edges) { + if (edge.kind === "contains") (containment.get(edge.from) ?? containment.set(edge.from, []).get(edge.from)!).push(edge.to); + else (relations.get(edge.from) ?? relations.set(edge.from, []).get(edge.from)!).push(edge); + } + + const kindSummary = Object.entries(graph.stats.edgeKinds ?? {}).map(([kind, count]) => `${kind}:${count}`).join(" · "); + const headerLines = [ + `repo map — ${graph.root.split("/").pop() || graph.root} via ${graph.provider}`, + ` ${graph.stats.files ?? 0} files · ${graph.stats.nodes} symbols · ${graph.stats.edges} edges` + + (kindSummary ? ` (${kindSummary})` : "") + (graph.stats.truncated ? " · truncated" : ""), + "", + ]; + + const relationSuffix = (id: string): string => { + const edgesOut = relations.get(id) ?? []; + if (!edgesOut.length) return ""; + const byKind = new Map(); + for (const edge of edgesOut) { + const target = nodesById.get(edge.to); + const label = target ? (target.scope === "local" ? target.label : `${target.label} ⊗`) : edge.to; + (byKind.get(edge.kind) ?? byKind.set(edge.kind, []).get(edge.kind)!).push(label); + } + const verbFor: Record = { calls: "→ calls", extends: "extends", implements: "implements", references: "← refs" }; + const parts = [...byKind].map(([kind, labels]) => `${verbFor[kind] ?? kind} ${labels.slice(0, 6).join(", ")}${labels.length > 6 ? ", …" : ""}`); + return " " + parts.join(" · "); + }; + + const childrenOf = (id: string): GraphNode[] => + (containment.get(id) ?? []).map((childId) => nodesById.get(childId)).filter((node): node is GraphNode => !!node).sort((a, b) => a.location.line - b.location.line); + + const lines: string[] = []; + const walk = (node: GraphNode, prefix: string, connector: string): void => { + const kindTag = node.kind && node.kind !== "file" && node.kind !== node.label ? `${node.kind} ` : ""; + lines.push(`${prefix}${connector}${kindTag}${node.label}${relationSuffix(node.id)}`); + const children = childrenOf(node.id); + const childPrefix = prefix + (connector.startsWith("└") ? " " : "│ "); + children.forEach((child, index) => walk(child, childPrefix, index === children.length - 1 ? "└─ " : "├─ ")); + }; + + for (const file of graph.nodes.filter((node) => node.kind === "file").sort((a, b) => a.id.localeCompare(b.id))) { + lines.push(file.id); + const children = childrenOf(file.id); + children.forEach((child, index) => walk(child, "", index === children.length - 1 ? "└─ " : "├─ ")); + lines.push(""); + } + return headerLines.concat(lines).join("\n"); + } + /** * HTML view: the call graph drawn as an actual node-and-edge diagram — a self-contained, zero-dependency * interactive page. Every `graph.nodes` entry is a circle, every `graph.edges` entry a directed arrow, laid @@ -89,18 +147,21 @@ export class GraphView { // The graph IS the data: no traversal/dedup here — nodes and edges go to the force renderer verbatim. Cycles // are just edges that close a loop; a recursive call is a self-edge. Entry is accented, externals are amber. + const isRepo = graph.mode === "repo"; const root = graph.nodes.find((node) => node.id === graph.entry); - const stats = - `${graph.stats.nodes} nodes · ${graph.stats.edges} edges · depth≤${graph.stats.maxDepth}` + - (graph.stats.external ? ` · ${graph.stats.external} external` : "") + - (graph.stats.truncated ? " · truncated" : ""); + const repoName = graph.root.split("/").pop() || graph.root; + const stats = isRepo + ? `${graph.stats.files ?? 0} files · ${graph.stats.nodes} symbols · ${graph.stats.edges} edges` + + (graph.stats.external ? ` · ${graph.stats.external} external` : "") + (graph.stats.truncated ? " · truncated" : "") + : `${graph.stats.nodes} nodes · ${graph.stats.edges} edges · depth≤${graph.stats.maxDepth}` + + (graph.stats.external ? ` · ${graph.stats.external} external` : "") + (graph.stats.truncated ? " · truncated" : ""); return GraphView.forceGraphDoc( { - title: `graph — ${root?.label ?? graph.entry}`, - h1: root?.label ?? graph.entry, - sub: `${root?.location.file ?? ""}${root?.location.line ? ":" + root.location.line : ""} · via ${graph.provider}`, + title: isRepo ? `repo map — ${repoName}` : `graph — ${root?.label ?? graph.entry}`, + h1: isRepo ? repoName : (root?.label ?? graph.entry), + sub: isRepo ? `${graph.stats.files ?? 0} files · via ${graph.provider}` : `${root?.location.file ?? ""}${root?.location.line ? ":" + root.location.line : ""} · via ${graph.provider}`, stats, - truncated: graph.stats.truncated ? "graph truncated — raise --depth for more, or pick a more specific entry" : undefined, + truncated: graph.stats.truncated ? (isRepo ? "repo map truncated — narrow with --entry , or raise --max-files" : "graph truncated — raise --depth for more, or pick a more specific entry") : undefined, }, { entry: graph.entry, diff --git a/src/codegraph/CodeGraphProvider.ts b/src/codegraph/CodeGraphProvider.ts index a1fece9..1a8b467 100644 --- a/src/codegraph/CodeGraphProvider.ts +++ b/src/codegraph/CodeGraphProvider.ts @@ -46,6 +46,29 @@ export interface CallGraphOptions { server?: string; } +/** + * Knobs for a whole-directory/repo map: discover every source file under `root`, then ask the server for the + * full picture of each — symbols (containment), calls, and inheritance — rather than walking out from one entry. + */ +export interface RepoGraphOptions { + /** Directory to map (already resolved to a real dir): the LSP workspace folder + relativization base. */ + root: string; + /** File extensions to scan; defaults to the bundled TS server's set. Discovery skips node_modules/dist/.git. */ + extensions?: string[]; + /** Hard cap on files opened — keeps a huge repo bounded (stats.truncated flips true when hit). */ + maxFiles: number; + /** Hard cap on node count, same role as in {@link CallGraphOptions}. */ + maxNodes: number; + /** Add `extends`/`implements` edges via the server's type hierarchy. Default on; skipped if unsupported. */ + inheritance?: boolean; + /** Add `references` edges (who-uses-each-symbol). Heavy (O(symbols × refs)) — opt-in, default off. */ + references?: boolean; + /** Keep edges to external (node_modules / outside-root) symbols, as leaf nodes. Default false. */ + includeExternal?: boolean; + /** LSP server launch command; defaults to the bundled TS server. */ + server?: string; +} + /** Scope of a node's source file: in the workspace, or an external dependency / outside the root. */ export type NodeScope = "local" | "external"; @@ -59,22 +82,33 @@ export interface GraphNode { external?: boolean; // true for scope !== "local" — convenience flag mirroring the schema } -/** A directed call edge. `weight` is the number of distinct call sites from→to. */ +/** A directed edge between two nodes. `weight` is the number of distinct sites (call sites / reference sites). */ export interface GraphEdge { from: string; to: string; - kind: string; // "calls" + kind: string; // "calls" | "contains" | "extends" | "implements" | "references" weight?: number; } -/** The built call graph: a normalized node/edge set rooted at `entry`, plus build provenance + stats. */ +/** + * The built graph: a normalized node/edge set plus build provenance + stats. `mode` says how it was built — + * "rooted" is the outward call walk from one `entry` (the entry node id); "repo" is the whole-directory map, + * where there is no single entry (`entry` is "") and edges carry every relationship kind, not just calls. + */ export interface CodeGraph { provider: string; root: string; - entry: string; // node id of the entry function + mode: "rooted" | "repo"; + entry: string; // node id of the entry function ("" for a repo map — no single root) nodes: GraphNode[]; edges: GraphEdge[]; - stats: { nodes: number; edges: number; maxDepth: number; truncated: boolean; external: number }; + stats: { + nodes: number; edges: number; maxDepth: number; truncated: boolean; external: number; + files?: number; // repo mode: how many source files were mapped + edgeKinds?: Record; // repo mode: count per relationship kind (contains/calls/extends/…) + }; + /** Human-readable notes about a partial build (e.g. a relationship the server couldn't provide) → warn diagnostics. */ + notes?: string[]; } /** Result of probing whether a provider can run here (binary present / library resolvable). */ @@ -90,4 +124,6 @@ export interface CodeGraphProvider { isAvailable(root: string): Promise; /** Build the outgoing-call graph rooted at `entry`. Throws with a clear message on unresolvable input. */ callGraph(entry: EntryReference, opts: CallGraphOptions): Promise; + /** Map a whole directory: every symbol the server reports, with containment + calls + inheritance edges. */ + repoGraph(opts: RepoGraphOptions): Promise; } diff --git a/src/codegraph/LspCodeGraphProvider.ts b/src/codegraph/LspCodeGraphProvider.ts index db073d3..cee29f7 100644 --- a/src/codegraph/LspCodeGraphProvider.ts +++ b/src/codegraph/LspCodeGraphProvider.ts @@ -6,16 +6,21 @@ import { DocumentSymbolRequest, CallHierarchyPrepareRequest, CallHierarchyOutgoingCallsRequest, + TypeHierarchyPrepareRequest, + TypeHierarchySupertypesRequest, SymbolKind, type CallHierarchyItem, type CallHierarchyOutgoingCall, type DocumentSymbol, type Position, + type Range, type SymbolInformation, + type TypeHierarchyItem, } from "vscode-languageserver-protocol"; -import type { CallGraphOptions, CodeGraph, CodeGraphProvider, EntryReference, GraphEdge, GraphNode, NodeScope, ProviderAvailability } from "./CodeGraphProvider.js"; +import type { CallGraphOptions, CodeGraph, CodeGraphProvider, EntryReference, GraphEdge, GraphNode, NodeScope, ProviderAvailability, RepoGraphOptions } from "./CodeGraphProvider.js"; import { LspClient, resolveServer, defaultTsServer } from "./LspClient.js"; +import { discoverSourceFiles } from "./sourceFiles.js"; import { logger } from "../shared/logger.js"; import { sleep } from "../shared/sleep.js"; @@ -152,6 +157,7 @@ export class LspCodeGraphProvider implements CodeGraphProvider { const graph: CodeGraph = { provider: this.name, root, + mode: "rooted", entry: rootNode.id, nodes: nodeList, edges, @@ -164,6 +170,195 @@ export class LspCodeGraphProvider implements CodeGraphProvider { } } + /** + * repoGraph — the whole-directory map. Discovers every source file under `root`, opens them all, then asks the + * server for the full structure of each: `documentSymbol` for containment (file → class → method/field) and the + * node kinds, `callHierarchy/outgoingCalls` per callable for `calls` edges, and `typeHierarchy/supertypes` per + * class/interface for `extends`/`implements` edges. Each pass is capability-guarded, so it degrades gracefully on + * servers that lack call/type hierarchy. The result is the same normalized {@link CodeGraph} the rooted walk + * produces (one schema, one renderer), tagged `mode: "repo"` with no single entry. + */ + async repoGraph(options: RepoGraphOptions): Promise { + const root = resolve(options.root); + const rootUri = pathToFileURL(root + "/").toString(); + const rootPath = fileURLToPath(rootUri).replace(/\\/g, "/"); + const discovery = discoverSourceFiles(root, { extensions: options.extensions, maxFiles: options.maxFiles }); + if (!discovery.files.length) throw new Error(`no source files found under ${relativePath(process.cwd(), root)} (checked the default source extensions)`); + + const client = new LspClient(resolveServer(options.server, discovery.files[0])); + try { + const initializeResult = await client.initialize({ + processId: process.pid, + rootUri, + workspaceFolders: [{ uri: rootUri, name: "root" }], + capabilities: { + textDocument: { + documentSymbol: { hierarchicalDocumentSymbolSupport: true }, + callHierarchy: { dynamicRegistration: false }, + typeHierarchy: { dynamicRegistration: false }, + }, + }, + }); + const capabilities = initializeResult.capabilities; + const hasCallHierarchy = !!capabilities.callHierarchyProvider; + const hasTypeHierarchy = !!capabilities.typeHierarchyProvider; + + const opened = new Set(); + 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); + }; + for (const file of discovery.files) open(pathToFileURL(file).toString()); + + const scopeOf = (uri: string): NodeScope => { + const filePath = fileURLToPath(uri).replace(/\\/g, "/"); + return filePath.startsWith(rootPath) && !filePath.includes("/node_modules/") ? "local" : "external"; + }; + + const nodes = new Map(); + const edges: GraphEdge[] = []; + const edgeKeys = new Set(); + const notes: string[] = []; + let truncated = discovery.truncated; + const atNodeCap = (): boolean => nodes.size >= options.maxNodes; + + const addEdge = (from: string, to: string, kind: GraphEdge["kind"], weight?: number): void => { + if (from === to && kind === "contains") return; // a symbol can't contain itself + const key = `${kind}:${from}->${to}`; + if (edgeKeys.has(key)) return; + edgeKeys.add(key); + edges.push({ from, to, kind, ...(weight ? { weight } : {}) }); + }; + + // Add (or fetch) a node for an LSP hierarchy item (call/type). Local items always join; external ones only + // when asked, so the map stays inside the repo by default. Returns the node id, or null when skipped. + const ensureItemNode = (item: { uri: string; kind: SymbolKind; name: string; range: Range; selectionRange: Range }): string | null => { + const scope = scopeOf(item.uri); + if (scope === "external" && !options.includeExternal) return null; + const file = relativePath(root, fileURLToPath(item.uri)); + const start = item.selectionRange.start; + const id = `${file}:${start.line + 1}:${start.character + 1}`; + if (!nodes.has(id)) { + if (atNodeCap()) { truncated = true; return null; } + const node: GraphNode = { + id, kind: symbolKindName(item.kind), label: item.name, + location: { file, line: start.line + 1, column: start.character + 1, endLine: item.range.end.line + 1 }, + scope, + }; + if (scope !== "local") node.external = true; + nodes.set(id, node); + } + return id; + }; + + // ── pass 1: containment + node kinds, from documentSymbol ────────────────────────────────────────────── + const callables: Array<{ uri: string; position: Position; nodeId: string }> = []; + const types: Array<{ uri: string; position: Position; kind: SymbolKind; nodeId: string }> = []; + + for (const file of discovery.files) { + if (atNodeCap()) { truncated = true; break; } + const uri = pathToFileURL(file).toString(); + const relFile = relativePath(root, file); + const fileId = relFile; + if (!nodes.has(fileId)) nodes.set(fileId, { id: fileId, kind: "file", label: relFile.split("/").pop() ?? relFile, location: { file: relFile, line: 1 }, scope: "local" }); + + let symbols: Array | null = null; + try { symbols = await client.request | null>(DocumentSymbolRequest.type, { textDocument: { uri } }); } catch (error) { + log.debug("documentSymbol failed", { file: relFile, err: String(error) }); + } + + const addSymbol = (symbol: DocumentSymbol | SymbolInformation, parentId: string): void => { + if (atNodeCap()) { truncated = true; return; } + // DocumentSymbol (hierarchical) carries selectionRange + children; SymbolInformation (flat) only a location. + const position = "selectionRange" in symbol ? symbol.selectionRange.start : symbol.location.range.start; + const endLine = ("selectionRange" in symbol ? symbol.range.end.line : symbol.location.range.end.line) + 1; + const id = `${relFile}:${position.line + 1}:${position.character + 1}`; + if (!nodes.has(id)) nodes.set(id, { + id, kind: symbolKindName(symbol.kind), label: symbol.name, + location: { file: relFile, line: position.line + 1, column: position.character + 1, endLine }, + scope: "local", + }); + addEdge(parentId, id, "contains"); + if (CALLABLE_KINDS.has(symbol.kind)) callables.push({ uri, position, nodeId: id }); + if (TYPE_KINDS.has(symbol.kind)) types.push({ uri, position, kind: symbol.kind, nodeId: id }); + if ("selectionRange" in symbol) for (const child of symbol.children ?? []) addSymbol(child, id); + }; + for (const symbol of symbols ?? []) addSymbol(symbol, fileId); + } + + // ── pass 2: calls, from callHierarchy/outgoingCalls per callable ─────────────────────────────────────── + if (hasCallHierarchy) { + for (const callable of callables) { + if (atNodeCap()) { truncated = true; break; } + let prepared: CallHierarchyItem[] | null = null; + try { prepared = await client.request(CallHierarchyPrepareRequest.type, { textDocument: { uri: callable.uri }, position: callable.position }); } catch { /* unresolvable position */ } + const item = prepared?.[0]; + if (!item) continue; + let outgoing: CallHierarchyOutgoingCall[] | null = null; + try { outgoing = await client.request(CallHierarchyOutgoingCallsRequest.type, { item }); } catch (error) { + log.debug("outgoing calls failed", { id: callable.nodeId, err: String(error) }); + } + for (const call of outgoing ?? []) { + const targetId = ensureItemNode(call.to); + if (targetId) addEdge(callable.nodeId, targetId, "calls", call.fromRanges?.length); + } + } + } + + // ── pass 3: inheritance, from typeHierarchy/supertypes per class/interface ───────────────────────────── + if (hasTypeHierarchy && options.inheritance !== false) { + for (const type of types) { + if (atNodeCap()) { truncated = true; break; } + let prepared: TypeHierarchyItem[] | null = null; + try { prepared = await client.request(TypeHierarchyPrepareRequest.type, { textDocument: { uri: type.uri }, position: type.position }); } catch { /* unresolvable */ } + const item = prepared?.[0]; + if (!item) continue; + let supertypes: TypeHierarchyItem[] | null = null; + try { supertypes = await client.request(TypeHierarchySupertypesRequest.type, { item }); } catch (error) { + log.debug("supertypes failed", { id: type.nodeId, err: String(error) }); + } + for (const supertype of supertypes ?? []) { + const targetId = ensureItemNode(supertype); + if (!targetId) continue; + // A class reaching an interface "implements" it; everything else (class→class, interface→interface) "extends". + const kind = type.kind === SymbolKind.Class && supertype.kind === SymbolKind.Interface ? "implements" : "extends"; + addEdge(type.nodeId, targetId, kind); + } + } + } else if (!hasTypeHierarchy && options.inheritance !== false && types.length) { + // The bundled typescript-language-server doesn't advertise a typeHierarchyProvider, so extends/implements + // can't be derived here. Record it as a note (→ a warn diagnostic) rather than silently omitting it. + notes.push(`inheritance edges (extends/implements) unavailable — the '${this.name}' server has no type-hierarchy support; containment + calls are still mapped`); + log.debug("server has no typeHierarchyProvider — skipping inheritance edges"); + } + + const nodeList = [...nodes.values()]; + const edgeKinds: Record = {}; + for (const edge of edges) edgeKinds[edge.kind] = (edgeKinds[edge.kind] ?? 0) + 1; + const graph: CodeGraph = { + provider: this.name, + root, + mode: "repo", + entry: "", + nodes: nodeList, + edges, + stats: { + nodes: nodeList.length, edges: edges.length, maxDepth: 0, truncated, + external: nodeList.filter((node) => node.external).length, + files: discovery.files.length, edgeKinds, + }, + ...(notes.length ? { notes } : {}), + }; + log.info("repo map built", { files: discovery.files.length, nodes: graph.stats.nodes, edges: graph.stats.edges, edgeKinds, truncated }); + return graph; + } finally { + await client.dispose(); + } + } + /** Resolve an entry to an LSP position: an explicit line:column is used directly; otherwise via `documentSymbol`. */ async #resolvePosition(client: LspClient, fileUri: string, entry: EntryReference): Promise { if (entry.line != null && entry.column != null) return { line: entry.line - 1, character: entry.column - 1 }; @@ -207,15 +402,29 @@ function languageIdFor(uri: string): string { } const KIND_NAMES: Partial> = { + [SymbolKind.File]: "file", + [SymbolKind.Module]: "module", + [SymbolKind.Namespace]: "namespace", + [SymbolKind.Package]: "package", [SymbolKind.Function]: "function", [SymbolKind.Method]: "method", [SymbolKind.Constructor]: "constructor", [SymbolKind.Class]: "class", [SymbolKind.Interface]: "interface", + [SymbolKind.Struct]: "struct", + [SymbolKind.Enum]: "enum", + [SymbolKind.EnumMember]: "enum-member", [SymbolKind.Property]: "property", [SymbolKind.Field]: "field", + [SymbolKind.Constant]: "constant", [SymbolKind.Variable]: "variable", + [SymbolKind.TypeParameter]: "type-parameter", }; function symbolKindName(kind: SymbolKind): string { return KIND_NAMES[kind] ?? "symbol"; } + +/** Symbols that can issue calls — the call-hierarchy pass prepares one of these per node. */ +const CALLABLE_KINDS = new Set([SymbolKind.Function, SymbolKind.Method, SymbolKind.Constructor]); +/** Symbols that can participate in a type hierarchy (extends/implements) — the inheritance pass walks these. */ +const TYPE_KINDS = new Set([SymbolKind.Class, SymbolKind.Interface, SymbolKind.Struct]); diff --git a/src/codegraph/sourceFiles.ts b/src/codegraph/sourceFiles.ts new file mode 100644 index 0000000..664d341 --- /dev/null +++ b/src/codegraph/sourceFiles.ts @@ -0,0 +1,70 @@ +import { readdirSync, statSync } from "node:fs"; +import { extname, join, resolve } from "node:path"; +import { findProjectRoot } from "../shared/projectRoot.js"; + +/** 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"]; + +/** Directories never worth walking: dependencies, build output, VCS metadata, caches. */ +const IGNORED_DIRS = new Set([ + "node_modules", ".git", "dist", "build", "out", ".next", ".turbo", ".cache", + "coverage", "vendor", "__pycache__", ".venv", "venv", +]); + +/** True if `path` exists and is a directory (false for a file or a missing path — never throws). */ +export function isDirectory(path: string): boolean { + try { return statSync(path).isDirectory(); } catch { return false; } +} + +/** + * Resolve the directory a repo map should cover: a directory is used as-is; anything else (a file, or a marker + * inside a project) resolves to the nearest project root (tsconfig/package.json/.git ancestor). Absolute path. + */ +export function resolveRepoRoot(path: string): string { + const absolute = resolve(path); + return isDirectory(absolute) ? absolute : findProjectRoot(absolute); +} + +export interface DiscoverOptions { + /** Extensions to include (lower-cased on compare). Defaults to {@link DEFAULT_SOURCE_EXTENSIONS}. */ + extensions?: string[]; + /** Stop after this many files — keeps a huge repo bounded; `truncated` reports when the cap was hit. */ + maxFiles: number; +} + +export interface Discovery { + /** Absolute paths to the source files found, in a deterministic (sorted) order. */ + files: string[]; + /** True if the scan stopped at `maxFiles` (more files exist than were returned). */ + truncated: boolean; +} + +/** + * Recursively collect source files under `root`: extension-filtered, skipping dependency/build/VCS directories, + * hidden entries (dot-files/dirs) and `.d.ts` declaration files (no implementation symbols to map). Deterministic + * order (directories walked in sorted order) and bounded by `maxFiles`, so a repo map is reproducible and finite. + */ +export function discoverSourceFiles(root: string, options: DiscoverOptions): Discovery { + const extensions = new Set((options.extensions ?? DEFAULT_SOURCE_EXTENSIONS).map((extension) => extension.toLowerCase())); + const files: string[] = []; + let truncated = false; + + const walk = (directory: string): void => { + if (files.length >= options.maxFiles) { truncated = true; return; } + let entries; + try { entries = readdirSync(directory, { withFileTypes: true }); } catch { return; } + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.name.startsWith(".")) continue; // hidden files/dirs (.git, .vscode, dot-configs) + const fullPath = join(directory, entry.name); + if (entry.isDirectory()) { + 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); + } + } + }; + + walk(resolve(root)); + return { files, truncated }; +} diff --git a/src/io/InputManager.ts b/src/io/InputManager.ts index 9434929..dfb904e 100644 --- a/src/io/InputManager.ts +++ b/src/io/InputManager.ts @@ -1,5 +1,8 @@ +import { isAbsolute, resolve } from "node:path"; + import { TargetKind } from "../domain/Target.js"; import { EntryReference } from "../codegraph/CodeGraphProvider.js"; +import { isDirectory } from "../codegraph/sourceFiles.js"; import { DEFAULT_NODE_PORT } from "../shared/defaults.js"; import { InputValidator } from "./InputValidator.js"; import type { @@ -58,16 +61,40 @@ export class InputManager { return { request, emit: raw.emit }; } - /** Validate + normalize a `graph` invocation: an entry anchor (file:line[:column] or file@symbol) + depth. */ + /** + * Validate + normalize a `graph` invocation. Two modes: a rooted call walk when `--entry` carries an anchor + * (file:line[:column] or file@symbol), or a whole-directory **repo map** when `--entry` is omitted or names a + * directory. The rooted path keeps the strict entry-anchor validation; the repo path needs no anchor. + */ acceptGraph(raw: RawGraphInput): GraphRequest { - const entry = EntryReference.parse(raw.entry); - this.#validator.validateGraph({ file: entry.file, line: entry.line, column: entry.column, symbol: entry.symbol, depth: raw.depth }); + const entryString = raw.entry?.trim(); + const entry = entryString ? EntryReference.parse(entryString) : undefined; + const baseDirectory = resolve(raw.root ?? process.cwd()); + const absoluteEntry = entry ? (isAbsolute(entry.file) ? entry.file : resolve(baseDirectory, entry.file)) : undefined; + // Repo map: no --entry, or a --entry that is a directory (no line/symbol anchor and it resolves to a dir). + const repo = !entry || (entry.line == null && entry.symbol == null && isDirectory(absoluteEntry!)); + + if (repo) { + return { + repo: true, + root: entry ? absoluteEntry : raw.root, // a directory --entry IS the root to map; else --root/cwd (GraphCommand detects) + maxDepth: raw.depth, + 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 } : {}) }, + }; + } + + this.#validator.validateGraph({ file: entry!.file, line: entry!.line, column: entry!.column, symbol: entry!.symbol, depth: raw.depth }); return { entry, root: raw.root, // optional — GraphCommand auto-detects the project root from the entry when absent 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 }, }; } diff --git a/src/io/descriptors.ts b/src/io/descriptors.ts index 0241f3c..784c4ec 100644 --- a/src/io/descriptors.ts +++ b/src/io/descriptors.ts @@ -35,10 +35,13 @@ export interface RawRunInput { } export interface RawGraphInput { - entry: string; + entry?: string; // file:line / file@symbol → rooted call walk; a dir or omitted → repo map root?: string; server?: string; depth: number; + maxFiles?: number; // repo map: cap on files scanned + includeExternal?: boolean; // repo map: keep edges to node_modules / outside-root symbols + inheritance?: boolean; // repo map: commander --no-inheritance sets this false (skip type hierarchy) html?: string | boolean; json?: string | boolean; concise?: boolean; diff --git a/src/shared/codes.ts b/src/shared/codes.ts index 77863a7..446ad48 100644 --- a/src/shared/codes.ts +++ b/src/shared/codes.ts @@ -42,6 +42,8 @@ export const Code = { CODEGRAPH_FAILED: "CODEGRAPH_FAILED", /** the call graph hit the depth/size cap and was truncated */ GRAPH_TRUNCATED: "GRAPH_TRUNCATED", + /** the map is missing a relationship the language server can't provide (e.g. no type-hierarchy support) */ + GRAPH_DEGRADED: "GRAPH_DEGRADED", /** the language server could not be spawned or connected */ LSP: "LSP_SPAWN_FAILED", /** module-import analysis (madge) failed */ diff --git a/test/codegraph.test.js b/test/codegraph.test.js index e9f6ee6..971993e 100644 --- a/test/codegraph.test.js +++ b/test/codegraph.test.js @@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url"; import { LspCodeGraphProvider } from "../dist/codegraph/LspCodeGraphProvider.js"; import { createCodeGraphProvider, CODEGRAPH_PROVIDERS } from "../dist/codegraph/createCodeGraphProvider.js"; import { GraphCommand } from "../dist/cli/commands/GraphCommand.js"; +import { discoverSourceFiles, resolveRepoRoot, isDirectory } from "../dist/codegraph/sourceFiles.js"; const ROOT = fileURLToPath(new URL("./fixtures/codegraph", import.meta.url)); const build = (entry, extra) => new LspCodeGraphProvider().callGraph(entry, { root: ROOT, maxDepth: 10, includeExternal: false, maxNodes: 500, ...extra }); @@ -73,3 +74,43 @@ test("a non-existent entry fails into an error envelope, not a throw", async () assert.equal(trace.ok, false); assert.ok(trace.diagnostics.some((d) => d.code === "CODEGRAPH_FAILED")); }); + +// ── repo map: the whole-directory mode ─────────────────────────────────────────────────────────────────────── + +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); + assert.equal(discoverSourceFiles(ROOT, { maxFiles: 1 }).truncated, true, "the file cap flips truncated"); + assert.ok(isDirectory(ROOT) && !isDirectory(ROOT + "/sample.ts")); + // a file resolves to its nearest project root (this repo's root has package.json/tsconfig/.git) + assert.ok(resolveRepoRoot(ROOT + "/sample.ts").length > 0); +}); + +test("repoGraph maps every file's symbols with containment + call edges (mode: repo, no single entry)", async () => { + const g = await new LspCodeGraphProvider().repoGraph({ root: ROOT, maxFiles: 100, maxNodes: 500 }); + assert.equal(g.mode, "repo"); + assert.equal(g.entry, "", "a repo map has no single entry"); + assert.equal(g.stats.files, 2); + // file nodes exist, and each contains its top-level functions + const fileNode = g.nodes.find((n) => n.kind === "file" && n.id === "sample.ts"); + assert.ok(fileNode, "sample.ts is a file node"); + const entryNode = g.nodes.find((n) => n.label === "entry" && n.kind === "function"); + assert.ok(entryNode, "entry is a function node"); + assert.ok(g.edges.some((e) => e.kind === "contains" && e.from === "sample.ts" && e.to === entryNode.id), "file contains entry"); + // call edges are present too (entry → alpha), and counted per kind + const alpha = g.nodes.find((n) => n.label === "alpha"); + assert.ok(g.edges.some((e) => e.kind === "calls" && e.from === entryNode.id && e.to === alpha.id), "entry → alpha (calls)"); + assert.ok((g.stats.edgeKinds.contains ?? 0) > 0 && (g.stats.edgeKinds.calls ?? 0) > 0); +}); + +test("GraphCommand repo mode → a valid envelope rendered as a per-file outline", async () => { + const trace = await new GraphCommand().run({ repo: true, root: ROOT, maxDepth: 6, maxNodes: 500 }); + assert.equal(trace.command, "graph.lsp"); + assert.equal(trace.ok, true); + assert.equal(trace.validate().length, 0, trace.validate().join("; ")); + const out = new GraphCommand().render(trace); + assert.match(out, /repo map/); + assert.match(out, /sample\.ts/); + assert.match(out, /entry.*→ calls/); +}); From b63d5831802a47e06c246b54fbf0e93a48a37016 Mon Sep 17 00:00:00 2001 From: burrows99 Date: Fri, 19 Jun 2026 14:34:33 +0100 Subject: [PATCH 2/4] =?UTF-8?q?fix(graph):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20root=20detection,=20meta.args,=20languageId,=20test?= =?UTF-8?q?=20portability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/cli/commands/GraphCommand.ts | 9 +++++---- src/codegraph/LspCodeGraphProvider.ts | 17 +++++++++++------ src/io/InputManager.ts | 12 ++++++++++-- src/shared/projectRoot.ts | 14 +++++++++----- test/codegraph.test.js | 2 +- 5 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/cli/commands/GraphCommand.ts b/src/cli/commands/GraphCommand.ts index e1a3f59..5a4ea58 100644 --- a/src/cli/commands/GraphCommand.ts +++ b/src/cli/commands/GraphCommand.ts @@ -4,7 +4,7 @@ import { Trace, TraceData } from "../../domain/Trace.js"; import { Diagnostic } from "../../domain/Diagnostic.js"; import { logger } from "../../shared/logger.js"; import { Code } from "../../shared/codes.js"; -import { findProjectRoot } from "../../shared/projectRoot.js"; +import { findProjectRoot, findProjectRootFrom } from "../../shared/projectRoot.js"; import { createCodeGraphProvider } from "../../codegraph/createCodeGraphProvider.js"; import type { CodeGraph, EntryReference } from "../../codegraph/CodeGraphProvider.js"; import { resolveRepoRoot } from "../../codegraph/sourceFiles.js"; @@ -45,9 +45,10 @@ export class GraphCommand extends TraceCommand { try { if (request.repo || !request.entry) { - // 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()); + // Repo map: an explicit --root/dir is mapped as given; with neither, detect the nearest project root by + // walking UP from cwd (tsconfig/package.json/.git) — so a bare `trace graph` in a subdir maps the whole + // project, not just that subdir. `resolveRepoRoot` keeps an explicit file argument pointing at its root. + const root = request.root ? resolveRepoRoot(request.root) : findProjectRootFrom(process.cwd()); const graph = await provider.repoGraph({ root, maxFiles: request.maxFiles ?? MAX_FILES, diff --git a/src/codegraph/LspCodeGraphProvider.ts b/src/codegraph/LspCodeGraphProvider.ts index cee29f7..107cf9c 100644 --- a/src/codegraph/LspCodeGraphProvider.ts +++ b/src/codegraph/LspCodeGraphProvider.ts @@ -392,13 +392,18 @@ function relativePath(root: string, file: string): string { return (relativized.startsWith("..") || isAbsolute(relativized) ? file : relativized).replace(/\\/g, "/"); } +/** LSP language ids by extension. The repo map runs against non-TS servers too (gopls/pyright/…), so a wrong + * id can stop a server from parsing a file at all — map the languages we name a default server for, and fall + * back to the bare extension (a sane best-effort) rather than always claiming "typescript". */ +const LANGUAGE_ID_BY_EXT: Record = { + ".ts": "typescript", ".mts": "typescript", ".cts": "typescript", ".tsx": "typescriptreact", + ".js": "javascript", ".mjs": "javascript", ".cjs": "javascript", ".jsx": "javascriptreact", + ".go": "go", ".py": "python", ".rs": "rust", ".rb": "ruby", + ".c": "c", ".h": "c", ".cc": "cpp", ".cpp": "cpp", ".hpp": "cpp", ".java": "java", +}; function languageIdFor(uri: string): string { - switch (extname(fileURLToPath(uri)).toLowerCase()) { - case ".tsx": return "typescriptreact"; - case ".jsx": return "javascriptreact"; - case ".js": case ".mjs": case ".cjs": return "javascript"; - default: return "typescript"; - } + const extension = extname(fileURLToPath(uri)).toLowerCase(); + return LANGUAGE_ID_BY_EXT[extension] ?? extension.replace(/^\./, "") ?? "plaintext"; } const KIND_NAMES: Partial> = { diff --git a/src/io/InputManager.ts b/src/io/InputManager.ts index dfb904e..494c64c 100644 --- a/src/io/InputManager.ts +++ b/src/io/InputManager.ts @@ -83,7 +83,12 @@ export class InputManager { 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 } : {}) }, + // meta.args is the only portable record of the invocation — keep every flag that changes the map. + args: { + ...(entryString ? { entry: entryString } : {}), ...(raw.root ? { root: raw.root } : {}), ...(raw.server ? { server: raw.server } : {}), + ...(raw.maxFiles ? { maxFiles: raw.maxFiles } : {}), ...(raw.includeExternal ? { includeExternal: true } : {}), + ...(raw.inheritance === false ? { inheritance: false } : {}), + }, }; } @@ -94,7 +99,10 @@ export class InputManager { maxDepth: raw.depth, includeExternal: raw.includeExternal, server: raw.server, - args: { entry: entryString, ...(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, + ...(raw.includeExternal ? { includeExternal: true } : {}), + }, }; } diff --git a/src/shared/projectRoot.ts b/src/shared/projectRoot.ts index b7a5cde..d8edbb7 100644 --- a/src/shared/projectRoot.ts +++ b/src/shared/projectRoot.ts @@ -3,14 +3,18 @@ import { dirname, join } from "node:path"; const ROOT_MARKERS = ["tsconfig.json", "jsconfig.json", "package.json", ".git"]; -/** Auto-detect the project root: the nearest ancestor of `file` containing a project marker, else its dir. */ -export function findProjectRoot(file: string): string { - const startDirectory = dirname(file); - let directory = startDirectory; +/** Auto-detect the project root from a directory: the nearest self-or-ancestor with a project marker, else `dir`. */ +export function findProjectRootFrom(dir: string): string { + let directory = dir; for (;;) { if (ROOT_MARKERS.some((marker) => existsSync(join(directory, marker)))) return directory; const parentDirectory = dirname(directory); - if (parentDirectory === directory) return startDirectory; // reached the filesystem root with no marker + if (parentDirectory === directory) return dir; // reached the filesystem root with no marker directory = parentDirectory; } } + +/** Auto-detect the project root: the nearest ancestor of `file` containing a project marker, else its dir. */ +export function findProjectRoot(file: string): string { + return findProjectRootFrom(dirname(file)); +} diff --git a/test/codegraph.test.js b/test/codegraph.test.js index 971993e..303430d 100644 --- a/test/codegraph.test.js +++ b/test/codegraph.test.js @@ -79,7 +79,7 @@ test("a non-existent entry fails into an error envelope, not a throw", async () 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.deepEqual(found.files.map((f) => f.split(/[/\\]/).pop()).sort(), ["helper.ts", "sample.ts"]); assert.equal(found.truncated, false); assert.equal(discoverSourceFiles(ROOT, { maxFiles: 1 }).truncated, true, "the file cap flips truncated"); assert.ok(isDirectory(ROOT) && !isDirectory(ROOT + "/sample.ts")); From a2e0124018c682a45f6aca641d55fc7361ae36d4 Mon Sep 17 00:00:00 2001 From: burrows99 Date: Fri, 19 Jun 2026 14:49:38 +0100 Subject: [PATCH 3/4] =?UTF-8?q?feat(export):=20`exports`=20command=20?= =?UTF-8?q?=E2=80=94=20skill=20+=20repo-map=20&=20deps=20HTML=20into=20one?= =?UTF-8?q?=20export=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cli/Cli.ts | 17 ++--- src/cli/commands/ExportCommand.ts | 86 ++++++++++++++++++++++++++ src/cli/commands/ExportSkillCommand.ts | 48 -------------- src/index.ts | 2 +- test/export.test.js | 47 ++++++++++++++ test/manifest.test.js | 2 +- 6 files changed, 144 insertions(+), 58 deletions(-) create mode 100644 src/cli/commands/ExportCommand.ts delete mode 100644 src/cli/commands/ExportSkillCommand.ts create mode 100644 test/export.test.js diff --git a/src/cli/Cli.ts b/src/cli/Cli.ts index 8da0da8..df28cfe 100644 --- a/src/cli/Cli.ts +++ b/src/cli/Cli.ts @@ -4,7 +4,7 @@ import { writeFileSync } from "node:fs"; import { ManifestCommand } from "./commands/ManifestCommand.js"; import { SchemaCommand } from "./commands/SchemaCommand.js"; import { ServeCommand } from "./commands/ServeCommand.js"; -import { ExportSkillCommand } from "./commands/ExportSkillCommand.js"; +import { ExportCommand } from "./commands/ExportCommand.js"; import { InputManager } from "../io/InputManager.js"; import { InputError } from "../io/InputError.js"; import { ProcessingManager, EngineAbortError } from "../io/ProcessingManager.js"; @@ -191,15 +191,16 @@ export class Cli { process.exit(0); }); - program.command("export-skill") - .description("copy the bundled `trace` skill into a project's .claude/skills/ so Claude Code picks it up") - .argument("[dir]", "target project root (default: current directory)") - .option("--force", "overwrite an existing .claude/skills/trace") - .action((dir, options) => { + program.command("exports") + .description("provision a project's export directory (.claude/skills/trace): copy the bundled `trace` skill so Claude Code picks it up, AND build interactive HTML maps of the project — the whole-repo LSP map (graph.html) and the module-import graph (deps.html). One command to get everything.") + .argument("[dir]", "target project root (default: current directory) — also the project the maps are built from") + .option("--force", "overwrite an existing export directory (.claude/skills/trace)") + .action(async (dir, options) => { try { - const { src: source, dest: destination } = new ExportSkillCommand().run({ dir, force: options.force }); + const { dest: destination, maps } = await new ExportCommand().run({ dir, force: options.force }); process.stdout.write(`[trace-cli] skill exported → ${destination}\n`); - log.info("skill exported", { src: source, dest: destination }); + for (const map of maps) process.stdout.write(`[trace-cli] ${map.kind} map → ${map.path}${map.ok ? "" : " (empty/degraded — see the page)"}\n`); + log.info("export complete", { dest: destination, maps: maps.map((m) => ({ kind: m.kind, ok: m.ok })) }); process.exit(0); } catch (error: any) { process.stderr.write(`trace-cli: ${error.message}\n`); diff --git a/src/cli/commands/ExportCommand.ts b/src/cli/commands/ExportCommand.ts new file mode 100644 index 0000000..7e77b0b --- /dev/null +++ b/src/cli/commands/ExportCommand.ts @@ -0,0 +1,86 @@ +import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { logger } from "../../shared/logger.js"; +import { CliCommand } from "./CliCommand.js"; +import { GraphCommand } from "./GraphCommand.js"; +import { DepsCommand } from "./DepsCommand.js"; + +const log = logger.child({ component: "export" }); +const GRAPH_DEPTH = 6; // rooted-mode knob; unused in the repo map but GraphRequest requires it + +export interface ExportRequest { + dir?: string; // target project root (default: cwd) — also the project the maps are built from + force?: boolean; // overwrite an existing export directory (.claude/skills/trace) +} + +/** One generated map written into the export directory. `ok` is false when the analysis degraded (page still written). */ +export interface ExportedMap { kind: "graph" | "deps"; path: string; ok: boolean } +export interface ExportResult { src: string; dest: string; maps: ExportedMap[] } + +/** + * ExportCommand — provision a project's **export directory** (`/.claude/skills/trace/`) with everything + * trace-cli hands off: the bundled `trace` skill (so Claude Code picks it up) AND interactive HTML maps of the + * project built right then — the whole-repo LSP map (`graph.html`) and the module-import graph (`deps.html`). + * One command, "get everything." The skill copy is the must-succeed step; each map is best-effort (a failed or + * empty analysis still writes a page and is reported via `ok`, never aborting the export). + */ +export class ExportCommand extends CliCommand { + static readonly SKILL_NAME = "trace"; + + /** Locate the bundled `skills/trace` dir across run modes (dist build, plugin, repo cwd). */ + #resolveSource(): string { + const candidatePaths = [ + // dist/cli/commands/ExportCommand.js → package root → skills/trace + fileURLToPath(new URL("../../../skills/trace", import.meta.url)), + ...(process.env.CLAUDE_PLUGIN_ROOT ? [join(process.env.CLAUDE_PLUGIN_ROOT, "skills", "trace")] : []), + join(process.cwd(), "skills", "trace"), + ]; + const foundPath = candidatePaths.find((candidatePath) => existsSync(join(candidatePath, "SKILL.md"))); + if (!foundPath) throw new Error(`bundled '${ExportCommand.SKILL_NAME}' skill not found (looked in: ${candidatePaths.join(", ")})`); + return foundPath; + } + + /** Build one HTML map into the export directory; degrade to a written page (ok:false) rather than throwing. */ + async #writeMap(kind: ExportedMap["kind"], exportDir: string, build: () => Promise<{ html: string; ok: boolean }>): Promise { + const path = join(exportDir, `${kind}.html`); + try { + const { html, ok } = await build(); + writeFileSync(path, html); + return { kind, path, ok }; + } catch (error) { + log.warn(`${kind} map failed`, { err: String((error as Error)?.message ?? error).split("\n")[0] }); + return { kind, path, ok: false }; + } + } + + async run(request: ExportRequest = {}): Promise { + const sourcePath = this.#resolveSource(); + const projectRoot = resolve(request.dir ?? process.cwd()); + const skillsDirectory = join(projectRoot, ".claude", "skills"); + const exportDir = join(skillsDirectory, ExportCommand.SKILL_NAME); + if (existsSync(exportDir) && !request.force) { + throw new Error(`${exportDir} already exists — pass --force to overwrite`); + } + + // 1. the skill (must succeed) — Claude Code discovers it at .claude/skills/trace. + mkdirSync(skillsDirectory, { recursive: true }); + cpSync(sourcePath, exportDir, { recursive: true, force: true }); + + // 2. the maps of THIS project, built into the same export directory (best-effort). + const maps = [ + await this.#writeMap("graph", exportDir, async () => { + const trace = await new GraphCommand().run({ repo: true, root: projectRoot, maxDepth: GRAPH_DEPTH }); + return { html: new GraphCommand().renderHtml(trace), ok: trace.ok }; + }), + await this.#writeMap("deps", exportDir, async () => { + const command = new DepsCommand(); + const trace = await command.run({ entry: projectRoot, root: projectRoot, args: { entry: projectRoot } }); + return { html: command.renderHtml(trace), ok: trace.ok }; + }), + ]; + + return { src: sourcePath, dest: exportDir, maps }; + } +} diff --git a/src/cli/commands/ExportSkillCommand.ts b/src/cli/commands/ExportSkillCommand.ts deleted file mode 100644 index cdeb35b..0000000 --- a/src/cli/commands/ExportSkillCommand.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { cpSync, existsSync, mkdirSync } from "node:fs"; -import { join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { CliCommand } from "./CliCommand.js"; - -export interface ExportSkillRequest { - dir?: string; // target project root (default: cwd) - force?: boolean; // overwrite an existing .claude/skills/trace -} - -export interface ExportSkillResult { src: string; dest: string } - -/** - * ExportSkillCommand — copy the bundled `trace` skill into a project's `.claude/skills/trace/`, so Claude - * Code in that project picks it up automatically. The skill ships with the package (see package.json - * `files`), so this is a self-install: anyone who has the CLI can drop the skill into their repo without - * cloning this one. Source resolution tolerates every run mode (compiled dist, plugin install, repo). - */ -export class ExportSkillCommand extends CliCommand { - static readonly SKILL_NAME = "trace"; - - /** Locate the bundled `skills/trace` dir across run modes (dist build, plugin, repo cwd). */ - #resolveSource(): string { - const candidatePaths = [ - // dist/cli/commands/ExportSkillCommand.js → package root → skills/trace - fileURLToPath(new URL("../../../skills/trace", import.meta.url)), - ...(process.env.CLAUDE_PLUGIN_ROOT ? [join(process.env.CLAUDE_PLUGIN_ROOT, "skills", "trace")] : []), - join(process.cwd(), "skills", "trace"), - ]; - const foundPath = candidatePaths.find((candidatePath) => existsSync(join(candidatePath, "SKILL.md"))); - if (!foundPath) throw new Error(`bundled '${ExportSkillCommand.SKILL_NAME}' skill not found (looked in: ${candidatePaths.join(", ")})`); - return foundPath; - } - - run(request: ExportSkillRequest = {}): ExportSkillResult { - const sourcePath = this.#resolveSource(); - const projectRoot = resolve(request.dir ?? process.cwd()); - const skillsDirectory = join(projectRoot, ".claude", "skills"); - const destinationPath = join(skillsDirectory, ExportSkillCommand.SKILL_NAME); - if (existsSync(destinationPath) && !request.force) { - throw new Error(`${destinationPath} already exists — pass --force to overwrite`); - } - mkdirSync(skillsDirectory, { recursive: true }); - cpSync(sourcePath, destinationPath, { recursive: true, force: true }); - return { src: sourcePath, dest: destinationPath }; - } -} diff --git a/src/index.ts b/src/index.ts index dae9955..a7c470a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,7 @@ export type { SessionStore, SessionSummary } from "./collector/SessionStore.js"; export { RunCommand } from "./cli/commands/RunCommand.js"; export type { RunRequest, RunResult, RunTargetKind } from "./cli/commands/RunCommand.js"; 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"; export { VERSION } from "./shared/version.js"; export { logger, Logger } from "./shared/logger.js"; diff --git a/test/export.test.js b/test/export.test.js new file mode 100644 index 0000000..08ff63d --- /dev/null +++ b/test/export.test.js @@ -0,0 +1,47 @@ +// Export test: `exports` provisions a project's .claude/skills/trace with the skill AND interactive HTML maps +// (graph.html via the repo LSP map, deps.html via madge) built from the target project. Integration test +// (spawns the language server; madge may be absent → its page degrades but is still written). Run via `npm test`. +import "reflect-metadata"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { ExportCommand } from "../dist/cli/commands/ExportCommand.js"; + +test("exports copies the skill and writes graph.html + deps.html into the export directory", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "trace-export-")); + try { + // a tiny TS project so the repo map has a real symbol to render + mkdirSync(join(projectDir, "src"), { recursive: true }); + writeFileSync(join(projectDir, "src", "app.ts"), "export function greet(name: string): string {\n return shout(name);\n}\nfunction shout(s: string): string { return s.toUpperCase(); }\n"); + + const result = await new ExportCommand().run({ dir: projectDir, force: true }); + const exportDir = join(projectDir, ".claude", "skills", "trace"); + + assert.equal(result.dest, exportDir); + assert.ok(existsSync(join(exportDir, "SKILL.md")), "the bundled skill was copied into the export directory"); + + const graphMap = result.maps.find((m) => m.kind === "graph"); + const depsMap = result.maps.find((m) => m.kind === "deps"); + assert.ok(graphMap && existsSync(graphMap.path), "graph.html written"); + assert.ok(depsMap && existsSync(depsMap.path), "deps.html written"); + assert.match(readFileSync(graphMap.path, "utf8"), //i, "graph.html is a real HTML page"); + assert.match(readFileSync(depsMap.path, "utf8"), //i, "deps.html is a real HTML page"); + // the repo map should have found our symbol → a non-degraded graph page + assert.equal(graphMap.ok, true, "the repo map built cleanly over the temp project"); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } +}); + +test("exports refuses to clobber an existing export directory without --force", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "trace-export-")); + try { + mkdirSync(join(projectDir, ".claude", "skills", "trace"), { recursive: true }); + await assert.rejects(() => new ExportCommand().run({ dir: projectDir }), /already exists.*--force/); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } +}); diff --git a/test/manifest.test.js b/test/manifest.test.js index 240f9bb..a8d757c 100644 --- a/test/manifest.test.js +++ b/test/manifest.test.js @@ -18,7 +18,7 @@ test("manifest describes the tool and the whole command tree", () => { const names = m.command.commands.map((c) => c.name).sort(); // Generated from the parser — every registered subcommand appears, including manifest itself. The four // static analyses (graph/deps/complexity/symbols) sit at the top level alongside the runtime `run` command. - assert.deepEqual(names, ["complexity", "deps", "doctor", "export-skill", "graph", "manifest", "run", "schema", "serve", "symbols"]); + assert.deepEqual(names, ["complexity", "deps", "doctor", "exports", "graph", "manifest", "run", "schema", "serve", "symbols"]); }); test("manifest captures option metadata: flags, defaults, optional, negate", () => { From 9c38b7f02a284e448f45d97ffe42134e4d11f277 Mon Sep 17 00:00:00 2001 From: burrows99 Date: Fri, 19 Jun 2026 15:27:26 +0100 Subject: [PATCH 4/4] fix(export): scope the deps map to the project + don't track generated exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 4 +++- src/cli/commands/ExportCommand.ts | 5 ++++- test/export.test.js | 9 ++++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index b5a7a7b..bd3a520 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,6 @@ npm-debug.log* dist/ -graphify-out/ \ No newline at end of file +graphify-out/ +# `trace exports` output — regenerable skill copy + HTML maps, not source +.claude/skills/trace/ diff --git a/src/cli/commands/ExportCommand.ts b/src/cli/commands/ExportCommand.ts index 7e77b0b..0ef23b6 100644 --- a/src/cli/commands/ExportCommand.ts +++ b/src/cli/commands/ExportCommand.ts @@ -9,6 +9,9 @@ import { DepsCommand } from "./DepsCommand.js"; const log = logger.child({ component: "export" }); const GRAPH_DEPTH = 6; // rooted-mode knob; unused in the repo map but GraphRequest requires it +// Keep the deps map about THIS project: drop dependency/build/cache trees (incl. a submodule's build output), +// mirroring the source-discovery ignore set the repo graph already uses. madge --exclude takes a path regexp. +const DEPS_EXCLUDE = "(^|/)(node_modules|dist|build|out|\\.next|\\.turbo|\\.cache|coverage|vendor|__pycache__)/"; export interface ExportRequest { dir?: string; // target project root (default: cwd) — also the project the maps are built from @@ -76,7 +79,7 @@ export class ExportCommand extends CliCommand { }), await this.#writeMap("deps", exportDir, async () => { const command = new DepsCommand(); - const trace = await command.run({ entry: projectRoot, root: projectRoot, args: { entry: projectRoot } }); + const trace = await command.run({ entry: projectRoot, root: projectRoot, exclude: DEPS_EXCLUDE, args: { entry: projectRoot } }); return { html: command.renderHtml(trace), ok: trace.ok }; }), ]; diff --git a/test/export.test.js b/test/export.test.js index 08ff63d..6155f72 100644 --- a/test/export.test.js +++ b/test/export.test.js @@ -16,6 +16,9 @@ test("exports copies the skill and writes graph.html + deps.html into the export // a tiny TS project so the repo map has a real symbol to render mkdirSync(join(projectDir, "src"), { recursive: true }); writeFileSync(join(projectDir, "src", "app.ts"), "export function greet(name: string): string {\n return shout(name);\n}\nfunction shout(s: string): string { return s.toUpperCase(); }\n"); + // build output should be excluded from the deps map (mirrors a submodule's build/ tree polluting the graph) + mkdirSync(join(projectDir, "build"), { recursive: true }); + writeFileSync(join(projectDir, "build", "legacy.js"), "module.exports = function legacyThing() {};\n"); const result = await new ExportCommand().run({ dir: projectDir, force: true }); const exportDir = join(projectDir, ".claude", "skills", "trace"); @@ -28,7 +31,11 @@ test("exports copies the skill and writes graph.html + deps.html into the export assert.ok(graphMap && existsSync(graphMap.path), "graph.html written"); assert.ok(depsMap && existsSync(depsMap.path), "deps.html written"); assert.match(readFileSync(graphMap.path, "utf8"), //i, "graph.html is a real HTML page"); - assert.match(readFileSync(depsMap.path, "utf8"), //i, "deps.html is a real HTML page"); + const depsHtml = readFileSync(depsMap.path, "utf8"); + assert.match(depsHtml, //i, "deps.html is a real HTML page"); + // build/ output is excluded from the deps map (and the graph never scans build/ either) + assert.ok(!depsHtml.includes("legacy.js"), "build/legacy.js is excluded from the deps map"); + assert.ok(!readFileSync(graphMap.path, "utf8").includes("legacy"), "build/ is not in the repo graph"); // the repo map should have found our symbol → a non-degraded graph page assert.equal(graphMap.ok, true, "the repo map built cleanly over the temp project"); } finally {