Skip to content
Open
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1904,6 +1904,11 @@ def evaluate_gate(findings, changed_ranges, policy):

## 🔗 Temporal Coupling

For a source-level blast-radius explanation, run `structorium impact <file-or-dir>`.
The bounded explorer reports shortest-path witnesses for dependents and dependencies,
and can emit JSON for agents or Mermaid for review discussions. See the
[dependency impact explorer guide](docs/IMPACT_EXPLORER.md).

Temporal coupling is computed from git history:

```
Expand Down Expand Up @@ -2172,4 +2177,3 @@ copies or substantial portions of the Software.
<p align="center">
<a href="#structorium">↑ Back to top</a>
</p>

5 changes: 4 additions & 1 deletion app/cli_support/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
from __future__ import annotations

import argparse
from importlib.metadata import PackageNotFoundError, version as get_version
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as get_version

from app.cli_support.parser_groups import (
_add_config_parser,
Expand All @@ -12,6 +13,7 @@
_add_exclude_parser,
_add_fix_parser,
_add_ignore_parser,
_add_impact_parser,
_add_langs_parser,
_add_move_parser,
_add_next_parser,
Expand Down Expand Up @@ -118,6 +120,7 @@ def create_parser(*, langs: list[str], detector_names: list[str]) -> argparse.Ar
)
_add_scan_parser(sub)
_add_status_parser(sub)
_add_impact_parser(sub)
_add_tree_parser(sub)
_add_show_parser(sub)
_add_next_parser(sub)
Expand Down
26 changes: 26 additions & 0 deletions app/cli_support/parser_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"_add_exclude_parser",
"_add_fix_parser",
"_add_ignore_parser",
"_add_impact_parser",
"_add_langs_parser",
"_add_move_parser",
"_add_next_parser",
Expand All @@ -40,6 +41,31 @@
]


def _add_impact_parser(sub) -> None:
p_impact = sub.add_parser(
"impact", help="Explain bounded dependency blast radius for files or directories"
)
p_impact.add_argument("targets", nargs="+", help="File or directory paths to explore")
p_impact.add_argument("--path", type=str, default=".", help="Project root directory")
p_impact.add_argument("--state", type=str, default=None, help="Path to state file")
p_impact.add_argument(
"--direction",
choices=["dependents", "dependencies", "both"],
default="both",
help="Graph direction to explore (default: both)",
)
p_impact.add_argument("--depth", type=int, default=3, help="Maximum traversal depth")
p_impact.add_argument(
"--max-nodes", type=int, default=200, help="Hard impacted-node budget"
)
p_impact.add_argument(
"--format", choices=["text", "json", "mermaid"], default="text"
)
p_impact.add_argument(
"--output", type=str, default=None, help="Write evidence to this path"
)


def _add_scan_parser(sub) -> None:
p_scan = sub.add_parser(
"scan",
Expand Down
72 changes: 72 additions & 0 deletions app/commands/impact_cmd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""CLI command for bounded dependency blast-radius exploration."""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

from app.commands.helpers.lang import resolve_lang, resolve_lang_settings
from app.commands.helpers.runtime import command_runtime
from core.discovery_api import safe_write_text
from core.output_api import colorize
from engine.impact import (
analyze_impact,
render_impact_json,
render_impact_mermaid,
render_impact_text,
)
from languages import runtime as lang_runtime


def cmd_impact(args: argparse.Namespace) -> None:
"""Build the language dependency graph and explain a bounded change radius."""
lang = resolve_lang(args)
if lang is None or not lang.build_dep_graph:
print(
colorize("No dependency-graph language integration is available.", "red"),
file=sys.stderr,
)
raise SystemExit(2)

project_root = Path(getattr(args, "path", None) or ".").resolve()
runtime = command_runtime(args)
lang_run = lang_runtime.make_lang_run(
lang,
overrides=lang_runtime.LangRunOverrides(
runtime_settings=resolve_lang_settings(runtime.config, lang)
),
)
try:
graph = lang_run.build_dep_graph(project_root)
except (OSError, UnicodeDecodeError, ValueError, TypeError, RuntimeError) as exc:
print(
colorize(f"Could not build dependency graph: {exc}", "red"),
file=sys.stderr,
)
raise SystemExit(2) from exc

report = analyze_impact(
graph,
list(args.targets),
project_root=project_root,
direction=args.direction,
max_depth=args.depth,
max_nodes=args.max_nodes,
)
renderers = {
"text": render_impact_text,
"json": render_impact_json,
"mermaid": render_impact_mermaid,
}
rendered = renderers[args.format](report)
if args.output:
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
safe_write_text(output, rendered)
print(colorize(f"Wrote dependency impact to {output.resolve()}", "green"))
else:
print(rendered, end="")


__all__ = ["cmd_impact"]
2 changes: 2 additions & 0 deletions app/commands/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def _build_handlers() -> dict[str, CommandHandler]:
from app.commands.dev_cmd import cmd_dev
from app.commands.exclude_cmd import cmd_exclude
from app.commands.fix import cmd_fix
from app.commands.impact_cmd import cmd_impact
from app.commands.langs import cmd_langs
from app.commands.move import cmd_move
from app.commands.next import cmd_next
Expand All @@ -38,6 +39,7 @@ def _build_handlers() -> dict[str, CommandHandler]:
"ignore": cmd_ignore_pattern,
"exclude": cmd_exclude,
"fix": cmd_fix,
"impact": cmd_impact,
"plan": cmd_plan,
"detect": cmd_detect,
"tree": cmd_tree,
Expand Down
38 changes: 38 additions & 0 deletions docs/IMPACT_EXPLORER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Dependency impact explorer

Before changing a shared module, ask Structorium which modules depend on it, which
dependencies it reaches, and the shortest path that proves each relationship:

```bash
structorium impact src/domain.py --direction dependents --depth 4
structorium impact src/domain.py --format json --output artifacts/impact.json
structorium impact src/domain.py --format mermaid --output artifacts/impact.mmd
```

Directory targets expand to every dependency-graph node below that prefix. Searches
are deterministic breadth-first traversals, so every result includes a shortest-path
witness. `--depth` defaults to 3 and `--max-nodes` defaults to 200; the report marks
itself as truncated instead of silently exploring an unbounded monorepo graph.

Directions use explicit names:

- `dependents`: files that could be affected when the target changes.
- `dependencies`: files the target itself relies on.
- `both`: both views within the same shared node budget.

JSON is intended for agents and CI artifacts. Mermaid is intended for PR descriptions
and architecture discussions. Both describe original source-to-dependency edges; the
visual renderer never invents relationships.

## Research provenance

The feature is original Structorium code informed by public code-graph workflows:

- [dependency-cruiser CLI reporters](https://github.com/sverweij/dependency-cruiser/blob/main/doc/cli.md)
provide graph outputs including Mermaid for repository-native review.
- [dependency-cruiser folder graphs](https://github.com/sverweij/dependency-cruiser/blob/main/doc/faq.md#folder-level-dependency-graph-ddot-reporter)
demonstrate why large dependency graphs need focused, summarized views.
- [CodeQL path explanations](https://codeql.github.com/docs/writing-codeql-queries/creating-path-queries/)
establish evidence paths as a useful way to explain why a result is reachable.

No competitor code or runtime dependency is used.
Loading
Loading