feat(cli): codegraph implementations <name> lists the types that implement or extend a type

Which types implement this trait is the first question when a flow crosses a
trait or interface boundary, and no command answered it: node shows members and
callers, and impact mixes implementors with dependents and tests under one list.

The command renders the shared hierarchy derivation (type-hierarchy.ts, the one
codegraph_explore and the viewer read), not a third walk. Each row gives the
kind, the edge that ties the type (implements or extends, and from depth 2 the
type it hangs off), file:line, and [test] or [synthesized]. --depth follows
subclasses of subclasses, --file narrows an ambiguous name, --json gives the
same rows. Test code is read in a new test-code.ts: the file path, a test
segment in the qualified name, and for Rust a test cfg module in the source,
since the extractor neither names such a module nor prefixes what it declares.

Changes on top of the contributor's commit:
- The command opens the index with openIndexToRead, as every other read command
  does. A plain open repairs and migrates: run during another process's bulk
  load it made the writer's next write fail with database is locked (issue
  2913), and on an older release's index a read took the schema from 4 to 16. It
  joins the read-commands test, which was red on all three scenarios.
- The Rust module reader counts braces over the existing blankRustCode instead
  of its own masker, which missed character literals, raw strings and block
  comments, so one '{' in a test module left a mock unmarked. It also reads test
  inside cfg(all(...)), the form tokio and others use.
- The README lists the command; the changelog bullet credits the author.

Validated on real repos. Across 2,761 Rust files (8,819 declarations, ripgrep,
gitbutler, lightningcss, Cap, yaak and three more) the new reader keeps all 196
test-module declarations the old one found and adds 20, spot-checked as real
fixtures in test modules. implementations ran on ripgrep (Matcher, Sink), gin
(ResponseWriter, IRouter, Binding, with Go's implicit edges marked synthesized),
flask (View at depth 2), excalidraw and Minestom. CLI start stays within 3 ms
(116 ms to 119 ms, median of 25, interleaved). The site's CLI page is left for
the release, so a push does not deploy a command nobody has yet.

Original PR #2839 by @danusha2345.
Fixes #2833

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
danusha2345
2026-10-30 20:18:39 +00:00
committed by Colby Mchenry
co-authored by Claude Opus 5.5
parent 3fca009e7b
commit 08b187aaf6
6 changed files with 402 additions and 0 deletions
+2
View File
@@ -23,6 +23,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- CodeGraph's MCP server now gives agents and editors its display name and icon, so one that shows them, like the Codex app, lists CodeGraph by name and logo instead of a plain `codegraph`. Thanks @fedosov.
- `codegraph status` now shows how many of a language's files yielded symbols when that isn't all of them, and warns when none did, so a parser that isn't working no longer looks healthy, and `codegraph status --strict` exits non-zero on that or on an index that's missing, unfinished or out of date, so packaging and CI can gate on it (#1658). Thanks @ferrine.
- `codegraph serve --mcp --no-telemetry` turns telemetry off for one MCP server entry, so a shared MCP config can opt out without an environment variable; a background daemon that's already running for the project keeps its own setting until it exits. Thanks @danusha2345. (#1908)
- `codegraph implementations <name>` lists the types that implement or extend a trait, interface or class, with each one's file and line, how it's tied to the type, and whether it's test code, a Rust mock in a `#[cfg(test)]` module included; `--depth` follows subclasses of subclasses. Thanks @danusha2345. (#2833)
- New `codegraph cycles` command lists the groups of files that import each other in a circle, as text or `--json`, and exits 1 when it finds any, so it works as a pre-commit or CI check (#888). Thanks @jiangyq9 and @goutham80808.
+1
View File
@@ -542,6 +542,7 @@ codegraph files [path] # Show file structure (--format, --filter, --m
codegraph callers <symbol> # Find what calls a function/method (--limit, --json)
codegraph callees <symbol> # Find what a function/method calls (--limit, --json)
codegraph impact <symbol> # Analyze what code is affected by changing a symbol (--depth, --json)
codegraph implementations <symbol> # List the types that implement or extend a type (--depth, --json)
codegraph affected [files...] # Find test files affected by changes (see below)
codegraph cycles [path] # List circular imports between files (--json); exits 1 when there are any
codegraph daemon # Manage background daemons — pick one to stop (alias: daemons)
+227
View File
@@ -0,0 +1,227 @@
/**
* `codegraph implementations <name>` and the type hierarchy's descendants (#2833).
*
* The issue's Cargo workspace: a trait in one crate, a real implementor in a
* second, a mock in that crate's `#[cfg(test)]` module, and a third crate that
* only depends on the trait. Plus a TypeScript chain for `--depth`.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { inCfgTestModule } from '../src/graph/test-code';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
let projectRoot: string;
let cg: CodeGraph;
function write(file: string, source: string) {
const absolute = path.join(projectRoot, file);
fs.mkdirSync(path.dirname(absolute), { recursive: true });
fs.writeFileSync(absolute, source);
}
function run(args: string[]) {
return spawnSync(process.execPath, [BIN, 'implementations', '-p', projectRoot, ...args], {
encoding: 'utf-8',
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1', NO_COLOR: '1' },
timeout: 30_000,
});
}
function json(args: string[]) {
const result = run([...args, '--json']);
expect(result.status, result.stderr).toBe(0);
return JSON.parse(result.stdout);
}
beforeAll(async () => {
projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-impl-2833-'));
write('Cargo.toml', '[workspace]\nmembers = ["api", "store-real", "app"]\n');
write('api/Cargo.toml', '[package]\nname = "api"\nversion = "0.1.0"\n');
write('api/src/lib.rs', 'pub trait Store {\n fn put(&self, key: &str);\n}\n');
write('store-real/Cargo.toml', '[package]\nname = "store-real"\nversion = "0.1.0"\n[dependencies]\napi = { path = "../api" }\n');
write('store-real/src/lib.rs', [
'use api::Store;',
'pub struct RealStore { pub n: u8 }',
'impl Store for RealStore { fn put(&self, key: &str) { let _ = key; } }',
'',
'#[cfg(test)]',
'mod tests {',
' use api::Store;',
' struct MockStore { n: u8 }',
' impl Store for MockStore { fn put(&self, _key: &str) {} }',
' #[test]',
' fn mock_puts() { MockStore { n: 0 }.put("k"); }',
'}',
'',
].join('\n'));
write('app/Cargo.toml', '[package]\nname = "app"\nversion = "0.1.0"\n[dependencies]\napi = { path = "../api" }\nstore-real = { path = "../store-real" }\n');
write('app/src/lib.rs', [
'use api::Store;',
'pub fn save(store: &dyn Store) { store.put("a"); }',
'pub fn build() -> store_real::RealStore { store_real::RealStore { n: 0 } }',
'',
].join('\n'));
write('ts/shapes.ts', [
'export interface Shape { area(): number; }',
'export class Square implements Shape { area() { return 1; } }',
'export class Cube extends Square { volume() { return 1; } }',
'export function measure(s: Shape) { return s.area(); }',
'',
].join('\n'));
write('ts/__tests__/mock-shape.ts', [
"import { Shape } from '../shapes';",
'export class MockShape implements Shape { area() { return 0; } }',
'',
].join('\n'));
cg = CodeGraph.initSync(projectRoot);
await cg.indexAll();
}, 60_000);
afterAll(() => {
cg.destroy();
fs.rmSync(projectRoot, { recursive: true, force: true });
});
describe('getTypeHierarchy()', () => {
it('returns the implementors of a trait, not only its ancestors', () => {
const store = cg.getNodesByName('Store').find((n) => n.kind === 'trait');
expect(store).toBeDefined();
const hierarchy = cg.getTypeHierarchy(store!.id);
const implementors = hierarchy.edges
.filter((e) => e.kind === 'implements' && e.target === store!.id)
.map((e) => hierarchy.nodes.get(e.source)?.name)
.sort();
expect(implementors).toEqual(['MockStore', 'RealStore']);
});
it('still returns the ancestors of a subtype', () => {
const cube = cg.getNodesByName('Cube').find((n) => n.kind === 'class');
const hierarchy = cg.getTypeHierarchy(cube!.id);
const names = [...hierarchy.nodes.values()].map((n) => n.name).sort();
expect(names).toEqual(['Cube', 'Shape', 'Square']);
});
});
describe('codegraph implementations', () => {
it('lists a trait\'s implementors with the edge kind, and marks the #[cfg(test)] mock as test code', () => {
const out = json(['Store']);
expect(out.ambiguous).toBe(false);
expect(out.definitions).toHaveLength(1);
const def = out.definitions[0];
expect(def.definition.kind).toBe('trait');
expect(def.directSubtypes).toBe(2);
// Order within a level is the hierarchy walk's, not this command's.
const rows = def.implementations.map((r: { name: string; relation: string; test: boolean; filePath: string; startLine: number }) =>
[r.name, r.relation, r.test, `${r.filePath}:${r.startLine}`]).sort();
expect(rows).toEqual([
['MockStore', 'implements', true, 'store-real/src/lib.rs:8'],
['RealStore', 'implements', false, 'store-real/src/lib.rs:2'],
]);
// Dependents and the trait's own method are not implementors.
const names = def.implementations.map((r: { name: string }) => r.name);
expect(names).not.toContain('save');
expect(names).not.toContain('build');
expect(names).not.toContain('put');
});
it('prints one line per implementor with its location and a [test] tag', () => {
const result = run(['Store']);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain('Implementations of trait Store (rust)');
expect(result.stdout).toMatch(/struct\s+RealStore\s+implements\s+store-real\/src\/lib\.rs:2\n/);
expect(result.stdout).toMatch(/struct\s+MockStore\s+implements\s+store-real\/src\/lib\.rs:8\s+\[test\]\n/);
expect(result.stdout).not.toContain('save');
});
it('lists direct implementors by default and follows subclasses with --depth', () => {
const direct = json(['Shape']);
const directRows = direct.definitions[0].implementations.map((r: { name: string; depth: number }) => [r.name, r.depth]).sort();
expect(directRows).toEqual([['MockShape', 1], ['Square', 1]]);
expect(direct.definitions[0].implementations.find((r: { name: string }) => r.name === 'MockShape').test).toBe(true);
const deep = json(['Shape', '--depth', '2']);
const deepRows = deep.definitions[0].implementations.map((r: { name: string; depth: number; relation: string; via?: string }) =>
[r.name, r.depth, r.relation, r.via]);
// Breadth-first: depth 1 is complete before depth 2 begins.
expect(deepRows.slice(0, 2).sort()).toEqual([
['MockShape', 1, 'implements', undefined],
['Square', 1, 'implements', undefined],
]);
expect(deepRows.slice(2)).toEqual([['Cube', 2, 'extends', 'Square']]);
const text = run(['Shape', '-d', '2']);
expect(text.stdout).toMatch(/\n {4}class\s+Cube\s+extends Square\s+ts\/shapes\.ts:3\n/);
});
it('says so when the symbol is not a type', () => {
const result = run(['measure']);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain('not a type something can implement or extend');
});
it('says so when the symbol is unknown', () => {
const result = run(['NoSuchType']);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain('not found');
});
});
describe('inCfgTestModule()', () => {
const source = [
'pub struct A;',
'#[cfg(test)]',
'#[allow(dead_code)]',
'mod tests {',
' struct Mock { x: u8 }',
' fn braces() { let _ = "{"; }',
'}',
'pub struct B;',
'#[cfg(not(test))]',
'mod real { pub struct C; }',
].join('\n');
it('is true inside the module and false before, after and in other modules', () => {
expect(inCfgTestModule(source, 1)).toBe(false);
expect(inCfgTestModule(source, 5)).toBe(true);
expect(inCfgTestModule(source, 6)).toBe(true);
expect(inCfgTestModule(source, 8)).toBe(false);
expect(inCfgTestModule(source, 10)).toBe(false);
});
it('finds the end of the module past a brace in a character, a raw string or a block comment', () => {
const tricky = [
'#[cfg(test)]',
'mod tests {',
" const OPEN: char = '{';",
' const RAW: &str = r#"{"#;',
' /* { */',
' struct Mock;',
'}',
'pub struct After;',
].join('\n');
expect(inCfgTestModule(tricky, 6)).toBe(true);
expect(inCfgTestModule(tricky, 8)).toBe(false);
});
it('reads test inside cfg(all(...)), and not a negated test', () => {
const gated = [
'#[cfg(all(test, not(loom)))]',
'mod tests {',
' struct Mock;',
'}',
'#[cfg(all(feature = "sync", test))]',
'mod sync_tests {',
' struct SyncMock;',
'}',
'#[cfg(all(unix, not(test)))]',
'mod real {',
' struct Real;',
'}',
].join('\n');
expect(inCfgTestModule(gated, 3)).toBe(true);
expect(inCfgTestModule(gated, 7)).toBe(true);
expect(inCfgTestModule(gated, 11)).toBe(false);
});
});
@@ -54,6 +54,7 @@ const READ_COMMANDS: ReadCommand[] = [
{ name: 'callers', args: ['callers', 'loadOrderState'], answer: /getUserProfile/ },
{ name: 'callees', args: ['callees', 'getUserProfile'], answer: /loadOrderState/ },
{ name: 'impact', args: ['impact', 'loadOrderState'], answer: /getUserProfile/ },
{ name: 'implementations', args: ['implementations', 'OrderStateMachine'], answer: /Implementations of class OrderStateMachine/ },
{ name: 'context', args: ['context', 'getUserProfile', 'loadOrderState'], answer: /loadOrderState/ },
{ name: 'explore', args: ['explore', 'getUserProfile', 'loadOrderState'], answer: /src\/order\.ts/ },
{ name: 'affected', args: ['affected', 'src/order.ts'], answer: /src\/user\.test\.ts/ },
+113
View File
@@ -19,6 +19,7 @@
* codegraph callers <symbol> Find what calls a function/method
* codegraph callees <symbol> Find what a function/method calls
* codegraph impact <symbol> Analyze what code is affected by changing a symbol
* codegraph implementations <symbol> List the types that implement or extend a trait, interface or class
* codegraph affected [files] Find test files affected by changes
* codegraph cycles [path] List circular imports between files
* codegraph ui [path] Open the browser viewer (alias: web; not released yet — needs CODEGRAPH_UI=1)
@@ -76,6 +77,8 @@ import { lookupSymbolNodes, describeSymbolNode, groupDefinitions } from '../grap
import { callSiteLines, formatCallSiteLines } from '../graph/call-sites';
import { goPackageReaderFor } from '../graph/go-package';
import { groupFilesByPackage, packageGroupTitle, packageListingTitle } from '../graph/owning-package';
import { buildTypeHierarchy, canHaveHierarchy, MAX_DESCENDANTS, MAX_DESCENDANT_DEPTH } from '../graph/type-hierarchy';
import { isTestCode } from '../graph/test-code';
import type { Node, Edge, Language } from '../types';
import { isTestPath } from '../search/query-utils';
@@ -2739,6 +2742,116 @@ async function affectedFilterMatcher(pattern: string): Promise<(filePath: string
return picomatch(glob, options);
}
/**
* codegraph implementations <symbol>
*
* The types that implement or extend a trait, interface or class (#2833): the
* first question when a call crosses a trait boundary, and one `impact` cannot
* answer, since it mixes implementors with dependents and tests under one list.
*/
program
.command('implementations <symbol>')
.description('List the types that implement or extend a trait, interface or class')
.option('-p, --path <path>', 'Project path')
.option('-f, --file <path>', 'Narrow definitions by file path or suffix (no match: show all with a note)')
.option('-d, --depth <number>', `Levels of subtypes to list, 1-${MAX_DESCENDANT_DEPTH} (1: direct implementors only)`, '1')
.option('-j, --json', 'Output as JSON')
.action(async (symbol: string, options: { path?: string; file?: string; depth?: string; json?: boolean }) => {
const projectPath = resolveProjectPath(options.path);
try {
if (!isInitialized(projectPath)) {
error(`CodeGraph not initialized in ${projectPath}`);
process.exit(1);
}
const cg = await openIndexToRead(projectPath);
try {
const depth = Math.min(Math.max(parseInt(options.depth || '1', 10) || 1, 1), MAX_DESCENDANT_DEPTH);
const { nodes: found } = lookupSymbolNodes(cg, symbol, goPackageReaderFor(cg));
if (found.length === 0) {
info(formatSymbolNotFound(symbol, cg.searchNodes(symbol, { limit: 5 }).map((m) => m.node.name)));
return;
}
const targets = found.filter(canHaveHierarchy);
if (targets.length === 0) {
info(`"${symbol}" is ${describeSymbolNode(found[0]!)} — not a type something can implement or extend.`);
return;
}
const { groups, filteredOut } = groupDefinitions(targets, options.file);
const ambiguous = groups.length > 1;
const note = filteredOut
? `no definition of "${symbol}" matches file "${options.file}" — showing all definitions instead.`
: undefined;
// A Rust implementor is test code when it sits in a `#[cfg(test)]`
// module, which only its file shows; read each file once.
const sources = new Map<string, string | undefined>();
const sourceOf = (filePath: string): string | undefined => {
if (!sources.has(filePath)) {
try { sources.set(filePath, fs.readFileSync(path.join(projectPath, filePath), 'utf-8')); } catch { sources.set(filePath, undefined); }
}
return sources.get(filePath);
};
const collected = groups.map((group) => {
const focus = group[0]!;
const hierarchy = buildTypeHierarchy(cg, focus, { overrides: false });
const entries = (hierarchy?.descendants ?? []).filter((e) => e.depth <= depth);
const nameOf = new Map<string, string>([[focus.id, focus.name]]);
for (const e of entries) nameOf.set(e.node.id, e.node.name);
const implementations = entries.map((e) => ({
...cliNode(e.node),
qualifiedName: e.node.qualifiedName,
language: e.node.language,
relation: e.relation,
depth: e.depth,
via: e.depth > 1 ? nameOf.get(e.parentId) : undefined,
test: isTestCode(e.node, e.node.language === 'rust' ? sourceOf(e.node.filePath) : undefined),
synthesized: e.synthesized,
}));
return { group, implementations, directSubtypes: hierarchy?.directSubtypes ?? 0, bounded: hierarchy?.bounded ?? false };
});
if (options.json) {
console.log(JSON.stringify({
symbol,
depth,
targets: groups.flat().map((node) => cliDefinition([node]).definition),
ambiguous,
file: options.file,
filteredOut,
note,
definitions: collected.map(({ group, implementations, directSubtypes, bounded }) => ({
...cliDefinition(group),
directSubtypes,
bounded,
implementations,
})),
}, null, 2));
} else {
if (note) warn(note);
for (const { group, implementations, directSubtypes, bounded } of collected) {
const deeper = depth > 1 ? `, ${implementations.length} within ${depth} levels` : '';
console.log(chalk.bold(`\nImplementations of ${describeSymbolNode(group[0]!)} — ${directSubtypes} direct${deeper}:\n`));
if (implementations.length === 0) console.log(chalk.dim(' (none in the index)'));
for (const r of implementations) {
const tags = [r.test ? '[test]' : '', r.synthesized ? '[synthesized]' : ''].filter(Boolean).join(' ');
const via = r.via ? ` ${r.via}` : '';
console.log(` ${' '.repeat(r.depth - 1)}${chalk.dim(r.kind.padEnd(10))}${r.name} ${chalk.dim(r.relation + via)} ${chalk.cyan(`${r.filePath}:${r.startLine}`)}${tags ? ` ${chalk.dim(tags)}` : ''}`);
}
if (bounded) console.log(chalk.dim(`\n …the walk stops at ${MAX_DESCENDANTS} subtypes or ${MAX_DESCENDANT_DEPTH} levels; more exist.`));
console.log();
}
}
} finally {
cg.destroy();
}
} catch (err) {
error(`implementations failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
});
/**
* codegraph affected [files...]
*
+58
View File
@@ -0,0 +1,58 @@
/**
* Whether a symbol is test code — one answer for the surfaces that mark
* implementors, callers or dead-code candidates as tests.
*
* Three readings, cheapest first: the file path (`isTestFile`), the qualified
* name (a nested `Tests` class, a `tests` module the extractor names), and, for
* Rust, the source itself: `#[cfg(test)] mod tests { … }` lives inside `src/`,
* and the extractor neither mints a node for the module nor prefixes the names
* declared in it, so a mock there is invisible to the first two readings.
*/
import type { Node } from '../types';
import { blankRustCode } from '../resolution/rust-scopes';
import { isTestFile } from '../search/query-utils';
import { isTestScope } from './dead-code';
/** `#[cfg(test)]`, or `test` as one condition of `#[cfg(all(…))]`. `not(test)` is neither. */
const CFG_TEST = /^#\[cfg\((?:test\)|all\((?:.*,\s*)?test\s*[,)])/;
/** `node` is test code. `source` is the node's file, read when the caller has it. */
export function isTestCode(node: Node, source?: string): boolean {
if (isTestFile(node.filePath)) return true;
if (node.qualifiedName && isTestScope(node.qualifiedName)) return true;
return node.language === 'rust' && source !== undefined && inCfgTestModule(source, node.startLine);
}
/**
* `line` (1-based) is inside a module declared under a test `cfg`. The
* module's extent is its brace block, counted with comments and the contents
* of strings and characters blanked, which is as far as a module-level reading
* needs to go.
*/
export function inCfgTestModule(source: string, line: number): boolean {
const lines = blankRustCode(source).split('\n');
let armed = false;
for (let i = 0; i < lines.length && i < line; i++) {
const text = lines[i]!.trim();
if (CFG_TEST.test(text)) { armed = true; continue; }
if (!armed) continue;
if (text.startsWith('#[') || text === '') continue;
armed = false;
if (!/^(?:pub(?:\([^)]*\))?\s+)?mod\s+\w+\s*\{/.test(text)) continue;
// Walk the block: it ends on the line where its braces close.
let depth = 0;
for (let j = i; j < lines.length; j++) {
for (const ch of lines[j]!) {
if (ch === '{') depth++;
else if (ch === '}') depth--;
}
if (depth <= 0) {
if (line - 1 > i && line - 1 <= j) return true;
i = j;
break;
}
}
}
return false;
}