Skip to content
Merged
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 .github/test-count-baseline
Original file line number Diff line number Diff line change
@@ -1 +1 @@
4264
4433
62 changes: 61 additions & 1 deletion src/cgis/api/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import os
import sys
from pathlib import Path
from typing import Annotated, Any, NamedTuple
from typing import Annotated, Any, Literal, NamedTuple

import structlog
from pydantic import Field
Expand All @@ -24,6 +24,7 @@
from cgis.core.models import RAW_CALL_PREFIX, Edge, EdgeType, Node, NodeType
from cgis.languages import extractors, impact_caveat
from cgis.pipeline import IngestionPipeline, extraction_fingerprint, ingest_data_keys
from cgis.query.analysis.module_graph import graph_to_dict, module_graph, render_mermaid
from cgis.query.analysis.overview import DEFAULT_DEPTH, DEFAULT_LIMIT, build_overview
from cgis.query.analysis.suggest_service import report_to_dict, suggest_packages
from cgis.query.context.audit import audit_reachability
Expand Down Expand Up @@ -919,6 +920,65 @@ def cgis_suggest_packages(
return json.dumps(_with_freshness(db_path, report_to_dict(report)), indent=2)


@mcp.tool()
@refreshes_graph
def cgis_module_graph(
prefix: Annotated[
str,
Field(
description="FQN prefix of the package as the graph names it, e.g. cgis.query "
"(cgis_find_symbol shows it), matched on whole dot-segments. A directory above "
"the ingest root, such as src., is dropped."
),
],
db_path: DbPath = _DEFAULT_DB,
layers: Annotated[
tuple[Literal["imports", "extends", "calls"], ...],
Field(description="Edge kinds to draw. Calls make the picture much denser."),
] = ("imports", "extends"),
reduce: Annotated[
bool,
Field(description="Drop each edge implied by a longer path, per kind; cycles stay."),
] = True,
exclude_tests: Annotated[bool, Field(description="Leave test modules out.")] = True,
expand: Annotated[
bool,
Field(description="Every module below the package instead of one node per sub-package."),
] = False,
output_format: Annotated[
Literal["mermaid", "json"],
Field(description="mermaid: a diagram plus a table of outside dependencies; json: data."),
] = "mermaid",
) -> str:
"""How one package is built inside: its modules and the dependencies between them.

Nodes are the package's direct children, each sub-package one node (``expand``
lists every module instead). Edges are imports ``-->``, inheritance ``==>``
and calls ``-.->``, each kind transitively reduced so the direct layering
shows; an edge that closes a cycle is never dropped. Dependencies on code
outside the package are summarised per module under the diagram, not drawn.
Test modules and type-only imports are left out. The json form is
``{prefix, nodes, subpackages, edges:[{source,target,kind}], dropped, outside}``.
Run ``cgis_ingest`` first.
"""
if not prefix.strip():
return "❌ Give the package's prefix, e.g. cgis.query."
if not Path(db_path).exists():
return f"❌ Database not found at: {db_path}. Run cgis_ingest first."
Comment thread
zaebee marked this conversation as resolved.
try:
package = effective_prefixes(db_path, [prefix.strip()])[0]
graph = module_graph(
db_path, package, layers, reduce=reduce, exclude_tests=exclude_tests, expand=expand
)
except Exception as exc:
return f"❌ Error during module-graph: {exc}"
if not graph.nodes:
return f"No modules under '{prefix}'. Check the prefix with cgis_find_symbol."
if output_format == "json":
return json.dumps(_with_freshness(db_path, graph_to_dict(graph)), indent=2)
return _freshness_note(db_path) + render_mermaid(graph)


@mcp.tool()
@refreshes_graph
def cgis_validate(
Expand Down
1 change: 1 addition & 0 deletions src/cgis/api/server_instructions.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def _caveats() -> str:
- What calls X / is changing or renaming X safe: cgis_analyze_impact.
- What does X end up calling: cgis_trace_flow.
- How a module or class is laid out: cgis_get_structure.
- How one package is built inside, module by module: cgis_module_graph.
- One symbol with its source and neighbours, before editing it: cgis_context.
- Where is the symbol named X: cgis_find_symbol.
- Coupling, drift, reachability of a guard: cgis_metrics, cgis_drift, \
Expand Down
64 changes: 64 additions & 0 deletions src/cgis/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@
from cgis.query.analysis.analyzer import AnalyzerEngine
from cgis.query.analysis.anomaly import AnomalyType, ArchitecturalAnomaly
from cgis.query.analysis.health import HealthScorer
from cgis.query.analysis.module_graph import (
DEFAULT_LAYERS,
checked_layers,
graph_to_dict,
module_graph,
render_mermaid,
)
from cgis.query.analysis.overview import DEFAULT_DEPTH, DEFAULT_LIMIT, build_overview
from cgis.query.analysis.suggest_service import SuggestReport, report_to_dict, suggest_packages
from cgis.query.context.audit import NoAuditSourcesError, ReachabilityAudit, audit_reachability
Expand Down Expand Up @@ -2000,6 +2007,63 @@ def suggest_packages_cmd(
_render_suggest(report)


@app.command(name="module-graph")
def module_graph_cmd(
prefix: str = typer.Argument(
...,
help="FQN prefix of the package as the graph names it (e.g. cgis.query).",
),
db: str = typer.Option(_DEFAULT_DB, "--db", "-d", help=_DEFAULT_DB_HELP),
layer: list[str] = typer.Option(
DEFAULT_LAYERS,
"--layer",
"-l",
help="Edge kind to draw, repeatable: imports, extends, calls.",
),
Comment thread
zaebee marked this conversation as resolved.
no_reduce: bool = typer.Option(
False, "--no-reduce", help="Keep edges implied by a longer path."
),
with_tests: bool = typer.Option(False, "--with-tests", help="Include test modules."),
expand: bool = typer.Option(
False, "--expand", help="Every module below the package, not one node per sub-package."
),
output_format: str = typer.Option("mermaid", "--format", "-f", help="mermaid or json"),
) -> None:
"""Draw how one package is built inside: its modules and their dependencies.

Imports -->, inheritance ==>, calls -.->; each kind transitively reduced, cycles
kept. Outside dependencies are listed per module under the diagram. Run
`ingest` first.
"""
if output_format not in ("mermaid", "json"):
message = "use mermaid or json"
raise typer.BadParameter(message, param_hint="--format")
if not prefix.strip():
console.print("[bold red]❌ Give the package's prefix, e.g. cgis.query.[/bold red]")
raise typer.Exit(code=1)
try:
layers = checked_layers(layer)
except ValueError as exc:
raise typer.BadParameter(str(exc), param_hint="--layer") from exc
if not Path(db).is_file():
console.print(
f"[bold red]❌ Database not found:[/bold red] {escape(db)}. Run `ingest` first."
)
raise typer.Exit(code=1)
Comment thread
zaebee marked this conversation as resolved.
_warn_if_not_fresh(db)
package = effective_prefixes(db, [prefix.strip()])[0]
graph = module_graph(
db, package, layers, reduce=not no_reduce, exclude_tests=not with_tests, expand=expand
)
if not graph.nodes:
typer.echo(f"No modules under '{prefix}'.", err=True)
raise typer.Exit(code=1)
if output_format == "json":
typer.echo(_json.dumps(graph_to_dict(graph), indent=2))
else:
typer.echo(render_mermaid(graph))


def _render_fractal(reports: list[FractalReport]) -> None:
"""Render one table per layer: the ladder, then the fit and verdict."""
for report in reports:
Expand Down
Loading
Loading