deadpush 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
deadpush/rules.py ADDED
@@ -0,0 +1,190 @@
1
+ """
2
+ Runtime configuration for deadpush — agent-configurable rules.
3
+
4
+ Agents can modify guardrail behavior at runtime via MCP tools.
5
+ Changes persist in .deadpush/rules.json and survive server restarts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import json
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ RULES_FILE = ".deadpush/rules.json"
18
+
19
+ GUARDRAIL_LEVELS = ["off", "warn", "block"]
20
+
21
+ DEFAULT_RULES: dict[str, Any] = {
22
+ "allowed_patterns": [],
23
+ "ignored_paths": [],
24
+ "guardrail_levels": {
25
+ "prompt_injection": "block",
26
+ "secret": "block",
27
+ "security": "block",
28
+ "layer": "block",
29
+ "sensitive": "block",
30
+ "destructive": "warn",
31
+ "debris": "warn",
32
+ "dependency": "warn",
33
+ },
34
+ }
35
+
36
+
37
+ class RuntimeConfig:
38
+ """Persistent runtime configuration for agent-customizable guardrails.
39
+
40
+ Stored in .deadpush/rules.json. Survives server restarts.
41
+ Merged with base config (pyproject.toml) at load time.
42
+ """
43
+
44
+ def __init__(self, repo_root: Path):
45
+ self.repo_root = repo_root
46
+ self.rules_path = repo_root / RULES_FILE
47
+ self._data: dict[str, Any] = {}
48
+ self._compiled: list[tuple[re.Pattern, str]] = [] # (compiled_regex, description)
49
+ self._load()
50
+
51
+ @classmethod
52
+ def from_dict(cls, repo_root: Path, data: dict[str, Any]) -> RuntimeConfig:
53
+ """Create a RuntimeConfig from a dict without file I/O (primarily for tests)."""
54
+ rc = cls.__new__(cls)
55
+ rc.repo_root = repo_root
56
+ rc.rules_path = repo_root / RULES_FILE
57
+ rc._data = copy.deepcopy(data)
58
+ rc._merge_defaults()
59
+ rc._rebuild_cache()
60
+ return rc
61
+
62
+ # ------------------------------------------------------------------
63
+ # Persistence
64
+ # ------------------------------------------------------------------
65
+ def _load(self):
66
+ if self.rules_path.exists():
67
+ try:
68
+ self._data = json.loads(self.rules_path.read_text(encoding="utf-8"))
69
+ except Exception:
70
+ self._data = {}
71
+ self._merge_defaults()
72
+ self._rebuild_cache()
73
+
74
+ def _save(self):
75
+ self.rules_path.parent.mkdir(parents=True, exist_ok=True)
76
+ self.rules_path.write_text(json.dumps(self._data, indent=2, default=str), encoding="utf-8")
77
+
78
+ def _merge_defaults(self):
79
+ defaults = copy.deepcopy(DEFAULT_RULES)
80
+ for key, default_val in defaults.items():
81
+ if key not in self._data:
82
+ self._data[key] = default_val
83
+ elif isinstance(default_val, dict):
84
+ for subkey, subval in default_val.items():
85
+ if subkey not in self._data[key]:
86
+ self._data[key][subkey] = subval
87
+
88
+ def _rebuild_cache(self):
89
+ self._compiled = []
90
+ for entry in self._data.get("allowed_patterns", []):
91
+ pattern = entry.get("pattern", "")
92
+ desc = entry.get("description", "")
93
+ try:
94
+ self._compiled.append((re.compile(pattern), desc))
95
+ except re.error:
96
+ pass
97
+
98
+ # ------------------------------------------------------------------
99
+ # Allowlist — patterns to skip during guardrail checks
100
+ # ------------------------------------------------------------------
101
+ def is_allowed(self, matched_text: str) -> bool:
102
+ """Check if a matched pattern is in the allowlist."""
103
+ for compiled_re, _ in self._compiled:
104
+ if compiled_re.search(matched_text):
105
+ return True
106
+ return False
107
+
108
+ def add_allowed_pattern(self, pattern: str, description: str = "") -> None:
109
+ """Add a regex pattern to the allowlist."""
110
+ # Validate the regex
111
+ re.compile(pattern)
112
+ patterns = self._data.setdefault("allowed_patterns", [])
113
+ # Avoid duplicates
114
+ for entry in patterns:
115
+ if entry.get("pattern") == pattern:
116
+ entry["description"] = description
117
+ self._rebuild_cache()
118
+ self._save()
119
+ return
120
+ patterns.append({"pattern": pattern, "description": description})
121
+ self._rebuild_cache()
122
+ self._save()
123
+
124
+ def remove_allowed_pattern(self, pattern: str) -> bool:
125
+ """Remove a pattern from the allowlist. Returns True if found."""
126
+ patterns = self._data.get("allowed_patterns", [])
127
+ before = len(patterns)
128
+ self._data["allowed_patterns"] = [p for p in patterns if p.get("pattern") != pattern]
129
+ if len(self._data["allowed_patterns"]) != before:
130
+ self._rebuild_cache()
131
+ self._save()
132
+ return True
133
+ return False
134
+
135
+ # ------------------------------------------------------------------
136
+ # Path ignore list
137
+ # ------------------------------------------------------------------
138
+ def is_path_ignored(self, rel_path: str) -> bool:
139
+ """Check if a relative path is in the ignore list."""
140
+ ignored = self._data.get("ignored_paths", [])
141
+ for ign in ignored:
142
+ if ign.endswith("*") and rel_path.startswith(ign.rstrip("*")):
143
+ return True
144
+ if rel_path == ign:
145
+ return True
146
+ return False
147
+
148
+ def ignore_path(self, rel_path: str) -> None:
149
+ """Add a path to the ignore list."""
150
+ ignored = self._data.setdefault("ignored_paths", [])
151
+ if rel_path not in ignored:
152
+ ignored.append(rel_path)
153
+ self._save()
154
+
155
+ def remove_ignored_path(self, rel_path: str) -> bool:
156
+ """Remove a path from the ignore list."""
157
+ ignored = self._data.get("ignored_paths", [])
158
+ if rel_path in ignored:
159
+ self._data["ignored_paths"] = [p for p in ignored if p != rel_path]
160
+ self._save()
161
+ return True
162
+ return False
163
+
164
+ # ------------------------------------------------------------------
165
+ # Guardrail levels
166
+ # ------------------------------------------------------------------
167
+ def get_guardrail_level(self, category: str) -> str:
168
+ """Get the level for a guardrail category: off, warn, block."""
169
+ return self._data.get("guardrail_levels", {}).get(category, "block")
170
+
171
+ def set_guardrail_level(self, category: str, level: str) -> None:
172
+ """Set the level for a guardrail category."""
173
+ if level not in GUARDRAIL_LEVELS:
174
+ raise ValueError(f"Level must be one of: {', '.join(GUARDRAIL_LEVELS)}")
175
+ levels = self._data.setdefault("guardrail_levels", {})
176
+ levels[category] = level
177
+ self._save()
178
+
179
+ # ------------------------------------------------------------------
180
+ # Full state
181
+ # ------------------------------------------------------------------
182
+ def to_dict(self) -> dict[str, Any]:
183
+ return dict(self._data)
184
+
185
+ def reset(self) -> None:
186
+ """Reset all runtime config to defaults."""
187
+ self._data = {}
188
+ self._merge_defaults()
189
+ self._rebuild_cache()
190
+ self._save()
deadpush/sarif.py ADDED
@@ -0,0 +1,123 @@
1
+ """
2
+ Enhanced SARIF v2.1.0 generator for deadpush.
3
+
4
+ Production-ready output for IDEs and GitHub Advanced Security.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .graph import DeadSymbol, DebrisFile
15
+
16
+
17
+ def generate_sarif(
18
+ dead_symbols: list[DeadSymbol],
19
+ debris: list[DebrisFile],
20
+ repo_root: Path,
21
+ ) -> dict[str, Any]:
22
+ results = []
23
+
24
+ for d in debris:
25
+ level = "error" if d.block_push else "warning"
26
+ results.append({
27
+ "ruleId": f"deadpush/debris/{d.category}",
28
+ "level": level,
29
+ "message": {
30
+ "text": d.suggestion,
31
+ "markdown": f"**Category:** {d.category}\n\n{d.suggestion}\n\n**Reasons:**\n" +
32
+ "\n".join(f"- {r}" for r in d.reasons)
33
+ },
34
+ "locations": [{
35
+ "physicalLocation": {
36
+ "artifactLocation": {
37
+ "uri": d.path,
38
+ "uriBaseId": "%SRCROOT%"
39
+ }
40
+ }
41
+ }],
42
+ "properties": {
43
+ "deadpush_category": d.category,
44
+ "confidence": round(d.confidence, 3),
45
+ "block_push": d.block_push
46
+ }
47
+ })
48
+
49
+ for ds in dead_symbols:
50
+ level = "warning" if ds.tier in ("definite", "probable") else "note"
51
+ results.append({
52
+ "ruleId": f"deadpush/deadcode/{ds.tier}",
53
+ "level": level,
54
+ "message": {
55
+ "text": f"{ds.symbol.name} is {ds.tier} dead code",
56
+ "markdown": f"**Symbol:** `{ds.symbol.name}`\n**Tier:** {ds.tier}\n**Confidence:** {ds.confidence*100:.0f}%\n\n" +
57
+ "\n".join(f"- {r}" for r in ds.reasons)
58
+ },
59
+ "locations": [{
60
+ "physicalLocation": {
61
+ "artifactLocation": {
62
+ "uri": str(Path(ds.symbol.path).relative_to(repo_root)),
63
+ "uriBaseId": "%SRCROOT%"
64
+ },
65
+ "region": {
66
+ "startLine": ds.symbol.line,
67
+ "startColumn": 1
68
+ }
69
+ }
70
+ }],
71
+ "properties": {
72
+ "deadpush_tier": ds.tier,
73
+ "confidence": round(ds.confidence, 3),
74
+ "kind": ds.symbol.kind,
75
+ "safe_to_delete": ds.safe_to_delete,
76
+ "delete_order": ds.delete_order
77
+ }
78
+ })
79
+
80
+ sarif = {
81
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
82
+ "version": "2.1.0",
83
+ "runs": [{
84
+ "tool": {
85
+ "driver": {
86
+ "name": "deadpush",
87
+ "version": "0.3.0",
88
+ "informationUri": "https://github.com/harris-ahmad/deadpush",
89
+ "rules": [
90
+ {
91
+ "id": "deadpush/debris/llm_context_file",
92
+ "name": "LLM Context File",
93
+ "shortDescription": {"text": "AI coding assistant context/instructions file committed to repository"},
94
+ "defaultConfiguration": {"level": "error"}
95
+ },
96
+ {
97
+ "id": "deadpush/deadcode/definite",
98
+ "name": "Definite Dead Code",
99
+ "shortDescription": {"text": "Code unreachable from any production entry point"},
100
+ "defaultConfiguration": {"level": "warning"}
101
+ },
102
+ {
103
+ "id": "deadpush/deadcode/ai_regenerated_duplicate",
104
+ "name": "AI Regenerated Duplicate",
105
+ "shortDescription": {"text": "File structurally similar to another (likely LLM-regenerated)"},
106
+ "defaultConfiguration": {"level": "warning"}
107
+ }
108
+ ]
109
+ }
110
+ },
111
+ "results": results,
112
+ "invocations": [{
113
+ "executionSuccessful": True,
114
+ "startTimeUtc": datetime.now(timezone.utc).isoformat()
115
+ }]
116
+ }]
117
+ }
118
+ return sarif
119
+
120
+
121
+ def write_sarif(sarif_data: dict[str, Any], output_path: Path) -> None:
122
+ output_path.parent.mkdir(parents=True, exist_ok=True)
123
+ output_path.write_text(json.dumps(sarif_data, indent=2), encoding="utf-8")
deadpush/scorer.py ADDED
@@ -0,0 +1,151 @@
1
+ """
2
+ Scoring + classification of dead symbols using multi-factor deadness analysis.
3
+
4
+ Integrates:
5
+ - MultiFactorDeadnessScorer (6-factor analysis)
6
+ - reachability info
7
+ - per-symbol dynamic_risk from language plugins
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .config import Config
16
+ from .graph import CallGraph, DeadSymbol, Symbol
17
+ from .deadness import DeadnessResult, MultiFactorDeadnessScorer
18
+ from .registration import RegistrationDetector
19
+ from .importgraph import ImportAnalyzer
20
+
21
+
22
+ def score_symbol(
23
+ sym: Symbol,
24
+ graph: CallGraph,
25
+ reachability: Any,
26
+ config: Config,
27
+ scorer: MultiFactorDeadnessScorer | None = None,
28
+ ) -> DeadSymbol | None:
29
+ """Return a DeadSymbol wrapper or None if we decide not to report it."""
30
+ if sym.kind == "file":
31
+ return None
32
+
33
+ reasons: list[str] = []
34
+ tier = "uncertain"
35
+ confidence = 0.6
36
+ safe = True
37
+ order = 10
38
+ alive_score = 0.0
39
+ factor_breakdown: dict[str, float] = {}
40
+
41
+ # Multi-factor scoring first (if available)
42
+ if scorer is not None:
43
+ result = scorer.score(sym)
44
+ if result is None:
45
+ return None # abstention
46
+
47
+ alive_score = result.alive_score
48
+ factor_breakdown = result.factors
49
+ if result.reasons:
50
+ reasons.extend(result.reasons)
51
+
52
+ # Map deadness tier to legacy tier
53
+ match result.tier:
54
+ case "high":
55
+ tier = "definite"
56
+ confidence = 0.95
57
+ order = 1
58
+ safe = True
59
+ case "medium":
60
+ tier = "probable"
61
+ confidence = 0.85
62
+ order = 3
63
+ safe = True
64
+ case "low":
65
+ tier = "suspicious"
66
+ confidence = 0.65
67
+ order = 5
68
+ safe = True
69
+ case "uncertain":
70
+ tier = "uncertain"
71
+ confidence = 0.4
72
+ order = 10
73
+ safe = False
74
+
75
+ # If no scorer (legacy path), fall through to reachability-based logic
76
+ else:
77
+ sid = sym.id
78
+ if sid in getattr(reachability, "unreachable", set()):
79
+ tier = "definite"
80
+ confidence = 0.92
81
+ reasons.append("No path from any detected entry point")
82
+ order = 1
83
+ elif sid in getattr(reachability, "uncertain", set()):
84
+ tier = "suspicious"
85
+ confidence = 0.65
86
+ reasons.append("Only reachable via dynamic or hard-to-resolve call")
87
+ else:
88
+ return None
89
+
90
+ # Boost with language plugin risk signal
91
+ if sym.dynamic_risk > 0.4:
92
+ confidence = min(0.98, confidence + 0.15)
93
+ reasons.append(f"High dynamic risk ({sym.dynamic_risk:.0%}) from language analysis")
94
+ if sym.dynamic_risk > 0.7:
95
+ tier = "probable" if tier == "definite" else tier
96
+ safe = False
97
+
98
+ # Kind-based
99
+ if sym.kind in ("method", "function") and "test" in sym.name.lower():
100
+ confidence *= 0.6
101
+ reasons.append("Likely test/helper (low priority)")
102
+
103
+ if sym.kind == "class" and confidence > 0.8:
104
+ order = 2
105
+
106
+ low_quality_names = {"foo", "bar", "baz", "temp", "tmp", "unused", "deadcode", "placeholder"}
107
+ if sym.name.lower() in low_quality_names:
108
+ confidence = min(0.99, confidence + 0.1)
109
+ reasons.append("Suspicious placeholder name often seen in generated code")
110
+
111
+ ds = DeadSymbol(
112
+ symbol=sym,
113
+ tier=tier,
114
+ confidence=round(confidence, 3),
115
+ reasons=reasons or ["Unreachable from entry points"],
116
+ safe_to_delete=safe,
117
+ delete_order=order,
118
+ alive_score=round(alive_score, 3),
119
+ tier_new=result.tier if scorer is not None else "uncertain",
120
+ factor_breakdown=factor_breakdown,
121
+ )
122
+ return ds
123
+
124
+
125
+ def build_scorer(
126
+ config: Config,
127
+ graph: CallGraph,
128
+ roots: set[str],
129
+ all_file_paths: list[Path],
130
+ custom_registrations: list[str] | None = None,
131
+ test_file_paths: list[Path] | None = None,
132
+ ) -> MultiFactorDeadnessScorer:
133
+ """Build a MultiFactorDeadnessScorer from analysis context."""
134
+ registration = RegistrationDetector(all_file_paths, config.repo_root)
135
+ if custom_registrations:
136
+ for pat in custom_registrations:
137
+ registration.add_custom_pattern(pat)
138
+
139
+ imports = ImportAnalyzer(all_file_paths, config.repo_root)
140
+
141
+ scorer = MultiFactorDeadnessScorer(
142
+ config=config,
143
+ repo_root=config.repo_root,
144
+ graph=graph,
145
+ registration=registration,
146
+ imports=imports,
147
+ roots=roots,
148
+ all_file_paths=all_file_paths,
149
+ test_file_paths=test_file_paths,
150
+ )
151
+ return scorer
deadpush/security.py ADDED
@@ -0,0 +1,187 @@
1
+ """
2
+ Security Boundary Map — tracks security-sensitive functions and their test coverage.
3
+
4
+ AI agents may introduce unsafe operations (eval, exec, raw SQL, crypto) without
5
+ corresponding tests. This module identifies those boundaries and flags untested ones.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ SENSITIVE_PATTERNS: list[tuple[str, str, str]] = [
18
+ # (category, description, regex pattern for Python/JS source)
19
+ ("code_exec", "Dynamic code execution", r"\b(exec|eval)\s*\("),
20
+ ("command_injection", "Shell command execution", r"\b(subprocess\.(call|run|Popen|check_output|check_call)|os\.system|os\.popen|shlex)\s*\("),
21
+ ("file_write", "File write operation", r"\bopen\s*\(.*[rwab]+\s*\)"),
22
+ ("file_delete", "File deletion", r"\b(os\.remove|os\.unlink|shutil\.rmtree|Path\.unlink|Path\.rmdir)\s*\("),
23
+ ("crypto", "Cryptographic operation", r"\b(hashlib|hmac|Cryptodome|Crypto|nacl|bcrypt|argon2|passlib)\b"),
24
+ ("network", "Network I/O", r"\b(socket|requests\.(get|post|put|delete|patch)|urllib|aiohttp|httpx)\s*\("),
25
+ ("sql_injection", "SQL query construction", r"\bexecute\s*\(\s*['\"](SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER)"),
26
+ ("pickle", "Unsafe deserialization", r"\b(pickle\.loads|pickle\.load|jsonpickle|yaml\.load(?!_safe)|shelve)\s*\("),
27
+ ("insecure_import", "Insecure module import", r"\bimport\s+(pickle|shelve|marshal|subprocess|ctypes)\b"),
28
+ ("temp_file", "Temporary file", r"\b(tempfile|NamedTemporaryFile|mkstemp|mkdtemp)\b"),
29
+ ("permission", "Permission/chmod operation", r"\b(os\.chmod|os\.chown|os\.setuid|os\.setgid)\s*\("),
30
+ ("path_traversal", "Path traversal risk", r"\b(Path|os\.path\.join)\s*\(.*\+\s*"),
31
+ ]
32
+
33
+
34
+ SENSITIVE_FUNCTION_NAMES: set[str] = {
35
+ # Python
36
+ "exec", "eval", "compile",
37
+ # Shell
38
+ "system", "popen",
39
+ # File
40
+ "remove", "unlink", "rmtree",
41
+ # Serialization
42
+ "loads", "load",
43
+ # Permissions
44
+ "chmod", "chown",
45
+ }
46
+
47
+
48
+ @dataclass
49
+ class SecurityBoundary:
50
+ """A security-sensitive operation found in source code."""
51
+ file: str
52
+ line: int
53
+ category: str
54
+ description: str
55
+ matched_text: str
56
+ has_test: bool = False
57
+ test_file: str = ""
58
+
59
+
60
+ @dataclass
61
+ class SecurityReport:
62
+ """Full security boundary scan result."""
63
+ boundaries: list[SecurityBoundary] = field(default_factory=list)
64
+ untested: list[SecurityBoundary] = field(default_factory=list)
65
+ tested: list[SecurityBoundary] = field(default_factory=list)
66
+
67
+
68
+ class SecurityScanner:
69
+ """Scans source files for security-sensitive operations and checks test coverage."""
70
+
71
+ def __init__(self, repo_root: Path | None = None):
72
+ self.repo_root = repo_root or Path.cwd()
73
+
74
+ MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
75
+
76
+ def scan_file(self, file_path: Path) -> list[SecurityBoundary]:
77
+ """Find security-sensitive operations in a single file."""
78
+ boundaries: list[SecurityBoundary] = []
79
+ try:
80
+ if file_path.stat().st_size > self.MAX_FILE_SIZE:
81
+ return boundaries
82
+ source = file_path.read_text(encoding="utf-8", errors="ignore")
83
+ except Exception:
84
+ return boundaries
85
+
86
+ rel_path = _relative(file_path, self.repo_root)
87
+
88
+ for category, desc, pattern in SENSITIVE_PATTERNS:
89
+ for m in re.finditer(pattern, source, re.IGNORECASE):
90
+ line_num = source[:m.start()].count("\n") + 1
91
+ # Determine context: extract the line
92
+ lines = source.splitlines()
93
+ context_line = lines[line_num - 1].strip() if line_num <= len(lines) else ""
94
+ boundaries.append(SecurityBoundary(
95
+ file=rel_path,
96
+ line=line_num,
97
+ category=category,
98
+ description=desc,
99
+ matched_text=context_line[:80],
100
+ ))
101
+ return boundaries
102
+
103
+ def find_test_files(self) -> list[Path]:
104
+ """Find test files in the repository."""
105
+ test_files: list[Path] = []
106
+ for pattern in ("**/test_*.py", "**/*_test.py", "**/tests/**/*.py", "**/test_*.js", "**/*.test.js", "**/*.spec.js", "**/__tests__/**", "**/*.test.ts", "**/*.spec.ts"):
107
+ matches = list(self.repo_root.glob(pattern))
108
+ test_files.extend(matches)
109
+ # Deduplicate
110
+ seen: set[Path] = set()
111
+ deduped: list[Path] = []
112
+ for p in test_files:
113
+ resolved = p.resolve()
114
+ if resolved not in seen:
115
+ seen.add(resolved)
116
+ deduped.append(p)
117
+ return deduped
118
+
119
+ def find_calls_in_tests(self, target_name: str, test_files: list[Path]) -> list[tuple[Path, int]]:
120
+ """Find calls to a specific function name in test files."""
121
+ results: list[tuple[Path, int]] = []
122
+ try:
123
+ name_lower = target_name.lower()
124
+ except Exception:
125
+ return results
126
+
127
+ for tf in test_files:
128
+ try:
129
+ source = tf.read_text(encoding="utf-8", errors="ignore")
130
+ for m in re.finditer(rf'\b{re.escape(target_name)}\s*\(', source):
131
+ line_num = source[:m.start()].count("\n") + 1
132
+ results.append((tf, line_num))
133
+ except Exception:
134
+ pass
135
+ return results
136
+
137
+ def check_test_coverage(self, boundaries: list[SecurityBoundary]) -> SecurityReport:
138
+ """Check each boundary for test coverage."""
139
+ test_files = self.find_test_files()
140
+ report = SecurityReport()
141
+
142
+ for b in boundaries:
143
+ # Try to infer the function name from the matched text
144
+ func_name = _extract_func_name(b.matched_text)
145
+ if func_name:
146
+ calls = self.find_calls_in_tests(func_name, test_files)
147
+ if calls:
148
+ b.has_test = True
149
+ b.test_file = str(calls[0][0])
150
+ report.tested.append(b)
151
+ else:
152
+ report.untested.append(b)
153
+ else:
154
+ report.untested.append(b)
155
+
156
+ report.boundaries.append(b)
157
+
158
+ return report
159
+
160
+ def scan_and_report(self, files: list[Any]) -> SecurityReport:
161
+ """Scan a batch of files and check test coverage."""
162
+ all_boundaries: list[SecurityBoundary] = []
163
+ for f in files:
164
+ path = getattr(f, "path", None)
165
+ if path is None:
166
+ continue
167
+ all_boundaries.extend(self.scan_file(Path(path)))
168
+ return self.check_test_coverage(all_boundaries)
169
+
170
+
171
+ def _relative(path: Path, root: Path) -> str:
172
+ try:
173
+ return str(path.relative_to(root))
174
+ except ValueError:
175
+ return str(path)
176
+
177
+
178
+ def _extract_func_name(text: str) -> str | None:
179
+ """Extract the function/method name from matched source text."""
180
+ m = re.match(r'^.*?\b([\w.]+)\s*\(', text)
181
+ if m:
182
+ name = m.group(1)
183
+ # Remove module prefix for the last component
184
+ if "." in name:
185
+ name = name.rsplit(".", 1)[-1]
186
+ return name
187
+ return None