File size: 3,230 Bytes
baa4989
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
from __future__ import annotations

import ast
from dataclasses import dataclass


@dataclass(frozen=True)
class SemanticIssue:
    line: int
    severity: str
    message: str
    stage: str


def _build_undirected_graph(tree: ast.AST) -> dict[str, set[str]]:
    adjacency: dict[str, set[str]] = {}

    def ensure(node: str) -> None:
        adjacency.setdefault(node, set())

    for node in ast.walk(tree):
        if not isinstance(node, ast.Assign) or len(node.targets) != 1:
            continue
        target = node.targets[0]
        if not isinstance(target, ast.Name):
            continue
        var_name = target.id
        value = node.value
        if not isinstance(value, ast.Call):
            continue
        if not isinstance(value.func, ast.Name) or value.func.id != "Node":
            continue

        ensure(var_name)
        if len(value.args) >= 3 and isinstance(value.args[2], ast.List):
            for item in value.args[2].elts:
                if isinstance(item, ast.Name):
                    ensure(item.id)
                    adjacency[var_name].add(item.id)
                    adjacency[item.id].add(var_name)

    return adjacency


def _connected(adjacency: dict[str, set[str]], left: str, right: str) -> bool:
    if left == right:
        return True
    if left not in adjacency or right not in adjacency:
        return False

    seen = {left}
    stack = [left]
    while stack:
        current = stack.pop()
        for neighbor in adjacency.get(current, set()):
            if neighbor in seen:
                continue
            if neighbor == right:
                return True
            seen.add(neighbor)
            stack.append(neighbor)
    return False


def detect_semantic_issues(raw_code: str) -> list[SemanticIssue]:
    try:
        tree = ast.parse(raw_code)
    except SyntaxError:
        return []

    lines = raw_code.splitlines()
    adjacency = _build_undirected_graph(tree)
    issues: list[SemanticIssue] = []

    for node in ast.walk(tree):
        if not isinstance(node, ast.If):
            continue
        test = node.test
        if not isinstance(test, ast.Call):
            continue
        if not isinstance(test.func, ast.Name) or test.func.id != "breadth_first_search":
            continue
        if len(test.args) < 2:
            continue
        if not isinstance(test.args[0], ast.Name) or not isinstance(test.args[1], ast.Name):
            continue

        src = test.args[0].id
        dst = test.args[1].id
        line_no = int(getattr(node, "lineno", 1))
        context_start = max(0, line_no - 4)
        context = "\n".join(lines[context_start:line_no]).lower()

        if "unconnected" in context and _connected(adjacency, src, dst):
            issues.append(
                SemanticIssue(
                    line=line_no,
                    severity="medium",
                    stage="medium",
                    message=(
                        f"Comment claims unconnected nodes, but '{src}' and '{dst}' belong to the same undirected"
                        " component. Test intent is misleading and can hide logical regressions."
                    ),
                )
            )

    return issues