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.
- deviatdd-2.5.1.dist-info/METADATA +386 -0
- deviatdd-2.5.1.dist-info/RECORD +124 -0
- deviatdd-2.5.1.dist-info/WHEEL +4 -0
- deviatdd-2.5.1.dist-info/entry_points.txt +2 -0
- deviatdd-2.5.1.dist-info/licenses/LICENSE +21 -0
- deviate/__init__.py +3 -0
- deviate/cli/__init__.py +824 -0
- deviate/cli/_common.py +155 -0
- deviate/cli/adhoc.py +183 -0
- deviate/cli/constitution.py +113 -0
- deviate/cli/feature.py +94 -0
- deviate/cli/init.py +485 -0
- deviate/cli/inspect.py +208 -0
- deviate/cli/macro.py +937 -0
- deviate/cli/meso.py +1894 -0
- deviate/cli/micro.py +3249 -0
- deviate/cli/review.py +441 -0
- deviate/core/__init__.py +0 -0
- deviate/core/_shared.py +20 -0
- deviate/core/agent.py +505 -0
- deviate/core/cache_discipline.py +65 -0
- deviate/core/commands.py +202 -0
- deviate/core/commit.py +86 -0
- deviate/core/complexity.py +36 -0
- deviate/core/constitution.py +85 -0
- deviate/core/contract.py +17 -0
- deviate/core/convention.py +147 -0
- deviate/core/epic.py +73 -0
- deviate/core/issues.py +46 -0
- deviate/core/prd.py +18 -0
- deviate/core/profile.py +33 -0
- deviate/core/repo.py +47 -0
- deviate/core/run_logger.py +50 -0
- deviate/core/tasks_ledger.py +69 -0
- deviate/core/treesitter/__init__.py +31 -0
- deviate/core/treesitter/analysis.py +457 -0
- deviate/core/treesitter/models.py +35 -0
- deviate/core/treesitter/parser.py +184 -0
- deviate/core/treesitter/queries/bash.scm +7 -0
- deviate/core/treesitter/queries/c_sharp.scm +11 -0
- deviate/core/treesitter/queries/cpp.scm +9 -0
- deviate/core/treesitter/queries/css.scm +4 -0
- deviate/core/treesitter/queries/dockerfile.scm +8 -0
- deviate/core/treesitter/queries/elixir.scm +6 -0
- deviate/core/treesitter/queries/go.scm +9 -0
- deviate/core/treesitter/queries/hcl.scm +3 -0
- deviate/core/treesitter/queries/html.scm +3 -0
- deviate/core/treesitter/queries/javascript.scm +12 -0
- deviate/core/treesitter/queries/json.scm +4 -0
- deviate/core/treesitter/queries/kotlin.scm +8 -0
- deviate/core/treesitter/queries/markdown.scm +4 -0
- deviate/core/treesitter/queries/python.scm +10 -0
- deviate/core/treesitter/queries/rust.scm +12 -0
- deviate/core/treesitter/queries/sql.scm +7 -0
- deviate/core/treesitter/queries/swift.scm +10 -0
- deviate/core/treesitter/queries/toml.scm +3 -0
- deviate/core/treesitter/queries/tsx.scm +14 -0
- deviate/core/treesitter/queries/typescript.scm +14 -0
- deviate/core/treesitter/queries/yaml.scm +4 -0
- deviate/core/validation.py +153 -0
- deviate/core/worktree.py +141 -0
- deviate/main.py +4 -0
- deviate/prompts/__init__.py +0 -0
- deviate/prompts/assembly.py +122 -0
- deviate/prompts/auto/__init__.py +1 -0
- deviate/prompts/auto/execute.md +62 -0
- deviate/prompts/auto/explore.md +103 -0
- deviate/prompts/auto/green.md +137 -0
- deviate/prompts/auto/judge.md +260 -0
- deviate/prompts/auto/plan.md +127 -0
- deviate/prompts/auto/prd.md +108 -0
- deviate/prompts/auto/red.md +156 -0
- deviate/prompts/auto/refactor.md +166 -0
- deviate/prompts/auto/research.md +111 -0
- deviate/prompts/auto/shard.md +90 -0
- deviate/prompts/auto/specify.md +77 -0
- deviate/prompts/auto/tasks.md +191 -0
- deviate/prompts/commands/deviate-adhoc.md +216 -0
- deviate/prompts/commands/deviate-architecture.md +147 -0
- deviate/prompts/commands/deviate-constitution.md +215 -0
- deviate/prompts/commands/deviate-e2e.md +264 -0
- deviate/prompts/commands/deviate-execute.md +223 -0
- deviate/prompts/commands/deviate-explore.md +253 -0
- deviate/prompts/commands/deviate-flows.md +226 -0
- deviate/prompts/commands/deviate-green.md +217 -0
- deviate/prompts/commands/deviate-hotfix.md +188 -0
- deviate/prompts/commands/deviate-init.md +170 -0
- deviate/prompts/commands/deviate-judge.md +193 -0
- deviate/prompts/commands/deviate-merge.md +221 -0
- deviate/prompts/commands/deviate-plan.md +158 -0
- deviate/prompts/commands/deviate-pr.md +144 -0
- deviate/prompts/commands/deviate-prd.md +216 -0
- deviate/prompts/commands/deviate-prune.md +260 -0
- deviate/prompts/commands/deviate-red.md +231 -0
- deviate/prompts/commands/deviate-refactor.md +211 -0
- deviate/prompts/commands/deviate-release.md +123 -0
- deviate/prompts/commands/deviate-research.md +359 -0
- deviate/prompts/commands/deviate-review.md +289 -0
- deviate/prompts/commands/deviate-shard.md +226 -0
- deviate/prompts/commands/deviate-tasks.md +281 -0
- deviate/prompts/commands/deviate-triage.md +141 -0
- deviate/prompts/constitution_seed.md +51 -0
- deviate/prompts/core/core.md +21 -0
- deviate/prompts/core/macro-auto.md +59 -0
- deviate/prompts/core/macro-command.md +54 -0
- deviate/prompts/core/macro-skill.md +54 -0
- deviate/prompts/core/meso-auto.md +63 -0
- deviate/prompts/core/meso-command.md +58 -0
- deviate/prompts/core/meso-skill.md +58 -0
- deviate/prompts/core/micro-auto.md +76 -0
- deviate/prompts/core/micro-command.md +67 -0
- deviate/prompts/core/micro-skill.md +67 -0
- deviate/prompts/extras/deviate-pr-graphite-routing.md +20 -0
- deviate/prompts/governance/__init__.py +0 -0
- deviate/prompts/governance/agents_seed.md +1 -0
- deviate/prompts/governance/claudemd_seed.md +1 -0
- deviate/prompts/governance/graphite_seed.md +18 -0
- deviate/prompts/governance/libref_seed.md +3 -0
- deviate/state/__init__.py +0 -0
- deviate/state/config.py +257 -0
- deviate/state/ledger.py +407 -0
- deviate/ui/__init__.py +5 -0
- deviate/ui/monitor.py +187 -0
- 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
|
deviate/core/profile.py
ADDED
|
@@ -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
|