Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion TELEMETRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ review:
- **Every number is a bucket.** A build reports `"200-999"` files, never `417`.
An exact count next to a language set starts to fingerprint a specific repo; a
bucket does not.
- **Every string is a member of a fixed set.** `command` is one of eight known
- **Every string is a member of a fixed set.** `command` is one of nine known
subcommands, `code` is one of eleven known failure codes. A value outside the
set is dropped, not sent — which is what stops a path, a symbol name, or a
snippet of an error message from riding along inside a "string property".
Expand Down
31 changes: 29 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* `graft` CLI. Commands: build, ask, check, viz, mcp, callers, skeleton, grep,
* `graft` CLI. Commands: build, ask, check, viz, mcp, callers, cycles, skeleton, grep,
* map, init. Git is the sync: commit graft/ and a clone has the graph. A
* workspace parent (≥2 git children) federates query commands across children.
*/
Expand Down Expand Up @@ -29,6 +29,7 @@ import {
runWorkspaceBuild,
runWorkspaceCallers,
runWorkspaceCheck,
runWorkspaceCycles,
runWorkspaceGrep,
runWorkspaceMap,
} from "./graph/workspace-cli.js";
Expand Down Expand Up @@ -747,7 +748,7 @@ program

program
.command("mcp")
.description("Serve the graph over MCP (stdio) — exposes graft_find_code, graft_trace_calls, graft_find_all, graft_file_api, graft_repo_map and graft_check_freshness as tools")
.description("Serve the graph over MCP (stdio) — exposes code search, call tracing, import cycles, file APIs, repo maps and freshness checks as tools")
.argument(...DIR_ARG)
.action(async (dirArg: string | undefined) => {
const dir = noteQuery(queryRoot(dirArg));
Expand All @@ -756,6 +757,32 @@ program
startMcpServer(dir, globalOpts.dir, currentVersion);
});

program
.command("cycles")
.description("Find resolved file-to-file import cycles, with lazy edges and import lines ($0, no LLM)")
.argument(...DIR_ARG)
.option(...NO_REFRESH_FLAG)
.action(async (dirArg: string | undefined, opts: { refresh?: boolean }) => {
const dir = noteQuery(queryRoot(dirArg));
await refreshBefore(dir, opts);
const root = resolve(dir);
const globalOpts = program.opts<{ dir?: string }>();
if (readWorkspace(root, globalOpts.dir)) {
noteHit(runWorkspaceCycles(root, globalOpts.dir) > 0);
return;
}
const graph = loadGraphCached(contextDirFor(root, globalOpts.dir));
if (!graph) {
console.error("✗ no graph — run graft build first");
process.exit(1);
return;
}
const { findImportCycles, formatImportCycles } = await import("./graph/cycles.js");
const cycles = findImportCycles(graph);
noteHit(cycles.length > 0);
process.stdout.write(formatImportCycles(cycles));
});

program
.command("callers")
.description(
Expand Down
116 changes: 116 additions & 0 deletions src/graph/cycles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { EdgeV1, GraphV1 } from "./types.js";

export interface ImportCycle {
files: string[];
edges: EdgeV1[];
}

export function formatImportCycles(cycles: ImportCycle[]): string {
if (cycles.length === 0) return "no import cycles found\n";
return (
cycles
.map((cycle, index) => {
const lines = [`Cycle ${index + 1} · ${cycle.files.length} files`];
for (const edge of cycle.edges) {
const source = edge.line === undefined ? edge.source : `${edge.source}:${edge.line}`;
lines.push(` ${source} imports → ${edge.target}${edge.lazy ? " [lazy]" : ""}`);
}
return lines.join("\n");
})
.join("\n\n") + "\n"
);
}

/** Find every multi-file strongly connected component in the resolved import graph. */
export function findImportCycles(graph: GraphV1): ImportCycle[] {
const files = graph.nodes.filter((node) => node.kind === "file").sort((a, b) => a.id.localeCompare(b.id));
const fileIds = new Set(files.map((file) => file.id));
const adjacency = new Map(files.map((file) => [file.id, [] as string[]]));
const importsBySource = new Map<string, EdgeV1[]>();
for (const edge of graph.edges) {
if (edge.relation !== "imports" || !fileIds.has(edge.source) || !fileIds.has(edge.target)) continue;
adjacency.get(edge.source)!.push(edge.target);
const sourceEdges = importsBySource.get(edge.source);
if (sourceEdges) sourceEdges.push(edge);
else importsBySource.set(edge.source, [edge]);
}
for (const neighbours of adjacency.values()) neighbours.sort((a, b) => a.localeCompare(b));

let nextIndex = 0;
const index = new Map<string, number>();
const lowlink = new Map<string, number>();
const stack: string[] = [];
const onStack = new Set<string>();
const components: string[][] = [];

const enter = (id: string): void => {
index.set(id, nextIndex);
lowlink.set(id, nextIndex);
nextIndex++;
stack.push(id);
onStack.add(id);
};

for (const file of files) {
if (index.has(file.id)) continue;
enter(file.id);
const work: Array<{ id: string; parent?: string; nextNeighbour: number }> = [
{ id: file.id, nextNeighbour: 0 },
];

while (work.length > 0) {
const frame = work.at(-1)!;
const neighbours = adjacency.get(frame.id)!;
if (frame.nextNeighbour < neighbours.length) {
const neighbour = neighbours[frame.nextNeighbour++];
if (!index.has(neighbour)) {
enter(neighbour);
work.push({ id: neighbour, parent: frame.id, nextNeighbour: 0 });
} else if (onStack.has(neighbour)) {
lowlink.set(frame.id, Math.min(lowlink.get(frame.id)!, index.get(neighbour)!));
}
continue;
}

work.pop();
if (frame.parent !== undefined) {
lowlink.set(frame.parent, Math.min(lowlink.get(frame.parent)!, lowlink.get(frame.id)!));
}
if (lowlink.get(frame.id) !== index.get(frame.id)) continue;

const component: string[] = [];
let member: string;
do {
member = stack.pop()!;
onStack.delete(member);
component.push(member);
} while (member !== frame.id);
if (component.length > 1) components.push(component.sort((a, b) => a.localeCompare(b)));
}
}

return components
.sort(compareMembers)
.map((members) => ({
files: members,
edges: cycleEdges(importsBySource, members),
}));
}

function compareMembers(a: string[], b: string[]): number {
return a.join("\0").localeCompare(b.join("\0"));
}

function cycleEdges(importsBySource: Map<string, EdgeV1[]>, members: string[]): EdgeV1[] {
const memberSet = new Set(members);
return members
.flatMap((source) => importsBySource.get(source) ?? [])
.filter((edge) => memberSet.has(edge.target))
.sort(
(a, b) =>
a.source.localeCompare(b.source) ||
a.target.localeCompare(b.target) ||
(a.line ?? 0) - (b.line ?? 0) ||
Number(a.lazy ?? false) - Number(b.lazy ?? false),
);
}
37 changes: 36 additions & 1 deletion src/graph/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ export interface RawEdge {
file: string; // the file this edge originates in (scopes name resolution)
targetId?: string; // already-resolved target (contains)
specifier?: string; // module path to resolve (imports / imported-symbol references)
lazy?: true; // present-only-when-true keeps eager imports byte-compatible
line?: number; // 1-based to match graph spans and editor coordinates
name?: string; // symbol name to resolve (extends/implements/calls)
viaMember?: boolean; // calls: was it `obj.foo()` (→ prefer method targets)?
/** calls with viaMember: the receiver's resolved type name (from bindings /
Expand Down Expand Up @@ -335,6 +337,7 @@ export interface WalkCtx {
enclosingClass: string | null; // nearest enclosing class (py/ts `self`/`this`)
goReceiverVar: string | null; // Go receiver var, e.g. `w` in `func (w *Worker)`
importedSymbols: ReadonlyMap<string, { name: string; specifier: string }>;
typeCheckingOnly: boolean;
// R6 (Phase 2): which list we're inside while walking an `R6Class(...)` call's
// arguments — set only for the direct span of a `public =`/`private =`/
// `active =` `list(...)`'s own entries (see walk()'s special-cased `argument`
Expand Down Expand Up @@ -422,6 +425,7 @@ export function extractFile(rel: string, source: string, lang: Language): Extrac
enclosingClass: null,
goReceiverVar: null,
importedSymbols,
typeCheckingOnly: false,
rR6Access: null,
rGenerics,
rSuperClass: null,
Expand Down Expand Up @@ -698,6 +702,28 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd
}
}

if (ctx.lang === "python" && ctx.enclosingKind === null && node.type === "if_statement") {
const condition = node.childForFieldName("condition");
const consequence = node.childForFieldName("consequence");
const isTypeChecking =
(condition?.type === "identifier" && condition.text === "TYPE_CHECKING") ||
(condition?.type === "attribute" &&
condition.childForFieldName("object")?.text === "typing" &&
condition.childForFieldName("attribute")?.text === "TYPE_CHECKING");
if (isTypeChecking && consequence) {
for (const child of node.namedChildren) {
walk(
child,
sameSyntaxNode(child, consequence) ? { ...ctx, typeCheckingOnly: true } : ctx,
out,
edges,
minted,
);
}
return;
}
}

// not a definition — capture calls/imports/references, then descend with the same context
// R's `call` node is also its ONLY vehicle for library()/require()/source() —
// there's no separate import-statement grammar construct to key off, so isImport
Expand All @@ -706,7 +732,16 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd
const callTypes = CALL_TYPES[ctx.lang];
if (isImport(node, ctx.lang)) {
const spec = importSpecifier(node, ctx.lang);
if (spec) edges.push({ source: ctx.rel, relation: "imports", specifier: spec, file: ctx.rel });
if (spec) {
edges.push({
source: ctx.rel,
relation: "imports",
specifier: spec,
file: ctx.rel,
line: node.startPosition.row + 1,
...(ctx.enclosingKind !== null || ctx.typeCheckingOnly ? { lazy: true as const } : {}),
});
}
// Imported identifiers are declarations, not uses. The import-binding pass
// above already recorded them, so do not descend and emit false references.
return;
Expand Down
Loading
Loading