Skip to content

Commit 1eae406

Browse files
committed
feat: decompose diffctx monolith + add treemapper graph subcommand
- Split 1382-line diffctx/__init__.py into 9 focused modules - Add `treemapper graph` CLI for project dependency graph analysis - JSON/GraphML export, Mermaid diagrams, cycles, hotspots, metrics - blast_radius, impact subgraph (PPR-based), coupling/cohesion - Fix Tarjan SCC: recursive → iterative (avoids RecursionError on large graphs) - Fix blast_radius: O(n²) → O(n) using pre-built file_counts dict - Fix GraphML namespace: graphstruct.org → graphdrawing.org - Fix blast_radius: use existing graph.reverse_adjacency (remove redundant pass) - 67 integration tests for graph subcommand
1 parent 735a3bf commit 1eae406

File tree

16 files changed

+3011
-1382
lines changed

16 files changed

+3011
-1382
lines changed

src/treemapper/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from pathlib import Path
66
from typing import Any
77

8-
from .diffctx import build_diff_context
8+
from .diffctx import ProjectGraph, build_diff_context, build_project_graph
99
from .ignore import get_ignore_specs, get_whitelist_spec
1010
from .tree import TreeBuildContext, build_tree
1111
from .version import __version__
@@ -14,8 +14,10 @@
1414
logging.getLogger("treemapper").addHandler(logging.NullHandler())
1515

1616
__all__ = [
17+
"ProjectGraph",
1718
"__version__",
1819
"build_diff_context",
20+
"build_project_graph",
1921
"map_directory",
2022
"to_json",
2123
"to_markdown",

src/treemapper/cli.py

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,42 @@ def _resolve_whitelist_file(whitelist_file_arg: str | None, root_dir: Path) -> P
119119
return resolved
120120

121121

122+
def _build_graph_parsed_args(args: argparse.Namespace) -> ParsedArgs:
123+
root_dir = _resolve_root_dir(args.directory)
124+
output_file_path = Path(args.output_file).resolve() if args.output_file else None
125+
ignore_file = _resolve_ignore_file(args.ignore, root_dir)
126+
whitelist_file = _resolve_whitelist_file(args.whitelist, root_dir)
127+
verbosity = "error" if args.quiet else args.log_level
128+
edge_types = [t.strip() for t in args.edge_types.split(",")] if args.edge_types else None
129+
130+
return ParsedArgs(
131+
root_dir=root_dir,
132+
ignore_file=ignore_file,
133+
whitelist_file=whitelist_file,
134+
output_file=output_file_path,
135+
no_default_ignores=args.no_default_ignores,
136+
verbosity=verbosity,
137+
output_format="yaml",
138+
max_depth=None,
139+
no_content=False,
140+
max_file_bytes=None,
141+
copy=args.copy,
142+
force_stdout=False,
143+
quiet=args.quiet,
144+
command="graph",
145+
format=args.format,
146+
summary=args.summary,
147+
level=args.level,
148+
edge_types=edge_types,
149+
mermaid=args.mermaid,
150+
cycles=args.cycles,
151+
hotspots=args.hotspots,
152+
metrics=args.metrics,
153+
impact=args.impact,
154+
blast_radius=args.blast_radius,
155+
)
156+
157+
122158
def _warn_diff_only_flags(args: argparse.Namespace) -> None:
123159
if args.diff_range:
124160
return
@@ -156,6 +192,17 @@ class ParsedArgs:
156192
alpha: float = 0.60
157193
tau: float = 0.08
158194
full_diff: bool = False
195+
command: str | None = None
196+
format: str = "json"
197+
summary: bool = False
198+
level: str = "fragment"
199+
edge_types: list[str] | None = None
200+
mermaid: bool = False
201+
cycles: bool = False
202+
hotspots: int | None = None
203+
metrics: bool = False
204+
impact: str | None = None
205+
blast_radius: str | None = None
159206

160207

161208
DEFAULT_IGNORES_HELP = """
@@ -200,12 +247,72 @@ class ParsedArgs:
200247
"""
201248

202249

203-
def parse_args() -> ParsedArgs:
250+
def _build_graph_parser() -> argparse.ArgumentParser:
251+
graph_parser = argparse.ArgumentParser(
252+
prog="treemapper graph",
253+
description="Build and analyze the project dependency graph",
254+
formatter_class=argparse.RawDescriptionHelpFormatter,
255+
)
256+
graph_parser.add_argument("directory", nargs="?", default=".", help="The directory to analyze")
257+
graph_parser.add_argument(
258+
"-f",
259+
"--format",
260+
choices=["json", "graphml"],
261+
default="json",
262+
help="Graph output format (default: json)",
263+
)
264+
graph_parser.add_argument("--summary", action="store_true", help="Print graph summary statistics")
265+
graph_parser.add_argument(
266+
"--level",
267+
choices=["fragment", "file", "directory"],
268+
default="fragment",
269+
help="Granularity level for graph operations (default: fragment)",
270+
)
271+
graph_parser.add_argument(
272+
"--edge-types",
273+
default=None,
274+
help="Comma-separated edge types to include (e.g., semantic,config)",
275+
)
276+
graph_parser.add_argument("--mermaid", action="store_true", help="Output graph as Mermaid diagram")
277+
graph_parser.add_argument("--cycles", action="store_true", help="Detect dependency cycles")
278+
graph_parser.add_argument(
279+
"--hotspots", type=int, nargs="?", const=10, default=None, metavar="N", help="Show top N hotspots (default: 10)"
280+
)
281+
graph_parser.add_argument("--metrics", action="store_true", help="Show coupling/cohesion metrics per module")
282+
graph_parser.add_argument("--impact", default=None, metavar="FILE", help="Show impact subgraph for a file")
283+
graph_parser.add_argument("--blast-radius", default=None, metavar="FILE", help="Estimate blast radius for a file")
284+
graph_parser.add_argument("-o", "--output-file", default=None, help="Write output to FILE")
285+
graph_parser.add_argument("-i", "--ignore", default=None, help="Path to custom ignore file")
286+
graph_parser.add_argument("-w", "--whitelist", default=None, help="Path to whitelist file")
287+
graph_parser.add_argument(
288+
"--no-default-ignores",
289+
action="store_true",
290+
help="Disable built-in ignore patterns",
291+
)
292+
graph_parser.add_argument("-c", "--copy", action="store_true", help="Copy to clipboard")
293+
graph_parser.add_argument(
294+
"-q",
295+
"--quiet",
296+
action="store_true",
297+
help="Suppress all non-error output",
298+
)
299+
graph_parser.add_argument(
300+
"--log-level",
301+
choices=["error", "warning", "info", "debug"],
302+
default="error",
303+
help="Log level (default: error)",
304+
)
305+
return graph_parser
306+
307+
308+
def _build_main_parser() -> argparse.ArgumentParser:
204309
parser = argparse.ArgumentParser(
205310
prog="treemapper",
206311
description=(
207312
"Generate a structured representation of a directory tree (YAML, JSON, text, or Markdown). "
208-
"Supports diff context mode (--diff) for intelligent code change analysis."
313+
"Supports diff context mode (--diff) for intelligent code change analysis.\n\n"
314+
"Subcommands:\n"
315+
" graph Build and analyze the project dependency graph"
209316
),
210317
epilog=DEFAULT_IGNORES_HELP,
211318
formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -299,8 +406,19 @@ def parse_args() -> ParsedArgs:
299406
action="store_true",
300407
help="Include all changed code (skip smart selection algorithm)",
301408
)
409+
return parser
410+
411+
412+
def parse_args(argv: list[str] | None = None) -> ParsedArgs:
413+
raw_args = sys.argv[1:] if argv is None else argv
414+
415+
if raw_args and raw_args[0] == "graph":
416+
graph_parser = _build_graph_parser()
417+
args = graph_parser.parse_args(raw_args[1:])
418+
return _build_graph_parsed_args(args)
302419

303-
args = parser.parse_args()
420+
parser = _build_main_parser()
421+
args = parser.parse_args(raw_args)
304422

305423
_validate_max_depth(args.max_depth)
306424
max_file_bytes = _validate_max_file_bytes(args.max_file_bytes, args.no_file_size_limit)

0 commit comments

Comments
 (0)