codigest 0.3.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.
- codigest/__init__.py +0 -0
- codigest/commands/diff.py +84 -0
- codigest/commands/digest.py +88 -0
- codigest/commands/init.py +87 -0
- codigest/commands/scan.py +142 -0
- codigest/commands/semdiff.py +131 -0
- codigest/commands/tree.py +110 -0
- codigest/config.py +0 -0
- codigest/core/git_ops.py +73 -0
- codigest/core/processor.py +31 -0
- codigest/core/prompts.py +164 -0
- codigest/core/scanner.py +99 -0
- codigest/core/semdiff.py +175 -0
- codigest/core/shadow.py +171 -0
- codigest/core/structure.py +48 -0
- codigest/core/tags.py +75 -0
- codigest/core/tokenizer.py +14 -0
- codigest/main.py +34 -0
- codigest-0.3.0.dist-info/METADATA +145 -0
- codigest-0.3.0.dist-info/RECORD +22 -0
- codigest-0.3.0.dist-info/WHEEL +4 -0
- codigest-0.3.0.dist-info/entry_points.txt +2 -0
codigest/core/git_ops.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git Operations.
|
|
3
|
+
Retains the 'Untracked File' detection logic from the prototype.
|
|
4
|
+
"""
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
def is_git_repo(root_path: Path) -> bool:
|
|
10
|
+
return (root_path / ".git").exists()
|
|
11
|
+
|
|
12
|
+
def get_smart_diff(root_path: Path) -> str:
|
|
13
|
+
"""
|
|
14
|
+
Fetches git diff AND content of untracked (new) files.
|
|
15
|
+
"""
|
|
16
|
+
if not is_git_repo(root_path):
|
|
17
|
+
return "Warning: Not a git repository."
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
# 1. Standard Git Diff (Staged + Unstaged)
|
|
21
|
+
diff_output = subprocess.check_output(
|
|
22
|
+
["git", "diff", "HEAD"],
|
|
23
|
+
cwd=root_path,
|
|
24
|
+
text=True,
|
|
25
|
+
encoding='utf-8',
|
|
26
|
+
stderr=subprocess.DEVNULL
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# 2. Untracked Files (The 'Killer Feature' from prototype)
|
|
30
|
+
untracked_files = subprocess.check_output(
|
|
31
|
+
["git", "ls-files", "--others", "--exclude-standard"],
|
|
32
|
+
cwd=root_path,
|
|
33
|
+
text=True,
|
|
34
|
+
encoding='utf-8',
|
|
35
|
+
stderr=subprocess.DEVNULL
|
|
36
|
+
).splitlines()
|
|
37
|
+
|
|
38
|
+
full_output = diff_output
|
|
39
|
+
new_files_content = []
|
|
40
|
+
|
|
41
|
+
for f in untracked_files:
|
|
42
|
+
file_path = root_path / f
|
|
43
|
+
if not file_path.is_file():
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
# Basic binary check (skip if cannot decode)
|
|
48
|
+
content = file_path.read_text(encoding='utf-8')
|
|
49
|
+
line_count = len(content.splitlines())
|
|
50
|
+
|
|
51
|
+
# Manually construct a Diff Header
|
|
52
|
+
header = (
|
|
53
|
+
f"diff --git a/{f} b/{f}\n"
|
|
54
|
+
f"new file mode 100644\n"
|
|
55
|
+
f"--- /dev/null\n"
|
|
56
|
+
f"+++ b/{f}\n"
|
|
57
|
+
f"@@ -0,0 +1,{line_count} @@\n"
|
|
58
|
+
f"{content}\n"
|
|
59
|
+
)
|
|
60
|
+
new_files_content.append(header)
|
|
61
|
+
|
|
62
|
+
except UnicodeDecodeError:
|
|
63
|
+
logger.debug(f"Skipping binary untracked file: {f}")
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
if new_files_content:
|
|
67
|
+
full_output += "\n# Untracked (New) Source Files:\n" + "".join(new_files_content)
|
|
68
|
+
|
|
69
|
+
return full_output.strip()
|
|
70
|
+
|
|
71
|
+
except subprocess.CalledProcessError as e:
|
|
72
|
+
logger.error(f"Git command failed: {e}")
|
|
73
|
+
return "Error: Failed to run git diff."
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from loguru import logger
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def read_file_content(path: Path, add_line_numbers: bool = True) -> str:
|
|
6
|
+
"""
|
|
7
|
+
Reads file content safely. Returns an error message string if binary/unreadable.
|
|
8
|
+
"""
|
|
9
|
+
try:
|
|
10
|
+
content = path.read_text(encoding="utf-8")
|
|
11
|
+
|
|
12
|
+
if not add_line_numbers:
|
|
13
|
+
return content
|
|
14
|
+
|
|
15
|
+
lines = content.splitlines()
|
|
16
|
+
if not lines:
|
|
17
|
+
return ""
|
|
18
|
+
|
|
19
|
+
# 라인 넘버 패딩 계산 (100줄이면 3칸 확보)
|
|
20
|
+
width = len(str(len(lines)))
|
|
21
|
+
|
|
22
|
+
return "\n".join(
|
|
23
|
+
f"{i+1:>{width}}: {line}"
|
|
24
|
+
for i, line in enumerate(lines)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
except UnicodeDecodeError:
|
|
28
|
+
return "<<Binary or Non-UTF8 Content>>"
|
|
29
|
+
except Exception as e:
|
|
30
|
+
logger.warning(f"Error reading {path}: {e}")
|
|
31
|
+
return f"<<Error: {e}>>"
|
codigest/core/prompts.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Prompt Management Module.
|
|
3
|
+
Philosophy: Defaults are Code (t-strings), Overrides are Data (TOML).
|
|
4
|
+
"""
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable, Dict
|
|
8
|
+
from . import tags
|
|
9
|
+
|
|
10
|
+
# RenderFunction takes keyword arguments and returns a processed string
|
|
11
|
+
RenderFunc = Callable[..., str]
|
|
12
|
+
|
|
13
|
+
# 1. Snapshot Template (codigest scan)
|
|
14
|
+
def _default_snapshot(project_name: str, tree_structure: str, source_code: str, instruction: str = "") -> str:
|
|
15
|
+
instruction_block = ""
|
|
16
|
+
if instruction:
|
|
17
|
+
instruction_block = tags.dedent(f"""
|
|
18
|
+
<user_instruction>
|
|
19
|
+
{instruction}
|
|
20
|
+
</user_instruction>
|
|
21
|
+
""")
|
|
22
|
+
|
|
23
|
+
return tags.dedent(t"""
|
|
24
|
+
[SYSTEM: CODIGEST - INITIAL CONTEXT]
|
|
25
|
+
You are an expert AI developer. I am providing the full context of a project named "{project_name}".
|
|
26
|
+
Please digest this structure and code to build your internal mental model.
|
|
27
|
+
{instruction_block}
|
|
28
|
+
|
|
29
|
+
<project_root>
|
|
30
|
+
{project_name}
|
|
31
|
+
</project_root>
|
|
32
|
+
|
|
33
|
+
<project_structure>
|
|
34
|
+
{tree_structure}
|
|
35
|
+
</project_structure>
|
|
36
|
+
|
|
37
|
+
<source_code>
|
|
38
|
+
{source_code}
|
|
39
|
+
</source_code>
|
|
40
|
+
""")
|
|
41
|
+
|
|
42
|
+
# 2. Diff Template (codigest diff)
|
|
43
|
+
def _default_diff(project_name: str, context_message: str, diff_content: str, instruction: str = "") -> str:
|
|
44
|
+
instruction_block = ""
|
|
45
|
+
if instruction:
|
|
46
|
+
instruction_block = tags.dedent(f"""
|
|
47
|
+
<user_instruction>
|
|
48
|
+
{instruction}
|
|
49
|
+
</user_instruction>
|
|
50
|
+
""")
|
|
51
|
+
|
|
52
|
+
return tags.dedent(t"""
|
|
53
|
+
[SYSTEM: CODIGEST - INCREMENTAL UPDATE]
|
|
54
|
+
Here are the latest changes for the project "{project_name}".
|
|
55
|
+
{context_message}.
|
|
56
|
+
{instruction_block}
|
|
57
|
+
|
|
58
|
+
Focus on these modifications to update your context.
|
|
59
|
+
|
|
60
|
+
<git_diff>
|
|
61
|
+
{diff_content}
|
|
62
|
+
</git_diff>
|
|
63
|
+
""")
|
|
64
|
+
|
|
65
|
+
# 3. SemDiff Template (codigest semdiff)
|
|
66
|
+
def _default_semdiff(project_name: str, context_message: str, semdiff_content: str, instruction: str = "") -> str:
|
|
67
|
+
instruction_block = ""
|
|
68
|
+
if instruction:
|
|
69
|
+
instruction_block = tags.dedent(f"""
|
|
70
|
+
<user_instruction>
|
|
71
|
+
{instruction}
|
|
72
|
+
</user_instruction>
|
|
73
|
+
""")
|
|
74
|
+
|
|
75
|
+
return tags.dedent(t"""
|
|
76
|
+
[SYSTEM: CODIGEST - SEMANTIC ANALYSIS]
|
|
77
|
+
Structural changes detected in "{project_name}".
|
|
78
|
+
{context_message}.
|
|
79
|
+
{instruction_block}
|
|
80
|
+
|
|
81
|
+
This report focuses on High-Level Architecture changes (Classes, Functions, Signatures).
|
|
82
|
+
|
|
83
|
+
<semantic_diff>
|
|
84
|
+
{semdiff_content}
|
|
85
|
+
</semantic_diff>
|
|
86
|
+
""")
|
|
87
|
+
|
|
88
|
+
# 4. Digest Template (codigest digest) - [Missing Part Fix]
|
|
89
|
+
def _default_digest(project_name: str, tree_structure: str, digest_content: str, instruction: str = "") -> str:
|
|
90
|
+
instruction_block = ""
|
|
91
|
+
if instruction:
|
|
92
|
+
instruction_block = tags.dedent(f"""
|
|
93
|
+
<user_instruction>
|
|
94
|
+
{instruction}
|
|
95
|
+
</user_instruction>
|
|
96
|
+
""")
|
|
97
|
+
|
|
98
|
+
return tags.dedent(t"""
|
|
99
|
+
[SYSTEM: CODIGEST - ARCHITECTURE DIGEST]
|
|
100
|
+
Here is the high-level architecture of "{project_name}".
|
|
101
|
+
This contains ONLY definitions (Classes, Functions), no implementation details.
|
|
102
|
+
Use this to understand the project structure and relationships.
|
|
103
|
+
{instruction_block}
|
|
104
|
+
|
|
105
|
+
<project_structure>
|
|
106
|
+
{tree_structure}
|
|
107
|
+
</project_structure>
|
|
108
|
+
|
|
109
|
+
<code_digest>
|
|
110
|
+
{digest_content}
|
|
111
|
+
</code_digest>
|
|
112
|
+
""")
|
|
113
|
+
|
|
114
|
+
# Registry of default implementations
|
|
115
|
+
# Ensure ALL keys correspond to prompt_engine.render calls
|
|
116
|
+
DEFAULT_RENDERERS: Dict[str, RenderFunc] = {
|
|
117
|
+
"snapshot": _default_snapshot,
|
|
118
|
+
"diff": _default_diff,
|
|
119
|
+
"semdiff": _default_semdiff,
|
|
120
|
+
"digest": _default_digest, # <--- 이 부분이 누락되었을 수 있습니다.
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
class PromptEngine:
|
|
124
|
+
def __init__(self, root_path: Path):
|
|
125
|
+
self.root_path = root_path
|
|
126
|
+
self.overrides = self._load_overrides()
|
|
127
|
+
|
|
128
|
+
def _load_overrides(self) -> Dict[str, str]:
|
|
129
|
+
"""Loads raw template strings from .codigest/prompts.toml"""
|
|
130
|
+
prompt_file = self.root_path / ".codigest" / "prompts.toml"
|
|
131
|
+
if not prompt_file.exists():
|
|
132
|
+
return {}
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
with open(prompt_file, "rb") as f:
|
|
136
|
+
data = tomllib.load(f)
|
|
137
|
+
return data.get("prompts", {})
|
|
138
|
+
except Exception:
|
|
139
|
+
return {}
|
|
140
|
+
|
|
141
|
+
def render(self, key: str, **kwargs) -> str:
|
|
142
|
+
"""
|
|
143
|
+
Renders a prompt by key.
|
|
144
|
+
Priority: TOML Override > Default t-string Function
|
|
145
|
+
"""
|
|
146
|
+
# 1. Check for TOML Override (String-based)
|
|
147
|
+
if key in self.overrides:
|
|
148
|
+
raw_template = self.overrides[key]
|
|
149
|
+
safe_kwargs = {
|
|
150
|
+
k: tags.escape_xml_value(v) for k, v in kwargs.items()
|
|
151
|
+
}
|
|
152
|
+
try:
|
|
153
|
+
return raw_template.format(**safe_kwargs)
|
|
154
|
+
except KeyError as e:
|
|
155
|
+
return f"Error rendering template '{key}': Missing argument {e}"
|
|
156
|
+
|
|
157
|
+
# 2. Use Default (Code-based t-string)
|
|
158
|
+
if key in DEFAULT_RENDERERS:
|
|
159
|
+
return DEFAULT_RENDERERS[key](**kwargs)
|
|
160
|
+
|
|
161
|
+
return f"Error: Prompt template '{key}' not found."
|
|
162
|
+
|
|
163
|
+
def get_engine(root_path: Path) -> PromptEngine:
|
|
164
|
+
return PromptEngine(root_path)
|
codigest/core/scanner.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core File System Scanner.
|
|
3
|
+
"""
|
|
4
|
+
import pathspec
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
# Default "Safe" ignores
|
|
10
|
+
ALWAYS_IGNORE = [
|
|
11
|
+
".git/", ".codigest/",
|
|
12
|
+
".venv/", "venv/", "env/",
|
|
13
|
+
"__pycache__/", "node_modules/",
|
|
14
|
+
".idea/", ".vscode/",
|
|
15
|
+
"*.pyc", "*.DS_Store"
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
class ProjectScanner:
|
|
19
|
+
def __init__(self, root_path: Path, extensions: Optional[set[str]] = None, extra_ignores: Optional[list[str]] = None):
|
|
20
|
+
self.root_path = root_path
|
|
21
|
+
self.extensions = extensions
|
|
22
|
+
self.extra_ignores = extra_ignores or []
|
|
23
|
+
self.ignore_spec = self._load_gitignore()
|
|
24
|
+
|
|
25
|
+
def _load_gitignore(self) -> pathspec.PathSpec:
|
|
26
|
+
"""Loads .gitignore and combines with ALWAYS_IGNORE and extra_ignores."""
|
|
27
|
+
patterns = list(ALWAYS_IGNORE)
|
|
28
|
+
|
|
29
|
+
# Add config-based ignores
|
|
30
|
+
if self.extra_ignores:
|
|
31
|
+
patterns.extend(self.extra_ignores)
|
|
32
|
+
|
|
33
|
+
gitignore_path = self.root_path / ".gitignore"
|
|
34
|
+
if gitignore_path.exists():
|
|
35
|
+
try:
|
|
36
|
+
with open(gitignore_path, "r", encoding="utf-8") as f:
|
|
37
|
+
user_patterns = [
|
|
38
|
+
line.strip() for line in f
|
|
39
|
+
if line.strip() and not line.startswith("#")
|
|
40
|
+
]
|
|
41
|
+
patterns.extend(user_patterns)
|
|
42
|
+
except Exception as e:
|
|
43
|
+
logger.warning(f"Failed to read .gitignore: {e}")
|
|
44
|
+
|
|
45
|
+
return pathspec.PathSpec.from_lines("gitwildmatch", patterns)
|
|
46
|
+
|
|
47
|
+
def is_ignored(self, path: Path) -> bool:
|
|
48
|
+
"""Checks if a path matches the ignore patterns."""
|
|
49
|
+
try:
|
|
50
|
+
# pathspec requires relative paths (e.g., "src/main.py")
|
|
51
|
+
rel_path = path.relative_to(self.root_path).as_posix()
|
|
52
|
+
if path.is_dir():
|
|
53
|
+
rel_path += "/"
|
|
54
|
+
except ValueError:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
return self.ignore_spec.match_file(rel_path)
|
|
58
|
+
|
|
59
|
+
def scan(self) -> list[Path]:
|
|
60
|
+
"""
|
|
61
|
+
Walks the directory tree and returns valid files.
|
|
62
|
+
(Modernized from old core.py's stack-based approach)
|
|
63
|
+
"""
|
|
64
|
+
valid_files = []
|
|
65
|
+
|
|
66
|
+
# Use rglob for simplicity, but we must manually filter dirs to respect gitignore
|
|
67
|
+
# For better performance on large repos, we use a manual walk similar to the old code
|
|
68
|
+
dirs_stack = [self.root_path]
|
|
69
|
+
|
|
70
|
+
while dirs_stack:
|
|
71
|
+
current = dirs_stack.pop()
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
# Sort for deterministic output (LLMs like order)
|
|
75
|
+
entries = sorted(current.iterdir(), key=lambda x: x.name)
|
|
76
|
+
except PermissionError:
|
|
77
|
+
logger.debug(f"Permission denied: {current}")
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
for entry in entries:
|
|
81
|
+
if self.is_ignored(entry):
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
if entry.is_dir():
|
|
85
|
+
dirs_stack.append(entry)
|
|
86
|
+
elif entry.is_file():
|
|
87
|
+
# Extension Filter Check
|
|
88
|
+
if self.extensions and entry.suffix.lower() not in self.extensions:
|
|
89
|
+
# Allow specific config files even if extension doesn't match
|
|
90
|
+
if entry.name not in {".gitignore", "Dockerfile", "pyproject.toml"}:
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
valid_files.append(entry)
|
|
94
|
+
|
|
95
|
+
return sorted(valid_files)
|
|
96
|
+
|
|
97
|
+
def scan_project(root_path: Path, extensions: set[str] = None, extra_ignores: list[str] = None) -> list[Path]:
|
|
98
|
+
scanner = ProjectScanner(root_path, extensions, extra_ignores)
|
|
99
|
+
return scanner.scan()
|
codigest/core/semdiff.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Semantic Difference Engine (Spec 0.1 Compliant).
|
|
3
|
+
Tracks: Functions, Classes, Methods, Global Variables.
|
|
4
|
+
Detects: Signature Changes AND Logic Body Changes.
|
|
5
|
+
"""
|
|
6
|
+
import ast
|
|
7
|
+
import hashlib
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Dict, List, Optional, Any
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class SymbolInfo:
|
|
13
|
+
name: str
|
|
14
|
+
type: str # 'function', 'class', 'variable', ...
|
|
15
|
+
signature: str
|
|
16
|
+
content_hash: str # For logic change detection
|
|
17
|
+
docstring: Optional[str] = None
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SemanticChange:
|
|
21
|
+
change_type: str # 'ADDED', 'REMOVED', 'MODIFIED', 'LOGIC_CHANGED'
|
|
22
|
+
symbol: SymbolInfo
|
|
23
|
+
details: str = ""
|
|
24
|
+
|
|
25
|
+
class CodeParser(ast.NodeVisitor):
|
|
26
|
+
def __init__(self):
|
|
27
|
+
self.symbols: Dict[str, SymbolInfo] = {}
|
|
28
|
+
self.current_class = None
|
|
29
|
+
|
|
30
|
+
def _get_hash(self, node: ast.AST) -> str:
|
|
31
|
+
"""Normalized hash of the node body (ignoring formatting/comments implicitly by unparse)."""
|
|
32
|
+
try:
|
|
33
|
+
# ast.unparse returns standardized code string
|
|
34
|
+
normalized_code = ast.unparse(node)
|
|
35
|
+
return hashlib.md5(normalized_code.encode('utf-8')).hexdigest()
|
|
36
|
+
except Exception:
|
|
37
|
+
return ""
|
|
38
|
+
|
|
39
|
+
def _get_signature(self, node) -> str:
|
|
40
|
+
"""Reconstructs signature."""
|
|
41
|
+
if hasattr(node, 'args'):
|
|
42
|
+
try:
|
|
43
|
+
return f"({ast.unparse(node.args)})"
|
|
44
|
+
except: pass
|
|
45
|
+
return ""
|
|
46
|
+
|
|
47
|
+
def visit_FunctionDef(self, node):
|
|
48
|
+
self._visit_func(node, "function")
|
|
49
|
+
|
|
50
|
+
def visit_AsyncFunctionDef(self, node):
|
|
51
|
+
self._visit_func(node, "async_function")
|
|
52
|
+
|
|
53
|
+
def _visit_func(self, node, type_label):
|
|
54
|
+
name = node.name
|
|
55
|
+
if self.current_class:
|
|
56
|
+
name = f"{self.current_class}.{name}"
|
|
57
|
+
type_label = "method"
|
|
58
|
+
|
|
59
|
+
self.symbols[name] = SymbolInfo(
|
|
60
|
+
name=name,
|
|
61
|
+
type=type_label,
|
|
62
|
+
signature=self._get_signature(node),
|
|
63
|
+
content_hash=self._get_hash(node), # Hash entire function body
|
|
64
|
+
docstring=ast.get_docstring(node)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def visit_ClassDef(self, node):
|
|
68
|
+
prev_class = self.current_class
|
|
69
|
+
self.current_class = node.name
|
|
70
|
+
|
|
71
|
+
bases = [ast.unparse(b) for b in node.bases]
|
|
72
|
+
base_str = f"({', '.join(bases)})" if bases else ""
|
|
73
|
+
|
|
74
|
+
self.symbols[node.name] = SymbolInfo(
|
|
75
|
+
name=node.name,
|
|
76
|
+
type="class",
|
|
77
|
+
signature=base_str,
|
|
78
|
+
content_hash=self._get_hash(node), # Hash class structure
|
|
79
|
+
docstring=ast.get_docstring(node)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
self.generic_visit(node)
|
|
83
|
+
self.current_class = prev_class
|
|
84
|
+
|
|
85
|
+
def visit_Assign(self, node):
|
|
86
|
+
# Only track global variables (no indent)
|
|
87
|
+
if self.current_class is None:
|
|
88
|
+
for target in node.targets:
|
|
89
|
+
if isinstance(target, ast.Name):
|
|
90
|
+
name = target.id
|
|
91
|
+
# Try to infer value type/content roughly
|
|
92
|
+
try:
|
|
93
|
+
val_str = ast.unparse(node.value)
|
|
94
|
+
# Truncate long values
|
|
95
|
+
if len(val_str) > 50: val_str = val_str[:47] + "..."
|
|
96
|
+
sig = f" = {val_str}"
|
|
97
|
+
except:
|
|
98
|
+
sig = " = ..."
|
|
99
|
+
|
|
100
|
+
self.symbols[name] = SymbolInfo(
|
|
101
|
+
name=name,
|
|
102
|
+
type="variable",
|
|
103
|
+
signature=sig,
|
|
104
|
+
content_hash=self._get_hash(node)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Python 3.6+ Annotated Assignment (x: int = 1)
|
|
108
|
+
def visit_AnnAssign(self, node):
|
|
109
|
+
if self.current_class is None and isinstance(node.target, ast.Name):
|
|
110
|
+
name = node.target.id
|
|
111
|
+
try:
|
|
112
|
+
type_hint = ast.unparse(node.annotation)
|
|
113
|
+
val_str = f" = {ast.unparse(node.value)}" if node.value else ""
|
|
114
|
+
sig = f": {type_hint}{val_str}"
|
|
115
|
+
except:
|
|
116
|
+
sig = ": ..."
|
|
117
|
+
|
|
118
|
+
self.symbols[name] = SymbolInfo(
|
|
119
|
+
name=name,
|
|
120
|
+
type="variable",
|
|
121
|
+
signature=sig,
|
|
122
|
+
content_hash=self._get_hash(node)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def parse_code(code: str) -> Dict[str, SymbolInfo]:
|
|
126
|
+
if not code: return {}
|
|
127
|
+
try:
|
|
128
|
+
tree = ast.parse(code)
|
|
129
|
+
parser = CodeParser()
|
|
130
|
+
parser.visit(tree)
|
|
131
|
+
return parser.symbols
|
|
132
|
+
except SyntaxError:
|
|
133
|
+
return {}
|
|
134
|
+
|
|
135
|
+
def compare(old_code: str, new_code: str) -> List[SemanticChange]:
|
|
136
|
+
old_syms = parse_code(old_code)
|
|
137
|
+
new_syms = parse_code(new_code)
|
|
138
|
+
|
|
139
|
+
changes = []
|
|
140
|
+
all_keys = set(old_syms.keys()) | set(new_syms.keys())
|
|
141
|
+
|
|
142
|
+
for key in sorted(all_keys):
|
|
143
|
+
if key not in old_syms:
|
|
144
|
+
changes.append(SemanticChange("ADDED", new_syms[key]))
|
|
145
|
+
elif key not in new_syms:
|
|
146
|
+
changes.append(SemanticChange("REMOVED", old_syms[key]))
|
|
147
|
+
else:
|
|
148
|
+
old, new = old_syms[key], new_syms[key]
|
|
149
|
+
|
|
150
|
+
# 1. Signature Change? (API Breakage)
|
|
151
|
+
if old.signature != new.signature:
|
|
152
|
+
detail = f"{old.signature} -> {new.signature}"
|
|
153
|
+
changes.append(SemanticChange("MODIFIED", new, detail))
|
|
154
|
+
|
|
155
|
+
# 2. Logic Change? (Internal Implementation)
|
|
156
|
+
# Only check if signature didn't change (to avoid double reporting)
|
|
157
|
+
elif old.content_hash != new.content_hash:
|
|
158
|
+
changes.append(SemanticChange("LOGIC_CHANGED", new, "Implementation changed"))
|
|
159
|
+
|
|
160
|
+
return changes
|
|
161
|
+
|
|
162
|
+
def summarize(code: str) -> str:
|
|
163
|
+
symbols = parse_code(code)
|
|
164
|
+
if not symbols: return ""
|
|
165
|
+
|
|
166
|
+
lines = []
|
|
167
|
+
for name, sym in sorted(symbols.items()):
|
|
168
|
+
if sym.type == "class": icon = "📦"
|
|
169
|
+
elif sym.type == "method": icon = " ƒ"
|
|
170
|
+
elif sym.type == "async_function": icon = "⚡"
|
|
171
|
+
elif sym.type == "variable": icon = "🔹"
|
|
172
|
+
else: icon = "ƒ"
|
|
173
|
+
|
|
174
|
+
lines.append(f"{icon} {sym.type} {sym.name}{sym.signature}")
|
|
175
|
+
return "\n".join(lines)
|