deviatdd 2.5.1__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.
Files changed (124) hide show
  1. deviatdd-2.5.1.dist-info/METADATA +386 -0
  2. deviatdd-2.5.1.dist-info/RECORD +124 -0
  3. deviatdd-2.5.1.dist-info/WHEEL +4 -0
  4. deviatdd-2.5.1.dist-info/entry_points.txt +2 -0
  5. deviatdd-2.5.1.dist-info/licenses/LICENSE +21 -0
  6. deviate/__init__.py +3 -0
  7. deviate/cli/__init__.py +824 -0
  8. deviate/cli/_common.py +155 -0
  9. deviate/cli/adhoc.py +183 -0
  10. deviate/cli/constitution.py +113 -0
  11. deviate/cli/feature.py +94 -0
  12. deviate/cli/init.py +485 -0
  13. deviate/cli/inspect.py +208 -0
  14. deviate/cli/macro.py +937 -0
  15. deviate/cli/meso.py +1894 -0
  16. deviate/cli/micro.py +3249 -0
  17. deviate/cli/review.py +441 -0
  18. deviate/core/__init__.py +0 -0
  19. deviate/core/_shared.py +20 -0
  20. deviate/core/agent.py +505 -0
  21. deviate/core/cache_discipline.py +65 -0
  22. deviate/core/commands.py +202 -0
  23. deviate/core/commit.py +86 -0
  24. deviate/core/complexity.py +36 -0
  25. deviate/core/constitution.py +85 -0
  26. deviate/core/contract.py +17 -0
  27. deviate/core/convention.py +147 -0
  28. deviate/core/epic.py +73 -0
  29. deviate/core/issues.py +46 -0
  30. deviate/core/prd.py +18 -0
  31. deviate/core/profile.py +33 -0
  32. deviate/core/repo.py +47 -0
  33. deviate/core/run_logger.py +50 -0
  34. deviate/core/tasks_ledger.py +69 -0
  35. deviate/core/treesitter/__init__.py +31 -0
  36. deviate/core/treesitter/analysis.py +457 -0
  37. deviate/core/treesitter/models.py +35 -0
  38. deviate/core/treesitter/parser.py +184 -0
  39. deviate/core/treesitter/queries/bash.scm +7 -0
  40. deviate/core/treesitter/queries/c_sharp.scm +11 -0
  41. deviate/core/treesitter/queries/cpp.scm +9 -0
  42. deviate/core/treesitter/queries/css.scm +4 -0
  43. deviate/core/treesitter/queries/dockerfile.scm +8 -0
  44. deviate/core/treesitter/queries/elixir.scm +6 -0
  45. deviate/core/treesitter/queries/go.scm +9 -0
  46. deviate/core/treesitter/queries/hcl.scm +3 -0
  47. deviate/core/treesitter/queries/html.scm +3 -0
  48. deviate/core/treesitter/queries/javascript.scm +12 -0
  49. deviate/core/treesitter/queries/json.scm +4 -0
  50. deviate/core/treesitter/queries/kotlin.scm +8 -0
  51. deviate/core/treesitter/queries/markdown.scm +4 -0
  52. deviate/core/treesitter/queries/python.scm +10 -0
  53. deviate/core/treesitter/queries/rust.scm +12 -0
  54. deviate/core/treesitter/queries/sql.scm +7 -0
  55. deviate/core/treesitter/queries/swift.scm +10 -0
  56. deviate/core/treesitter/queries/toml.scm +3 -0
  57. deviate/core/treesitter/queries/tsx.scm +14 -0
  58. deviate/core/treesitter/queries/typescript.scm +14 -0
  59. deviate/core/treesitter/queries/yaml.scm +4 -0
  60. deviate/core/validation.py +153 -0
  61. deviate/core/worktree.py +141 -0
  62. deviate/main.py +4 -0
  63. deviate/prompts/__init__.py +0 -0
  64. deviate/prompts/assembly.py +122 -0
  65. deviate/prompts/auto/__init__.py +1 -0
  66. deviate/prompts/auto/execute.md +62 -0
  67. deviate/prompts/auto/explore.md +103 -0
  68. deviate/prompts/auto/green.md +137 -0
  69. deviate/prompts/auto/judge.md +260 -0
  70. deviate/prompts/auto/plan.md +127 -0
  71. deviate/prompts/auto/prd.md +108 -0
  72. deviate/prompts/auto/red.md +156 -0
  73. deviate/prompts/auto/refactor.md +166 -0
  74. deviate/prompts/auto/research.md +111 -0
  75. deviate/prompts/auto/shard.md +90 -0
  76. deviate/prompts/auto/specify.md +77 -0
  77. deviate/prompts/auto/tasks.md +191 -0
  78. deviate/prompts/commands/deviate-adhoc.md +216 -0
  79. deviate/prompts/commands/deviate-architecture.md +147 -0
  80. deviate/prompts/commands/deviate-constitution.md +215 -0
  81. deviate/prompts/commands/deviate-e2e.md +264 -0
  82. deviate/prompts/commands/deviate-execute.md +223 -0
  83. deviate/prompts/commands/deviate-explore.md +253 -0
  84. deviate/prompts/commands/deviate-flows.md +226 -0
  85. deviate/prompts/commands/deviate-green.md +217 -0
  86. deviate/prompts/commands/deviate-hotfix.md +188 -0
  87. deviate/prompts/commands/deviate-init.md +170 -0
  88. deviate/prompts/commands/deviate-judge.md +193 -0
  89. deviate/prompts/commands/deviate-merge.md +221 -0
  90. deviate/prompts/commands/deviate-plan.md +158 -0
  91. deviate/prompts/commands/deviate-pr.md +144 -0
  92. deviate/prompts/commands/deviate-prd.md +216 -0
  93. deviate/prompts/commands/deviate-prune.md +260 -0
  94. deviate/prompts/commands/deviate-red.md +231 -0
  95. deviate/prompts/commands/deviate-refactor.md +211 -0
  96. deviate/prompts/commands/deviate-release.md +123 -0
  97. deviate/prompts/commands/deviate-research.md +359 -0
  98. deviate/prompts/commands/deviate-review.md +289 -0
  99. deviate/prompts/commands/deviate-shard.md +226 -0
  100. deviate/prompts/commands/deviate-tasks.md +281 -0
  101. deviate/prompts/commands/deviate-triage.md +141 -0
  102. deviate/prompts/constitution_seed.md +51 -0
  103. deviate/prompts/core/core.md +21 -0
  104. deviate/prompts/core/macro-auto.md +59 -0
  105. deviate/prompts/core/macro-command.md +54 -0
  106. deviate/prompts/core/macro-skill.md +54 -0
  107. deviate/prompts/core/meso-auto.md +63 -0
  108. deviate/prompts/core/meso-command.md +58 -0
  109. deviate/prompts/core/meso-skill.md +58 -0
  110. deviate/prompts/core/micro-auto.md +76 -0
  111. deviate/prompts/core/micro-command.md +67 -0
  112. deviate/prompts/core/micro-skill.md +67 -0
  113. deviate/prompts/extras/deviate-pr-graphite-routing.md +20 -0
  114. deviate/prompts/governance/__init__.py +0 -0
  115. deviate/prompts/governance/agents_seed.md +1 -0
  116. deviate/prompts/governance/claudemd_seed.md +1 -0
  117. deviate/prompts/governance/graphite_seed.md +18 -0
  118. deviate/prompts/governance/libref_seed.md +3 -0
  119. deviate/state/__init__.py +0 -0
  120. deviate/state/config.py +257 -0
  121. deviate/state/ledger.py +407 -0
  122. deviate/ui/__init__.py +5 -0
  123. deviate/ui/monitor.py +187 -0
  124. deviate/ui/render.py +26 -0
deviate/core/prd.py ADDED
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+
7
+ def extract_prd_requirements(prd_path: Path) -> list[str]:
8
+ if not prd_path.exists():
9
+ return []
10
+ content = prd_path.read_text(encoding="utf-8")
11
+ return re.findall(r"FR-\d+", content)
12
+
13
+
14
+ def validate_traceability(issue_body: str, prd_reqs: list[str]) -> dict:
15
+ result: dict[str, bool] = {}
16
+ for req in prd_reqs:
17
+ result[req] = req in issue_body
18
+ return result
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ ExecutionProfile = Literal["full", "fast", "secure"]
6
+
7
+ _PROFILE_DEFAULTS: dict[str, tuple[bool, bool]] = {
8
+ "full": (False, False),
9
+ "fast": (True, True),
10
+ "secure": (False, True),
11
+ }
12
+
13
+ _VALID_PROFILE_CHOICES = ", ".join(sorted(_PROFILE_DEFAULTS))
14
+
15
+
16
+ def resolve_profile(
17
+ profile: str,
18
+ no_judge: bool | None = None,
19
+ no_refactor: bool | None = None,
20
+ ) -> tuple[bool, bool]:
21
+ if profile not in _PROFILE_DEFAULTS:
22
+ raise ValueError(
23
+ f"Invalid profile '{profile}'. Must be one of: {_VALID_PROFILE_CHOICES}"
24
+ )
25
+
26
+ no_j, no_r = _PROFILE_DEFAULTS[profile]
27
+
28
+ if no_judge is not None:
29
+ no_j = no_judge
30
+ if no_refactor is not None:
31
+ no_r = no_refactor
32
+
33
+ return (no_j, no_r)
deviate/core/repo.py ADDED
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from deviate.core._shared import git_env as _git_env
7
+
8
+
9
+ def find_repo_root(start_at: Path | None = None) -> Path:
10
+ start_at = start_at or Path.cwd()
11
+ for parent in [start_at] + list(start_at.parents):
12
+ if (parent / ".git").exists():
13
+ return parent
14
+ raise ValueError(f"not a git repository: {start_at}")
15
+
16
+
17
+ def gather_git_state(repo: Path | None = None) -> dict:
18
+ repo = repo or Path.cwd()
19
+ result = subprocess.run(
20
+ ["git", "status", "--porcelain"],
21
+ cwd=repo,
22
+ env=_git_env(),
23
+ capture_output=True,
24
+ text=True,
25
+ check=True,
26
+ )
27
+ staged = []
28
+ unstaged = []
29
+ untracked = []
30
+ for line in result.stdout.splitlines():
31
+ if not line.strip():
32
+ continue
33
+ if line.startswith("??"):
34
+ untracked.append(line[3:])
35
+ else:
36
+ x = line[0]
37
+ y = line[1]
38
+ filename = line[3:]
39
+ if x != " ":
40
+ staged.append(filename)
41
+ if y != " ":
42
+ unstaged.append(filename)
43
+ return {
44
+ "staged_files": staged,
45
+ "unstaged_files": unstaged,
46
+ "untracked_files": untracked,
47
+ }
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import contextvars
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+
8
+ _current_run_log: contextvars.ContextVar[RunLogger | None] = contextvars.ContextVar(
9
+ "_current_run_log", default=None
10
+ )
11
+
12
+
13
+ def get_run_logger() -> RunLogger | None:
14
+ return _current_run_log.get()
15
+
16
+
17
+ def set_run_logger(logger: RunLogger | None) -> None:
18
+ _current_run_log.set(logger)
19
+
20
+
21
+ class RunLogger:
22
+ def __init__(self, root: Path) -> None:
23
+ self.log_dir = root / ".deviate" / "logs"
24
+ self.log_dir.mkdir(parents=True, exist_ok=True)
25
+ timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
26
+ self.log_file = self.log_dir / f"run_{timestamp}.log"
27
+ self._file = self.log_file.open("w", encoding="utf-8")
28
+
29
+ def log(self, event: str, **kwargs: object) -> None:
30
+ ts = datetime.now(timezone.utc).isoformat()
31
+ self._file.write(f"[{ts}] {event}\n")
32
+ for k, v in kwargs.items():
33
+ if isinstance(v, str) and "\n" in v:
34
+ self._file.write(f" {k}:\n")
35
+ for line in v.split("\n"):
36
+ self._file.write(f" {line}\n")
37
+ else:
38
+ self._file.write(f" {k}: {v}\n")
39
+ self._file.write("\n")
40
+ self._file.flush()
41
+
42
+ def close(self) -> None:
43
+ if not self._file.closed:
44
+ self._file.close()
45
+
46
+ def __enter__(self) -> RunLogger:
47
+ return self
48
+
49
+ def __exit__(self, *args: object) -> None:
50
+ self.close()
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from pydantic import ValidationError
7
+
8
+ from deviate.state.ledger import TaskRecord
9
+
10
+
11
+ _TASK_LINE_PATTERN = re.compile(r"^\s*-\s+(?:\[(?:x| )\]\s+)?(TSK-\d{3}-\d{2}):\s*(.+)")
12
+ _MODE_PATTERN = re.compile(r"\*\*Mode\*\*:\s*(\S+)")
13
+
14
+
15
+ def generate_jsonl_from_md(tasks_md: Path, issue_id: str) -> list[TaskRecord]:
16
+ content = tasks_md.read_text(encoding="utf-8")
17
+ records: list[TaskRecord] = []
18
+ current_id: str | None = None
19
+ current_desc: str | None = None
20
+ current_mode: str = "TDD"
21
+
22
+ for line in content.splitlines():
23
+ task_match = _TASK_LINE_PATTERN.match(line)
24
+ if task_match:
25
+ if current_id:
26
+ records.append(
27
+ _build_task_record(current_id, issue_id, current_desc, current_mode)
28
+ )
29
+ current_id = task_match.group(1)
30
+ current_desc = task_match.group(2).strip()
31
+ current_mode = "TDD"
32
+ elif current_id:
33
+ mode_match = _MODE_PATTERN.search(line)
34
+ if mode_match:
35
+ current_mode = mode_match.group(1)
36
+
37
+ if current_id:
38
+ records.append(
39
+ _build_task_record(current_id, issue_id, current_desc, current_mode)
40
+ )
41
+
42
+ return records
43
+
44
+
45
+ def _build_task_record(
46
+ task_id: str,
47
+ issue_id: str,
48
+ description: str | None,
49
+ execution_mode: str,
50
+ ) -> TaskRecord:
51
+ return TaskRecord(
52
+ id=task_id,
53
+ issue_id=issue_id,
54
+ description=description or "",
55
+ status="PENDING",
56
+ execution_mode=execution_mode,
57
+ )
58
+
59
+
60
+ def validate_tasks_jsonl(records: list[dict]) -> list[str]:
61
+ errors: list[str] = []
62
+ for i, record in enumerate(records):
63
+ try:
64
+ TaskRecord.model_validate(record)
65
+ except ValidationError as e:
66
+ for err in e.errors():
67
+ loc = ".".join(str(part) for part in err["loc"])
68
+ errors.append(f"Record {i}: {loc}: {err['msg']}")
69
+ return errors
@@ -0,0 +1,31 @@
1
+ from deviate.core.treesitter.models import DuplicateBlock, FileStructure, SymbolChange
2
+ from deviate.core.treesitter.parser import (
3
+ _QUERIES_DIR,
4
+ EXTENSION_MAP,
5
+ get_language_id,
6
+ get_parser,
7
+ )
8
+ from deviate.core.treesitter.analysis import (
9
+ detect_duplicate_blocks,
10
+ estimate_cyclomatic_complexity,
11
+ extract_changed_symbols,
12
+ extract_dead_code,
13
+ extract_file_structure,
14
+ incremental_parse,
15
+ )
16
+
17
+ __all__ = [
18
+ "EXTENSION_MAP",
19
+ "SymbolChange",
20
+ "DuplicateBlock",
21
+ "FileStructure",
22
+ "_QUERIES_DIR",
23
+ "get_language_id",
24
+ "get_parser",
25
+ "extract_file_structure",
26
+ "incremental_parse",
27
+ "extract_changed_symbols",
28
+ "extract_dead_code",
29
+ "detect_duplicate_blocks",
30
+ "estimate_cyclomatic_complexity",
31
+ ]
@@ -0,0 +1,457 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+
6
+ from typing import Any
7
+
8
+ from deviate.core.treesitter.models import DuplicateBlock, FileStructure, SymbolChange
9
+ from deviate.core.treesitter.parser import (
10
+ _CAP_KIND,
11
+ _load_queries,
12
+ _tree_sitter_available,
13
+ get_language_id,
14
+ get_parser,
15
+ Parser,
16
+ Query,
17
+ QueryCursor,
18
+ )
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ __all__ = [
24
+ "extract_file_structure",
25
+ "incremental_parse",
26
+ "extract_changed_symbols",
27
+ "extract_dead_code",
28
+ "detect_duplicate_blocks",
29
+ "estimate_cyclomatic_complexity",
30
+ ]
31
+
32
+
33
+ def _extract_symbols_from_parsed(
34
+ tree: Any, grammar_id: str
35
+ ) -> tuple[list[dict[str, str | int]], list[str]]:
36
+ symbols: list[dict[str, str | int]] = []
37
+ imports: list[str] = []
38
+
39
+ query = _load_queries(grammar_id)
40
+ if query is None:
41
+ return symbols, imports
42
+ if QueryCursor is None:
43
+ return symbols, imports
44
+
45
+ cursor = QueryCursor(query)
46
+ captures = cursor.captures(tree.root_node)
47
+
48
+ for cap_name, nodes in captures.items():
49
+ for node in nodes:
50
+ text = node.text.decode("utf-8", errors="replace")
51
+ if cap_name == "import":
52
+ imports.append(text)
53
+ elif cap_name in _CAP_KIND:
54
+ fn = _fn_node(node)
55
+ srow = fn.start_point.row
56
+ erow = fn.end_point.row
57
+ signature = _extract_fn_signature(fn)
58
+ entry: dict[str, str | int] = {
59
+ "kind": _CAP_KIND[cap_name],
60
+ "name": text,
61
+ "start_line": srow,
62
+ "end_line": erow,
63
+ "line_count": max(0, erow - srow + 1),
64
+ }
65
+ if signature:
66
+ entry["signature"] = signature
67
+ symbols.append(entry)
68
+
69
+ return symbols, imports
70
+
71
+
72
+ def extract_file_structure(filepath: str) -> FileStructure:
73
+ result = FileStructure(filepath=filepath, language="", symbols=[], imports=[])
74
+ if not os.path.isfile(filepath):
75
+ return result
76
+ lang_id = get_language_id(filepath)
77
+ if lang_id is None:
78
+ logger.warning("Unknown extension for file: %s", filepath)
79
+ return result
80
+ result.language = lang_id
81
+ parser = get_parser(filepath)
82
+ if parser is None:
83
+ return result
84
+ try:
85
+ with open(filepath, "rb") as f:
86
+ src = f.read()
87
+ tree = parser.parse(src)
88
+ symbols, imports = _extract_symbols_from_parsed(tree, lang_id)
89
+ result.symbols = symbols
90
+ result.imports = imports
91
+ except Exception:
92
+ logger.warning("Failed to parse file: %s", filepath)
93
+ return result
94
+
95
+
96
+ def incremental_parse(filepath: str, old_tree: Any = None) -> Any | None:
97
+ if not _tree_sitter_available or Parser is None:
98
+ return None
99
+ parser = get_parser(filepath)
100
+ if parser is None:
101
+ return None
102
+ try:
103
+ with open(filepath, "rb") as f:
104
+ src = f.read()
105
+ if old_tree is not None:
106
+ return parser.parse(src, old_tree)
107
+ return parser.parse(src)
108
+ except Exception as exc:
109
+ logger.warning("Failed to parse: %s (%s)", filepath, exc)
110
+ return None
111
+
112
+
113
+ def _reconstruct_sources_from_diff(
114
+ diff_text: str, target_filepath: str
115
+ ) -> tuple[bytes | None, bytes | None]:
116
+ if not diff_text.strip():
117
+ return None, None
118
+
119
+ lines = diff_text.splitlines()
120
+ old_lines: list[bytes] = []
121
+ new_lines: list[bytes] = []
122
+ in_target = False
123
+
124
+ for line in lines:
125
+ if line.startswith("diff --git"):
126
+ parts = line.split()
127
+ b_path = parts[-1] if len(parts) >= 4 else ""
128
+ b_path = b_path.lstrip("b/")
129
+ in_target = b_path == target_filepath
130
+ if in_target:
131
+ old_lines = []
132
+ new_lines = []
133
+ continue
134
+
135
+ if not in_target:
136
+ continue
137
+
138
+ if line.startswith("--- ") or line.startswith("+++ "):
139
+ continue
140
+ if (
141
+ line.startswith("index ")
142
+ or line.startswith("new file")
143
+ or line.startswith("deleted file")
144
+ ):
145
+ continue
146
+ if line.startswith("@@"):
147
+ continue
148
+
149
+ if len(line) > 0:
150
+ prefix = line[0]
151
+ content = line[1:] if len(line) > 1 else ""
152
+ content_bytes = content.encode("utf-8")
153
+ if prefix == " ":
154
+ old_lines.append(content_bytes)
155
+ new_lines.append(content_bytes)
156
+ elif prefix == "-":
157
+ old_lines.append(content_bytes)
158
+ elif prefix == "+":
159
+ new_lines.append(content_bytes)
160
+
161
+ old_src = b"\n".join(old_lines) if old_lines else None
162
+ new_src = b"\n".join(new_lines) if new_lines else None
163
+ return old_src, new_src
164
+
165
+
166
+ def _fn_node(node: Any) -> Any:
167
+ if Query is None:
168
+ return node
169
+ parent = getattr(node, "parent", None)
170
+ if parent is None:
171
+ return node
172
+ ptype = getattr(parent, "type", "")
173
+ if (
174
+ "function" in ptype
175
+ or "class" in ptype
176
+ or "struct" in ptype
177
+ or "interface" in ptype
178
+ or ptype in ("trait_item", "impl_item")
179
+ ):
180
+ return parent
181
+ grand = getattr(parent, "parent", None)
182
+ if grand is not None:
183
+ gtype = getattr(grand, "type", "")
184
+ if (
185
+ "function" in gtype
186
+ or "class" in gtype
187
+ or "struct" in gtype
188
+ or "interface" in gtype
189
+ or gtype in ("trait_item", "impl_item")
190
+ ):
191
+ return grand
192
+ return node
193
+
194
+
195
+ def _extract_fn_signature(node: Any) -> str:
196
+ if not _tree_sitter_available:
197
+ return ""
198
+ try:
199
+ text = node.text.decode("utf-8", errors="replace")
200
+ lines = text.splitlines()
201
+ if lines:
202
+ return lines[0].strip()[:80]
203
+ return text[:80].strip()
204
+ except Exception:
205
+ return ""
206
+
207
+
208
+ def _extract_fn_metadata(tree: Any, query: Any) -> dict[str, dict]:
209
+ meta: dict[str, dict] = {}
210
+ if QueryCursor is None:
211
+ return meta
212
+ cursor = QueryCursor(query)
213
+ captures = cursor.captures(tree.root_node)
214
+ for cap_name, nodes in captures.items():
215
+ if cap_name not in _CAP_KIND:
216
+ continue
217
+ for node in nodes:
218
+ text = node.text.decode("utf-8", errors="replace")
219
+ fn = _fn_node(node)
220
+ srow = fn.start_point.row
221
+ erow = fn.end_point.row
222
+ line_count = max(0, erow - srow + 1)
223
+ signature = _extract_fn_signature(fn)
224
+ meta[text] = {
225
+ "kind": _CAP_KIND.get(cap_name, "function"),
226
+ "start_line": srow,
227
+ "end_line": erow,
228
+ "signature": signature,
229
+ "line_count": line_count,
230
+ }
231
+ return meta
232
+
233
+
234
+ def extract_changed_symbols(diff_text: str, filepath: str) -> list[SymbolChange]:
235
+ if not _tree_sitter_available or Parser is None:
236
+ return []
237
+
238
+ lang_id = get_language_id(filepath)
239
+ if lang_id is None:
240
+ return []
241
+
242
+ parser = get_parser(filepath)
243
+ if parser is None:
244
+ return []
245
+
246
+ old_src, new_src = _reconstruct_sources_from_diff(diff_text, filepath)
247
+ if old_src is None and new_src is None:
248
+ return []
249
+
250
+ query = _load_queries(lang_id)
251
+ if query is None or QueryCursor is None:
252
+ return []
253
+
254
+ changes: list[SymbolChange] = []
255
+
256
+ try:
257
+ old_tree = parser.parse(old_src) if old_src is not None else None
258
+ new_tree = parser.parse(new_src) if new_src is not None else None
259
+
260
+ old_meta = _extract_fn_metadata(old_tree, query) if old_tree else {}
261
+ new_meta = _extract_fn_metadata(new_tree, query) if new_tree else {}
262
+
263
+ old_names = set(old_meta)
264
+ new_names = set(new_meta)
265
+
266
+ all_names = old_names | new_names
267
+
268
+ for name in all_names:
269
+ if name in old_names and name not in new_names:
270
+ ch = "removed"
271
+ elif name not in old_names and name in new_names:
272
+ ch = "added"
273
+ else:
274
+ ch = "modified"
275
+
276
+ o = old_meta.get(name, {})
277
+ n = new_meta.get(name, {})
278
+ kind = n.get("kind") or o.get("kind", "function")
279
+
280
+ changes.append(
281
+ SymbolChange(
282
+ language=lang_id,
283
+ kind=kind,
284
+ name=name,
285
+ change=ch,
286
+ old_name=o.get("signature", ""),
287
+ start_line=n.get("start_line", 0),
288
+ end_line=n.get("end_line", 0),
289
+ old_start_line=o.get("start_line", 0),
290
+ old_end_line=o.get("end_line", 0),
291
+ old_signature=o.get("signature", ""),
292
+ new_signature=n.get("signature", ""),
293
+ old_line_count=o.get("line_count", 0),
294
+ new_line_count=n.get("line_count", 0),
295
+ )
296
+ )
297
+ except Exception:
298
+ pass
299
+
300
+ return changes
301
+
302
+
303
+ def _build_kind_map(tree: Any, query: Any) -> dict[str, str]:
304
+ kind_map: dict[str, str] = {}
305
+ if QueryCursor is None:
306
+ return kind_map
307
+ cursor = QueryCursor(query)
308
+ captures = cursor.captures(tree.root_node)
309
+ for cap_name, nodes in captures.items():
310
+ mapped = _CAP_KIND.get(cap_name)
311
+ if mapped:
312
+ for node in nodes:
313
+ name = node.text.decode("utf-8", errors="replace")
314
+ kind_map[name] = mapped
315
+ return kind_map
316
+
317
+
318
+ def extract_dead_code(filepath: str) -> list[str]:
319
+ if not _tree_sitter_available or QueryCursor is None:
320
+ return []
321
+
322
+ lang_id = get_language_id(filepath)
323
+ if lang_id is None:
324
+ return []
325
+
326
+ parser = get_parser(filepath)
327
+ if parser is None:
328
+ return []
329
+
330
+ query = _load_queries(lang_id)
331
+ if query is None:
332
+ return []
333
+
334
+ try:
335
+ with open(filepath, "rb") as f:
336
+ src = f.read()
337
+ tree = parser.parse(src)
338
+
339
+ cursor = QueryCursor(query)
340
+ captures = cursor.captures(tree.root_node)
341
+
342
+ defined_functions: list[str] = []
343
+ called_functions: list[str] = []
344
+
345
+ for cap_name, nodes in captures.items():
346
+ for node in nodes:
347
+ text = node.text.decode("utf-8", errors="replace")
348
+ if cap_name in ("function", "method"):
349
+ defined_functions.append(text)
350
+ elif cap_name == "call":
351
+ called_functions.append(text)
352
+
353
+ defined_set = set(defined_functions)
354
+ called_set = set(called_functions)
355
+ dead = defined_set - called_set
356
+
357
+ return list(dead)
358
+ except Exception:
359
+ logger.warning("Failed to detect dead code in: %s", filepath)
360
+ return []
361
+
362
+
363
+ def detect_duplicate_blocks(filepath: str, min_lines: int = 5) -> list[DuplicateBlock]:
364
+ if not _tree_sitter_available or Parser is None:
365
+ return []
366
+
367
+ lang_id = get_language_id(filepath)
368
+ if lang_id is None:
369
+ return []
370
+
371
+ parser = get_parser(filepath)
372
+ if parser is None:
373
+ return []
374
+
375
+ try:
376
+ with open(filepath, "rb") as f:
377
+ src = f.read()
378
+ tree = parser.parse(src)
379
+
380
+ blocks: list[tuple[int, int, int, str]] = []
381
+ stack = [tree.root_node]
382
+
383
+ while stack:
384
+ node = stack.pop()
385
+ line_count = node.end_point.row - node.start_point.row + 1
386
+ if node.child_count > 1 and line_count >= min_lines:
387
+ children_types = tuple(c.type for c in node.children)
388
+ if children_types:
389
+ blocks.append(
390
+ (
391
+ node.start_point.row,
392
+ node.end_point.row,
393
+ line_count,
394
+ str(children_types),
395
+ )
396
+ )
397
+ for child in node.children:
398
+ stack.append(child)
399
+
400
+ duplicates: list[DuplicateBlock] = []
401
+ seen: dict[str, tuple[int, str]] = {}
402
+
403
+ for row_start, row_end, lines, sig in blocks:
404
+ loc = f"{os.path.basename(filepath)}:{row_start}-{row_end}"
405
+ if sig in seen and abs(lines - seen[sig][0]) <= 1:
406
+ duplicates.append(
407
+ DuplicateBlock(
408
+ lines=lines,
409
+ locations=[seen[sig][1], loc],
410
+ similarity=0.85,
411
+ )
412
+ )
413
+ else:
414
+ seen[sig] = (lines, loc)
415
+
416
+ return duplicates
417
+ except Exception:
418
+ logger.warning("Failed to detect duplicate blocks in: %s", filepath)
419
+ return []
420
+
421
+
422
+ def estimate_cyclomatic_complexity(filepath: str, func_node: Any | None) -> int:
423
+ if not _tree_sitter_available or QueryCursor is None:
424
+ return 1
425
+
426
+ lang_id = get_language_id(filepath)
427
+ if lang_id is None:
428
+ return 1
429
+
430
+ parser = get_parser(filepath)
431
+ if parser is None:
432
+ return 1
433
+
434
+ try:
435
+ if func_node is not None:
436
+ target_node = func_node
437
+ else:
438
+ with open(filepath, "rb") as f:
439
+ src = f.read()
440
+ if not src.strip():
441
+ return 1
442
+ tree = parser.parse(src)
443
+ target_node = tree.root_node
444
+
445
+ query = _load_queries(lang_id)
446
+ if query is None:
447
+ return 1
448
+ cursor = QueryCursor(query)
449
+ captures = cursor.captures(target_node)
450
+ decision_count = 0
451
+ for cap_name, nodes in captures.items():
452
+ if cap_name in ("conditional", "loop"):
453
+ decision_count += len(nodes)
454
+
455
+ return decision_count + 1
456
+ except Exception:
457
+ return 1