Skip to content

Commit 8a0dcb5

Browse files
committed
fix(cli): report circular dependencies consistently
1 parent d33d5dd commit 8a0dcb5

9 files changed

Lines changed: 351 additions & 1 deletion

File tree

docs/cli-output.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,18 @@ verdicts, records the affected dead-code candidates as abstentions, emits
120120
`analysis_summary.grep_verify.status` and `incomplete_reason`; increase the
121121
budget and rerun before treating the dead-code result as complete.
122122

123+
Circular dependencies (`SKY-CIRC`) are shown in rich, pretty, and concise
124+
output, and remain available in JSON under `circular_dependencies`. When
125+
source evidence is available, the finding points to an actual import in the
126+
cycle. Ordinary package re-exports do not by themselves form a cycle.
127+
128+
`--strict` counts circular dependencies and exits with status `1` when they
129+
remain in the selected report. Without an explicit `--gate`, concise output
130+
also exits `1` for cycles, as it does for other findings. Ordinary non-strict
131+
gates keep their existing thresholds: cycles do not contribute to
132+
`max_quality` or grades. The existing `--force` override can bypass finding
133+
failures, but incomplete analysis still exits `2`.
134+
123135
The legacy flags still work:
124136

125137
```bash

skylos/analysis/circular_deps.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,29 @@ def analyze(self) -> List[CircularDependency]:
340340
return findings
341341

342342
def get_findings(self) -> List[Dict[str, Any]]:
343-
return [cd.to_dict() for cd in self.analyze()]
343+
# Use recorded import edges, not filenames guessed from module names.
344+
# A graph assembled without source evidence should remain locationless.
345+
edge_locations = {}
346+
for dep in self.all_deps:
347+
source = self.modules.get(dep.from_module)
348+
if source and dep.import_line > 0:
349+
edge = (dep.from_module, dep.to_module)
350+
location = (str(source), dep.import_line)
351+
edge_locations[edge] = min(edge_locations.get(edge, location), location)
352+
353+
findings = []
354+
for cd in self.analyze():
355+
finding = cd.to_dict()
356+
edges = zip(cd.cycle, cd.cycle[1:] + cd.cycle[:1])
357+
locations = [
358+
edge_locations[edge] for edge in edges if edge in edge_locations
359+
]
360+
if locations:
361+
# Stable across file discovery order and repeated imports. Only
362+
# edges in this cycle qualify, not chords forming another cycle.
363+
finding["file"], finding["line"] = min(locations)
364+
findings.append(finding)
365+
return findings
344366

345367
def get_core_infrastructure(self) -> Set[str]:
346368
cycles = self.find_simple_cycles()

skylos/cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2291,6 +2291,7 @@ def _concise_scan_exit_code(
22912291
("reliability", "reliability issue"),
22922292
("ai_defects", "AI defect"),
22932293
("quality", "quality issue"),
2294+
("circular_dependencies", "circular dependency"),
22942295
("secrets", "secret"),
22952296
("custom_rules", "custom rule"),
22962297
("dependency_vulnerabilities", "dependency vulnerability"),

skylos/core/gatekeeper.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ def _check_strict_gate(
424424
reliability,
425425
ai_defects,
426426
quality,
427+
circular_dependencies,
427428
secrets,
428429
dependencies,
429430
):
@@ -434,6 +435,7 @@ def _check_strict_gate(
434435
+ len(reliability)
435436
+ len(ai_defects)
436437
+ len(gate_quality)
438+
+ len(circular_dependencies)
437439
+ len(secrets)
438440
+ len(dependencies)
439441
)
@@ -629,6 +631,7 @@ def check_gate(results, config, strict=False, provenance=None):
629631
reliability=reliability,
630632
ai_defects=ai_defects,
631633
quality=gate_quality,
634+
circular_dependencies=results.get("circular_dependencies", []) or [],
632635
secrets=secrets,
633636
dependencies=dependencies,
634637
)

skylos/ui/terminal_report.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
("reliability", "Reliability", "reliability issue"),
2222
("secrets", "Secret", "secret detected"),
2323
("quality", "Quality", "quality issue"),
24+
("circular_dependencies", "Architecture", "circular dependency"),
2425
("custom_rules", "Custom", "custom rule"),
2526
("dependency_vulnerabilities", "Dependency", "dependency vulnerability"),
2627
("unused_functions", "Dead Code", "unused function"),
@@ -67,6 +68,7 @@
6768
"unused_classes",
6869
"ai_defects",
6970
"quality",
71+
"circular_dependencies",
7072
"custom_rules",
7173
"danger",
7274
"reliability",

test/test_circular_deps.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,88 @@ def test_direct_module_self_import_is_not_suppressed(mode):
153153
assert findings[0]["cycle"] == ["module"]
154154

155155

156+
@pytest.mark.parametrize("mode", ["ast", "raw"])
157+
def test_absolute_and_relative_package_reexports_match_reported_example(mode):
158+
rule = _rule_for_sources(
159+
{
160+
"demo_pkg": (
161+
"/project/demo_pkg/__init__.py",
162+
"from demo_pkg.core import value\n__all__ = ['value']\n",
163+
),
164+
"demo_pkg.core": ("/project/demo_pkg/core.py", "value = 1\n"),
165+
"relative_pkg": (
166+
"/project/relative_pkg/__init__.py",
167+
"from .core import value\n__all__ = ['value']\n",
168+
),
169+
"relative_pkg.core": ("/project/relative_pkg/core.py", "value = 2\n"),
170+
"consumer": (
171+
"/project/consumer.py",
172+
"import demo_pkg\nimport relative_pkg\n",
173+
),
174+
},
175+
mode,
176+
)
177+
178+
assert rule.analyze() == []
179+
assert dict(rule._analyzer.dependencies) == {
180+
"demo_pkg": {"demo_pkg.core"},
181+
"relative_pkg": {"relative_pkg.core"},
182+
"consumer": {"demo_pkg", "relative_pkg"},
183+
}
184+
185+
186+
@pytest.mark.parametrize("mode", ["ast", "raw"])
187+
@pytest.mark.parametrize("reverse_files", [False, True])
188+
def test_circular_finding_has_stable_location_on_a_real_cycle_edge(mode, reverse_files):
189+
sources = {
190+
"alpha": (
191+
"/project/alpha.py",
192+
"import helper\n\nfrom beta import value\nfrom beta import other\n",
193+
),
194+
"beta": ("/project/beta.py", "from alpha import value\n"),
195+
"helper": ("/project/helper.py", ""),
196+
}
197+
if reverse_files:
198+
sources = dict(reversed(list(sources.items())))
199+
rule = _rule_for_sources(sources, mode)
200+
201+
findings = rule.analyze()
202+
203+
assert len(findings) == 1
204+
assert set(findings[0]["cycle"]) == {"alpha", "beta"}
205+
assert findings[0]["file"] == "/project/alpha.py"
206+
assert findings[0]["line"] == 3
207+
208+
209+
@pytest.mark.parametrize("mode", ["ast", "raw"])
210+
def test_cycle_location_does_not_use_an_edge_from_another_cycle(mode):
211+
rule = _rule_for_sources(
212+
{
213+
"alpha": ("/project/alpha.py", "import gamma\n\nimport beta\n"),
214+
"beta": ("/project/beta.py", "import gamma\n"),
215+
"gamma": ("/project/gamma.py", "import alpha\n"),
216+
},
217+
mode,
218+
)
219+
220+
findings = rule.analyze()
221+
long_cycle = next(finding for finding in findings if finding["cycle_length"] == 3)
222+
223+
assert long_cycle["file"] == "/project/alpha.py"
224+
assert long_cycle["line"] == 3
225+
226+
227+
def test_manual_cycle_graph_does_not_invent_an_import_location():
228+
analyzer = CircularDependencyAnalyzer()
229+
analyzer.modules = {"a": "a.py", "b": "b.py"}
230+
analyzer.dependencies = {"a": {"b"}, "b": {"a"}}
231+
232+
finding = analyzer.get_findings()[0]
233+
234+
assert "file" not in finding
235+
assert "line" not in finding
236+
237+
156238
@pytest.mark.parametrize("child_source", ["value = 'label'", "import package"])
157239
def test_same_package_graph_has_python_native_cycle_parity(child_source):
158240
if circular_deps._fast_find_cycles is None:

test/test_cli.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2518,6 +2518,151 @@ def test_main_json_strict_failure_exits_nonzero(monkeypatch):
25182518
mock_print.assert_called_once_with(json.dumps(result))
25192519

25202520

2521+
@pytest.mark.parametrize(
2522+
("output_format", "extra_args", "incomplete", "expected_exit", "show_cycle"),
2523+
[
2524+
pytest.param("concise", [], False, 1, True, id="concise"),
2525+
pytest.param("concise", ["--limit", "0"], False, 1, False, id="concise-limit"),
2526+
pytest.param("json", ["--strict"], False, 1, True, id="json-strict"),
2527+
pytest.param(
2528+
"json", ["--strict", "--gate"], False, 1, True, id="json-strict-gate"
2529+
),
2530+
pytest.param("pretty", ["--strict"], False, 1, True, id="pretty-strict"),
2531+
pytest.param("rich", ["--strict"], False, 1, True, id="rich-strict"),
2532+
pytest.param(
2533+
"concise",
2534+
["--strict", "--select=SKY-L012"],
2535+
False,
2536+
0,
2537+
False,
2538+
id="concise-unselected",
2539+
),
2540+
pytest.param(
2541+
"json",
2542+
["--strict", "--gate", "--select=SKY-L012"],
2543+
False,
2544+
0,
2545+
False,
2546+
id="json-strict-gate-unselected",
2547+
),
2548+
pytest.param("json", ["--gate"], False, 0, True, id="json-ordinary-gate"),
2549+
pytest.param("concise", ["--gate"], False, 0, True, id="concise-ordinary-gate"),
2550+
pytest.param("json", ["--strict", "--force"], False, 0, True, id="json-forced"),
2551+
pytest.param(
2552+
"json", ["--strict", "--gate"], True, 2, True, id="json-incomplete"
2553+
),
2554+
pytest.param(
2555+
"concise", ["--force"], True, 2, True, id="concise-incomplete-forced"
2556+
),
2557+
],
2558+
)
2559+
def test_main_circular_dependency_reporting_and_exit_codes(
2560+
monkeypatch,
2561+
capsys,
2562+
output_format,
2563+
extra_args,
2564+
incomplete,
2565+
expected_exit,
2566+
show_cycle,
2567+
):
2568+
cycle = {
2569+
"rule_id": "SKY-CIRC",
2570+
"kind": "circular_dependency",
2571+
"category": "ARCHITECTURE",
2572+
"severity": "MEDIUM",
2573+
"file": "pkg/left.py",
2574+
"line": 4,
2575+
"message": "Circular dependency: pkg.left → pkg.right → pkg.left",
2576+
"cycle": ["pkg.left", "pkg.right"],
2577+
"cycle_length": 2,
2578+
"suggested_break": "pkg.left → pkg.right",
2579+
}
2580+
result = {
2581+
"analysis_summary": {"total_files": 2},
2582+
"circular_dependencies": [cycle],
2583+
}
2584+
if incomplete:
2585+
result["analysis_errors"] = [
2586+
{
2587+
"rule_id": "SKY-ANALYSIS-INCOMPLETE",
2588+
"kind": "syntax_error",
2589+
"severity": "HIGH",
2590+
"file": "broken.py",
2591+
"line": 1,
2592+
"message": "invalid syntax",
2593+
}
2594+
]
2595+
monkeypatch.setattr(
2596+
cli.sys,
2597+
"argv",
2598+
[
2599+
"skylos",
2600+
".",
2601+
"--format",
2602+
output_format,
2603+
"--no-provenance",
2604+
"--no-upload",
2605+
*extra_args,
2606+
],
2607+
)
2608+
terminal_output = StringIO()
2609+
fake_logger = Mock()
2610+
fake_logger.console = Console(
2611+
file=terminal_output,
2612+
width=160,
2613+
force_terminal=False,
2614+
theme=cli._skylos_console_theme(),
2615+
)
2616+
exit_code = 0
2617+
with (
2618+
patch("skylos.cli.setup_logger", return_value=fake_logger),
2619+
patch("skylos.cli.Progress", return_value=_progress_ctx()),
2620+
patch("skylos.cli.run_analyze", return_value=json.dumps(result)),
2621+
patch("skylos.cli.load_config", return_value={"gate": {"max_quality": 0}}),
2622+
patch("skylos.cli.print_badge"),
2623+
):
2624+
try:
2625+
cli.main()
2626+
except SystemExit as exc:
2627+
exit_code = exc.code
2628+
2629+
output = capsys.readouterr().out + terminal_output.getvalue()
2630+
assert exit_code == expected_exit
2631+
if output_format == "json":
2632+
rendered = json.loads(output)
2633+
assert rendered.get("circular_dependencies", []) == (
2634+
[cycle] if show_cycle else []
2635+
)
2636+
elif show_cycle:
2637+
if output_format == "rich":
2638+
assert "Circular Dependencies" in output
2639+
assert "pkg.left → pkg.right → pkg.left" in output
2640+
else:
2641+
assert output.count("SKY-CIRC") == 1
2642+
assert "pkg/left.py:4" in output
2643+
assert cycle["message"] in output
2644+
else:
2645+
assert "SKY-CIRC" not in output
2646+
assert cycle["message"] not in output
2647+
if incomplete:
2648+
assert "SKY-ANALYSIS-INCOMPLETE" in output
2649+
2650+
2651+
def test_concise_circular_dependency_without_location_remains_visible():
2652+
result = {
2653+
"circular_dependencies": [
2654+
{
2655+
"rule_id": "SKY-CIRC",
2656+
"message": "Circular dependency: left → right → left",
2657+
}
2658+
]
2659+
}
2660+
2661+
assert cli._format_concise_results(result) == (
2662+
"?:1 SKY-CIRC Circular dependency: left → right → left\n"
2663+
)
2664+
2665+
25212666
def test_main_json_incomplete_analysis_exits_two_after_output(monkeypatch):
25222667
result = {
25232668
"analysis_summary": {

test/test_gatekeeper.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,44 @@ def test_check_gate_strict_counts_reliability_findings():
247247
assert reasons == ["Strict mode: 1 issue(s) found"]
248248

249249

250+
def test_check_gate_strict_counts_circular_dependencies_but_not_advisory_quality():
251+
results = {
252+
"circular_dependencies": [
253+
{"rule_id": "SKY-CIRC", "cycle": ["pkg.left", "pkg.right"]},
254+
{"rule_id": "SKY-CIRC", "cycle": ["pkg.other", "pkg.last"]},
255+
],
256+
"quality": [{"rule_id": "SKY-Q802", "advisory": True}],
257+
}
258+
259+
passed, reasons = gk.check_gate(results, {}, strict=True)
260+
261+
assert passed is False
262+
assert reasons == ["Strict mode: 2 issue(s) found"]
263+
264+
265+
def test_circular_dependencies_do_not_change_ordinary_gate_thresholds():
266+
results = {
267+
"circular_dependencies": [
268+
{"rule_id": "SKY-CIRC", "severity": "HIGH", "cycle": ["left", "right"]}
269+
],
270+
}
271+
272+
passed, reasons = gk.check_gate(
273+
results,
274+
{
275+
"gate": {
276+
"max_quality": 0,
277+
"max_high": 0,
278+
"max_security": 0,
279+
"max_dead_code": 0,
280+
}
281+
},
282+
)
283+
284+
assert passed is True
285+
assert reasons == []
286+
287+
250288
def test_reliability_does_not_affect_security_thresholds():
251289
results = {
252290
"danger": [],

0 commit comments

Comments
 (0)