Skip to content
Draft
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
11 changes: 7 additions & 4 deletions desloppify/app/commands/show/formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,13 @@
]


def format_detail(detail: dict) -> list[str]:
"""Build display parts from a finding's detail dict."""
def format_detail(detail: object) -> list[str]:
"""Build display parts from a finding's detail payload."""
if isinstance(detail, str):
return [f"detail: {detail}"] if detail else []
if not isinstance(detail, dict):
return []

parts = []
for key, label, formatter in DETAIL_DISPLAY:
value = detail.get(key)
Expand Down Expand Up @@ -60,8 +65,6 @@ def format_detail(detail: dict) -> list[str]:

def suppressed_match_estimate(pattern: str, hidden_by_detector: dict[str, int]) -> int:
"""Estimate hidden-match count for a show pattern using detector-level noise totals."""
if not isinstance(pattern, str) or not isinstance(hidden_by_detector, dict):
return 0
detector = pattern.split("::", 1)[0]
return int(hidden_by_detector.get(detector, 0))

Expand Down
3 changes: 2 additions & 1 deletion desloppify/app/commands/show/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ def _print_single_finding(finding: dict, *, show_code: bool) -> None:
if detail_parts:
print(colorize(f" {' · '.join(detail_parts)}", "dim"))
if show_code:
detail = finding.get("detail", {})
detail_raw = finding.get("detail", {})
detail = detail_raw if isinstance(detail_raw, dict) else {}
target_line = (
detail.get("line") or (detail.get("lines", [None]) or [None])[0]
)
Expand Down
6 changes: 5 additions & 1 deletion desloppify/languages/_framework/treesitter/phases.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ def run(path, lang):
f"{e['component_count']} disconnected function clusters "
f"({e['function_count']} functions) — likely mixed responsibilities"
),
detail=f"Clusters: {families}",
detail={
"cluster_count": e["component_count"],
"family": families,
"families": e["families"],
},
))
if entries:
potentials["responsibility_cohesion"] = len(entries)
Expand Down
25 changes: 22 additions & 3 deletions desloppify/languages/go/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from __future__ import annotations

from desloppify.core._internal.text_utils import get_area
from desloppify.engine.policy.zones import COMMON_ZONE_RULES, Zone, ZoneRule
from desloppify.engine.policy.zones import COMMON_ZONE_RULES, FileZoneMap, Zone, ZoneRule
from desloppify.hook_registry import register_lang_hooks
from desloppify.languages import register_lang
from desloppify.languages._framework.base.phase_builders import (
Expand All @@ -22,12 +22,18 @@
from desloppify.languages.go import test_coverage as go_test_coverage_hooks
from desloppify.languages.go.commands import get_detect_commands
from desloppify.languages.go.detectors.deps import build_dep_graph as build_go_dep_graph
from desloppify.languages.go.detectors.security import detect_go_security
from desloppify.languages.go.extractors import (
GO_FILE_EXCLUSIONS,
extract_functions,
find_go_files,
)
from desloppify.languages.go.phases import _phase_structural
from desloppify.languages.go.phases import (
_phase_coupling,
_phase_smells,
_phase_structural,
_phase_unused,
)
from desloppify.languages.go.review import (
HOLISTIC_REVIEW_DIMENSIONS,
LOW_VALUE_PATTERN,
Expand Down Expand Up @@ -62,6 +68,9 @@ def __init__(self):
barrel_names=set(),
phases=[
DetectorPhase("Structural analysis", _phase_structural),
DetectorPhase("Unused symbols", _phase_unused),
DetectorPhase("Code smells", _phase_smells),
DetectorPhase("Coupling + cycles + orphaned", _phase_coupling),
make_tool_phase(
"golangci-lint",
"golangci-lint run --out-format=json",
Expand All @@ -72,7 +81,11 @@ def __init__(self):
make_tool_phase(
"go vet", "go vet ./...", "gnu", "vet_error", tier=3
),
*all_treesitter_phases("go"),
# Exclude tree-sitter unused imports: Go compiler already
# enforces unused imports (compile error), and the generic
# name extractor mishandles Go versioned paths (e.g. v2).
*[p for p in all_treesitter_phases("go")
if p.label != "Unused imports"],
detector_phase_signature(),
detector_phase_test_coverage(),
detector_phase_security(),
Expand Down Expand Up @@ -100,3 +113,9 @@ def __init__(self):
extract_functions=extract_functions,
zone_rules=GO_ZONE_RULES,
)

def detect_lang_security(
self, files: list[str], zone_map: FileZoneMap | None
) -> tuple[list[dict], int]:
"""Go-specific security checks."""
return detect_go_security(files, zone_map)
93 changes: 83 additions & 10 deletions desloppify/languages/go/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,26 @@
from __future__ import annotations

import argparse
from pathlib import Path

from desloppify.engine.detectors import gods as gods_detector_mod
from desloppify.languages._framework.commands_base import (
build_standard_detect_registry,
make_cmd_complexity,
make_cmd_cycles,
make_cmd_deps,
make_cmd_dupes,
make_cmd_large,
make_cmd_naming,
make_cmd_orphaned,
make_cmd_single_use,
make_cmd_smells,
)
from desloppify.core.discovery_api import rel
from desloppify.core.output_api import display_entries
from desloppify.languages.go.detectors.deps import build_dep_graph
from desloppify.languages.go.detectors.gods import GO_GOD_RULES, extract_go_structs
from desloppify.languages.go.detectors.smells import detect_smells
from desloppify.languages.go.detectors.unused import detect_unused
from desloppify.languages.go.extractors import extract_functions, find_go_files
from desloppify.languages.go.phases import GO_COMPLEXITY_SIGNALS

Expand All @@ -38,6 +47,16 @@
extra_barrel_names=set(),
)
_cmd_dupes_impl = make_cmd_dupes(extract_functions_fn=extract_functions)
_cmd_single_use_impl = make_cmd_single_use(
build_dep_graph=build_dep_graph,
barrel_names=set(),
)
_cmd_smells_impl = make_cmd_smells(detect_smells)
_cmd_naming_impl = make_cmd_naming(
find_go_files,
skip_names={"main.go", "doc.go"},
skip_dirs={"vendor"},
)


def cmd_large(args: argparse.Namespace) -> None:
Expand All @@ -64,13 +83,67 @@ def cmd_dupes(args: argparse.Namespace) -> None:
_cmd_dupes_impl(args)


def get_detect_commands() -> dict[str, object]:
"""Return the standard detect command registry for Go."""
return build_standard_detect_registry(
cmd_deps=cmd_deps,
cmd_cycles=cmd_cycles,
cmd_orphaned=cmd_orphaned,
cmd_dupes=cmd_dupes,
cmd_large=cmd_large,
cmd_complexity=cmd_complexity,
def cmd_single_use(args: argparse.Namespace) -> None:
_cmd_single_use_impl(args)


def cmd_smells(args: argparse.Namespace) -> None:
_cmd_smells_impl(args)


def cmd_naming(args: argparse.Namespace) -> None:
_cmd_naming_impl(args)


def cmd_unused(args: argparse.Namespace) -> None:
entries, total, available = detect_unused(Path(args.path))
if not available:
empty = "staticcheck is not installed — install it for unused symbol detection."
else:
empty = "No unused symbols found."
display_entries(
args,
entries,
label=f"Unused symbols ({total} files checked)",
empty_msg=empty,
columns=["File", "Line", "Name", "Category"],
widths=[55, 6, 30, 10],
row_fn=lambda e: [rel(e["file"]), str(e["line"]), e["name"], e["category"]],
)


def cmd_gods(args: argparse.Namespace) -> None:
entries, _ = gods_detector_mod.detect_gods(
extract_go_structs(Path(args.path)), GO_GOD_RULES
)
display_entries(
args,
entries,
label="God structs",
empty_msg="No god structs found.",
columns=["File", "Struct", "LOC", "Why"],
widths=[50, 20, 6, 45],
row_fn=lambda e: [
rel(e["file"]),
e["name"],
str(e["loc"]),
", ".join(e["reasons"]),
],
)


def get_detect_commands() -> dict[str, object]:
"""Return the detect command registry for Go."""
return {
"deps": cmd_deps,
"cycles": cmd_cycles,
"orphaned": cmd_orphaned,
"dupes": cmd_dupes,
"large": cmd_large,
"complexity": cmd_complexity,
"single_use": cmd_single_use,
"smells": cmd_smells,
"naming": cmd_naming,
"unused": cmd_unused,
"gods": cmd_gods,
}
132 changes: 124 additions & 8 deletions desloppify/languages/go/detectors/deps.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,135 @@
"""Go dependency graph builder (stub).

Originally contributed by tinker495 (KyuSeok Jung) in PR #128.
Go import resolution is not yet implemented — returns an empty graph.
"""
"""Go dependency graph builder — regex-based import parsing."""

from __future__ import annotations

import os
import re
from collections import defaultdict
from pathlib import Path
from typing import Any

from desloppify.engine.detectors.graph import finalize_graph
from desloppify.languages._framework.treesitter._imports import resolve_go_import
from desloppify.languages.go.extractors import find_go_files

# Match single import: import "path" or import alias "path"
_SINGLE_IMPORT_RE = re.compile(
r'^\s*import\s+(?:\w+\s+)?"([^"]+)"'
)

# Match start of grouped import block: import (
_GROUP_IMPORT_START_RE = re.compile(r'^\s*import\s*\(')

# Match import line inside group: "path" or alias "path" or . "path"
_GROUP_IMPORT_LINE_RE = re.compile(
r'^\s*(?:\w+\s+|\.\s+)?"([^"]+)"'
)


def _extract_imports(content: str) -> list[str]:
"""Extract all import paths from Go source content."""
imports: list[str] = []
lines = content.splitlines()
i = 0
while i < len(lines):
line = lines[i]

# Check for grouped import
if _GROUP_IMPORT_START_RE.match(line):
i += 1
while i < len(lines):
group_line = lines[i].strip()
if group_line == ')':
break
m = _GROUP_IMPORT_LINE_RE.match(lines[i])
if m:
imports.append(m.group(1))
i += 1
i += 1
continue

# Check for single import
m = _SINGLE_IMPORT_RE.match(line)
if m:
imports.append(m.group(1))

i += 1
return imports


def build_dep_graph(
path: Path,
roslyn_cmd: str | None = None,
) -> dict[str, dict[str, Any]]:
"""Build Go dependency graph — stub returning empty dict."""
del path, roslyn_cmd
return {}
"""Build Go dependency graph from import declarations.

Parses Go import blocks (single and grouped), resolves local imports
via go.mod module path, and returns the standard graph shape.
"""
del roslyn_cmd # Not used for Go

scan_path = str(path.resolve())
files = find_go_files(path)

# Normalize all file paths to absolute for consistent matching.
# find_go_files may return relative paths while resolve_go_import
# returns absolute paths — both must use the same form.
abs_files = [
os.path.normpath(os.path.join(scan_path, f))
if not os.path.isabs(f) else os.path.normpath(f)
for f in files
]
file_set = set(abs_files)

# Initialize graph with all files
graph: dict[str, dict[str, Any]] = {}
for f in abs_files:
graph[f] = {"imports": set(), "importers": set()}

# Track package declarations per file for same-package edge linking
dir_pkg: dict[tuple[str, str], list[str]] = defaultdict(list)

for filepath in abs_files:
try:
content = Path(filepath).read_text(errors="replace")
except OSError:
continue

# Collect package name for same-package linking (single read)
pkg_match = _PACKAGE_RE.search(content)
if pkg_match:
key = (str(Path(filepath).parent), pkg_match.group(1))
dir_pkg[key].append(filepath)

import_paths = _extract_imports(content)
for import_path in import_paths:
resolved = resolve_go_import(import_path, filepath, scan_path)
if resolved is None:
continue

resolved = os.path.normpath(resolved)

# Only track edges within the scanned file set
if resolved not in file_set:
continue

graph[filepath]["imports"].add(resolved)
if resolved in graph:
graph[resolved]["importers"].add(filepath)

# Add implicit same-package edges: Go files in the same directory
# sharing the same `package` declaration are implicitly linked.
# Without these edges, every file that isn't cross-package imported
# would appear "orphaned."
for pkg_files in dir_pkg.values():
if len(pkg_files) < 2:
continue
rep = pkg_files[0]
for other in pkg_files[1:]:
graph[rep]["importers"].add(other)
graph[other]["importers"].add(rep)

return finalize_graph(graph)


_PACKAGE_RE = re.compile(r"^\s*package\s+(\w+)", re.MULTILINE)
Loading