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.
@@ -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}>>"
@@ -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)
@@ -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()
@@ -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)