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/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import pyperclip
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
6
|
+
|
|
7
|
+
from ..core import scanner, prompts, shadow
|
|
8
|
+
from .scan import _load_config_filters
|
|
9
|
+
|
|
10
|
+
app = typer.Typer()
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
@app.callback(invoke_without_command=True)
|
|
14
|
+
def handle(
|
|
15
|
+
copy: bool = typer.Option(True, help="Auto-copy to clipboard"),
|
|
16
|
+
save: bool = typer.Option(True, help="Save to .codigest/changes.diff"),
|
|
17
|
+
message: str = typer.Option("", "--message", "-m", help="Add specific instruction context"),
|
|
18
|
+
):
|
|
19
|
+
"""
|
|
20
|
+
[Context Update] Shows changes since the last 'codigest scan'.
|
|
21
|
+
Useful for updating LLM context without re-uploading everything.
|
|
22
|
+
"""
|
|
23
|
+
root_path = Path.cwd()
|
|
24
|
+
anchor = shadow.ContextAnchor(root_path)
|
|
25
|
+
|
|
26
|
+
# 1. Check Baseline Existence
|
|
27
|
+
last_update = anchor.get_last_update_time()
|
|
28
|
+
if last_update == "Never":
|
|
29
|
+
console.print("[yellow]⚠️ No scan history found.[/yellow]")
|
|
30
|
+
console.print(" Run [bold cyan]codigest scan[/bold cyan] first to establish a baseline.")
|
|
31
|
+
raise typer.Exit(1)
|
|
32
|
+
|
|
33
|
+
console.print(f"[dim]Checking changes since last scan ({last_update})...[/dim]")
|
|
34
|
+
|
|
35
|
+
# 2. Calculate Diff
|
|
36
|
+
with Progress(
|
|
37
|
+
SpinnerColumn(),
|
|
38
|
+
TextColumn("[bold blue]Analyzing changes...[/bold blue]"),
|
|
39
|
+
transient=True,
|
|
40
|
+
console=console
|
|
41
|
+
) as progress:
|
|
42
|
+
task = progress.add_task("diff", total=None)
|
|
43
|
+
|
|
44
|
+
# Load same filters as scan
|
|
45
|
+
extensions, extra_ignores = _load_config_filters(root_path)
|
|
46
|
+
|
|
47
|
+
# Get current valid files (Working Tree)
|
|
48
|
+
current_files = scanner.scan_project(root_path, extensions, extra_ignores)
|
|
49
|
+
|
|
50
|
+
# Compare Working Tree vs Anchor
|
|
51
|
+
diff_content = anchor.get_changes(current_files)
|
|
52
|
+
|
|
53
|
+
progress.update(task, completed=100)
|
|
54
|
+
|
|
55
|
+
if not diff_content.strip():
|
|
56
|
+
console.print("[green]✨ No changes detected since last scan.[/green]")
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
# 3. Render
|
|
60
|
+
prompt_engine = prompts.get_engine(root_path)
|
|
61
|
+
try:
|
|
62
|
+
formatted_diff = prompt_engine.render(
|
|
63
|
+
"diff",
|
|
64
|
+
project_name=root_path.name,
|
|
65
|
+
context_message=f"Changes since last scan ({last_update})",
|
|
66
|
+
diff_content=diff_content,
|
|
67
|
+
instruction=message
|
|
68
|
+
)
|
|
69
|
+
except Exception:
|
|
70
|
+
# Fallback if template fails
|
|
71
|
+
formatted_diff = f"<diff>\n{diff_content}\n</diff>"
|
|
72
|
+
|
|
73
|
+
# 4. Output
|
|
74
|
+
console.print(f"[bold green]✔ Changes Detected![/bold green] ({len(formatted_diff)} chars)")
|
|
75
|
+
|
|
76
|
+
if copy:
|
|
77
|
+
pyperclip.copy(formatted_diff)
|
|
78
|
+
console.print("[dim]📋 Copied to clipboard[/dim]")
|
|
79
|
+
|
|
80
|
+
if save:
|
|
81
|
+
out_path = root_path / ".codigest" / "changes.diff"
|
|
82
|
+
out_path.parent.mkdir(exist_ok=True)
|
|
83
|
+
out_path.write_text(formatted_diff, encoding="utf-8")
|
|
84
|
+
console.print(f"[dim]💾 Saved to {out_path}[/dim]")
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import pyperclip
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
6
|
+
|
|
7
|
+
from ..core import scanner, structure, prompts, semdiff, tags, tokenizer
|
|
8
|
+
|
|
9
|
+
# Reuse config loader
|
|
10
|
+
from .scan import _load_config_filters
|
|
11
|
+
|
|
12
|
+
app = typer.Typer()
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
@app.callback(invoke_without_command=True)
|
|
16
|
+
def handle(
|
|
17
|
+
target: Path = typer.Argument(Path.cwd(), help="Target directory"),
|
|
18
|
+
copy: bool = typer.Option(True, help="Auto-copy to clipboard"),
|
|
19
|
+
save: bool = typer.Option(True, help="Save to .codigest/digest.xml"),
|
|
20
|
+
message: str = typer.Option("", "--message", "-m", help="Add specific instruction"),
|
|
21
|
+
):
|
|
22
|
+
"""
|
|
23
|
+
[Architectural View] Summarizes the codebase structure (Classes/Functions only).
|
|
24
|
+
"""
|
|
25
|
+
root_path = target.resolve()
|
|
26
|
+
|
|
27
|
+
prompt_engine = prompts.get_engine(root_path)
|
|
28
|
+
extensions, extra_ignores = _load_config_filters(root_path)
|
|
29
|
+
|
|
30
|
+
with Progress(
|
|
31
|
+
SpinnerColumn(),
|
|
32
|
+
TextColumn("[bold blue]Digesting Architecture...[/bold blue]"),
|
|
33
|
+
transient=True,
|
|
34
|
+
console=console
|
|
35
|
+
) as progress:
|
|
36
|
+
|
|
37
|
+
task = progress.add_task("digest", total=None)
|
|
38
|
+
|
|
39
|
+
files = scanner.scan_project(root_path, extensions, extra_ignores)
|
|
40
|
+
tree_str = structure.generate_ascii_tree(files, root_path)
|
|
41
|
+
|
|
42
|
+
summary_blocks = []
|
|
43
|
+
for file_path in files:
|
|
44
|
+
# Only summarize Python files via AST
|
|
45
|
+
if file_path.suffix in (".py", ".pyi"):
|
|
46
|
+
try:
|
|
47
|
+
content = file_path.read_text(encoding="utf-8")
|
|
48
|
+
summary = semdiff.summarize(content)
|
|
49
|
+
|
|
50
|
+
if summary:
|
|
51
|
+
rel_path = file_path.relative_to(root_path).as_posix()
|
|
52
|
+
block = tags.xml(t"""
|
|
53
|
+
<file path="{rel_path}">
|
|
54
|
+
{summary}
|
|
55
|
+
</file>
|
|
56
|
+
""")
|
|
57
|
+
summary_blocks.append(block)
|
|
58
|
+
except Exception:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
summary_blob = "\n".join(summary_blocks)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
digest_content = prompt_engine.render(
|
|
65
|
+
"digest",
|
|
66
|
+
project_name=root_path.name,
|
|
67
|
+
tree_structure=tree_str,
|
|
68
|
+
digest_content=summary_blob,
|
|
69
|
+
instruction=message
|
|
70
|
+
)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
console.print(f"[red]❌ Rendering Failed:[/red] {e}")
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
|
|
75
|
+
progress.update(task, completed=100)
|
|
76
|
+
|
|
77
|
+
token_count = tokenizer.estimate_tokens(digest_content)
|
|
78
|
+
console.print(f"[bold green]✔ Digest Generated![/bold green] ([bold cyan]~{token_count:,} Tokens[/bold cyan])")
|
|
79
|
+
|
|
80
|
+
if copy:
|
|
81
|
+
pyperclip.copy(digest_content)
|
|
82
|
+
console.print("[dim]📋 Copied to clipboard[/dim]")
|
|
83
|
+
|
|
84
|
+
if save:
|
|
85
|
+
out_path = root_path / ".codigest" / "digest.xml"
|
|
86
|
+
out_path.parent.mkdir(exist_ok=True)
|
|
87
|
+
out_path.write_text(digest_content, encoding="utf-8")
|
|
88
|
+
console.print(f"[dim]💾 Saved to {out_path}[/dim]")
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
|
|
6
|
+
from ..core import scanner, shadow
|
|
7
|
+
|
|
8
|
+
app = typer.Typer()
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
DEFAULT_CONFIG = """# Codigest Configuration File
|
|
12
|
+
[project]
|
|
13
|
+
description = "Auto-generated context configuration"
|
|
14
|
+
|
|
15
|
+
[filter]
|
|
16
|
+
max_file_size_kb = 100
|
|
17
|
+
extensions = [
|
|
18
|
+
".py", ".pyi",
|
|
19
|
+
".ts", ".tsx", ".js", ".jsx",
|
|
20
|
+
".json", ".html", ".css",
|
|
21
|
+
".md", ".toml", ".yaml", ".yml", ".xml"
|
|
22
|
+
]
|
|
23
|
+
exclude_patterns = [
|
|
24
|
+
"*.lock",
|
|
25
|
+
"dist/",
|
|
26
|
+
"build/",
|
|
27
|
+
"node_modules/",
|
|
28
|
+
"__pycache__/"
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[output]
|
|
32
|
+
format = "xml"
|
|
33
|
+
structure = "toon"
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
@app.callback(invoke_without_command=True)
|
|
37
|
+
def handle(
|
|
38
|
+
force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing config"),
|
|
39
|
+
):
|
|
40
|
+
"""
|
|
41
|
+
Initialize the .codigest environment and capture the initial state.
|
|
42
|
+
"""
|
|
43
|
+
root_path = Path.cwd()
|
|
44
|
+
config_dir = root_path / ".codigest"
|
|
45
|
+
config_file = config_dir / "config.toml"
|
|
46
|
+
gitignore_file = root_path / ".gitignore"
|
|
47
|
+
|
|
48
|
+
console.print(Panel(f"[bold blue]Initializing Codigest[/bold blue]\n📂 Location: {root_path}", expand=False))
|
|
49
|
+
|
|
50
|
+
# 1. Config Setup (기존 로직)
|
|
51
|
+
if not config_dir.exists():
|
|
52
|
+
config_dir.mkdir()
|
|
53
|
+
console.print(" [green]✔[/green] Created [bold].codigest/[/bold] directory")
|
|
54
|
+
|
|
55
|
+
if not config_file.exists() or force:
|
|
56
|
+
config_file.write_text(DEFAULT_CONFIG, encoding="utf-8")
|
|
57
|
+
console.print(" [green]✔[/green] Created [bold]config.toml[/bold]")
|
|
58
|
+
else:
|
|
59
|
+
console.print(" [yellow]![/yellow] Config exists. Use --force to overwrite.")
|
|
60
|
+
|
|
61
|
+
_update_gitignore(gitignore_file)
|
|
62
|
+
|
|
63
|
+
# 2. [NEW] Initial Anchoring (Shadow Git)
|
|
64
|
+
# "프로그램 시작 시에도 Anchor 하나 잡고 시작"
|
|
65
|
+
console.print("\n[dim]Creating initial context anchor...[/dim]")
|
|
66
|
+
try:
|
|
67
|
+
# Default scan settings for initialization
|
|
68
|
+
files = scanner.scan_project(root_path)
|
|
69
|
+
anchor = shadow.ContextAnchor(root_path)
|
|
70
|
+
anchor.update(files)
|
|
71
|
+
console.print(" [green]✔[/green] Baseline snapshot captured.")
|
|
72
|
+
except Exception as e:
|
|
73
|
+
console.print(f" [red]⚠️ Failed to create initial anchor: {e}[/red]")
|
|
74
|
+
|
|
75
|
+
console.print("\n[bold green]✨ Ready to digest![/bold green]")
|
|
76
|
+
console.print("Try running: [cyan]codigest diff[/cyan] to see changes from now on.")
|
|
77
|
+
|
|
78
|
+
def _update_gitignore(path: Path):
|
|
79
|
+
entry = ".codigest/"
|
|
80
|
+
if not path.exists():
|
|
81
|
+
path.write_text(f"# Git Ignore\n{entry}\n", encoding="utf-8")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
if entry not in path.read_text(encoding="utf-8"):
|
|
85
|
+
with path.open("a", encoding="utf-8") as f:
|
|
86
|
+
f.write(f"\n{entry}\n")
|
|
87
|
+
console.print(" [green]✔[/green] Added [bold].codigest/[/bold] to .gitignore")
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import tomllib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
6
|
+
|
|
7
|
+
from ..core import scanner, structure, tags, prompts, processor, shadow, tokenizer
|
|
8
|
+
|
|
9
|
+
app = typer.Typer()
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
def _load_config_filters(root_path: Path):
|
|
13
|
+
config_path = root_path / ".codigest" / "config.toml"
|
|
14
|
+
extensions = None
|
|
15
|
+
exclude_patterns = []
|
|
16
|
+
|
|
17
|
+
if config_path.exists():
|
|
18
|
+
try:
|
|
19
|
+
with open(config_path, "rb") as f:
|
|
20
|
+
data = tomllib.load(f)
|
|
21
|
+
filters = data.get("filter", {})
|
|
22
|
+
ext_list = filters.get("extensions", [])
|
|
23
|
+
if ext_list:
|
|
24
|
+
extensions = set(ext_list)
|
|
25
|
+
exclude_patterns = filters.get("exclude_patterns", [])
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
return extensions, exclude_patterns
|
|
30
|
+
|
|
31
|
+
@app.callback(invoke_without_command=True)
|
|
32
|
+
def handle(
|
|
33
|
+
target: Path = typer.Argument(Path.cwd(), help="Target directory"),
|
|
34
|
+
output: str = typer.Option("snapshot.xml", help="Output filename inside .codigest/"),
|
|
35
|
+
all: bool = typer.Option(False, "--all", "-a", help="Ignore config filters"),
|
|
36
|
+
message: str = typer.Option("", "--message", "-m", help="Add specific instruction"),
|
|
37
|
+
line_numbers: bool = typer.Option(False, "--lines", "-l", help="Add line numbers to code blocks"),
|
|
38
|
+
):
|
|
39
|
+
"""
|
|
40
|
+
[Legacy Mode] Scans the codebase using the PromptEngine.
|
|
41
|
+
"""
|
|
42
|
+
root_path = target.resolve()
|
|
43
|
+
artifact_dir = root_path / ".codigest"
|
|
44
|
+
|
|
45
|
+
if not artifact_dir.exists():
|
|
46
|
+
console.print("[yellow]⚠️ .codigest directory missing. Running init...[/yellow]")
|
|
47
|
+
try:
|
|
48
|
+
artifact_dir.mkdir(exist_ok=True)
|
|
49
|
+
except PermissionError:
|
|
50
|
+
console.print(f"[red]❌ Error: Cannot create .codigest at {root_path}[/red]")
|
|
51
|
+
raise typer.Exit(1)
|
|
52
|
+
|
|
53
|
+
output_path = artifact_dir / output
|
|
54
|
+
|
|
55
|
+
# Initialize Engines
|
|
56
|
+
prompt_engine = prompts.get_engine(root_path)
|
|
57
|
+
anchor = shadow.ContextAnchor(root_path)
|
|
58
|
+
|
|
59
|
+
extensions, extra_ignores = (None, [])
|
|
60
|
+
if not all:
|
|
61
|
+
extensions, extra_ignores = _load_config_filters(root_path)
|
|
62
|
+
|
|
63
|
+
with Progress(
|
|
64
|
+
SpinnerColumn(),
|
|
65
|
+
TextColumn("[bold blue]Scanning Project...[/bold blue]"),
|
|
66
|
+
transient=True,
|
|
67
|
+
console=console
|
|
68
|
+
):
|
|
69
|
+
|
|
70
|
+
# File Discovery
|
|
71
|
+
files = scanner.scan_project(root_path, extensions, extra_ignores)
|
|
72
|
+
|
|
73
|
+
# Pre-scan Diff Report
|
|
74
|
+
if anchor.has_history():
|
|
75
|
+
try:
|
|
76
|
+
diff_content = anchor.get_changes(files)
|
|
77
|
+
if diff_content.strip():
|
|
78
|
+
pre_diff_path = artifact_dir / "previous_changes.diff"
|
|
79
|
+
pre_diff_path.write_text(diff_content, encoding="utf-8")
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
# Structure Generation
|
|
84
|
+
tree_str = structure.generate_ascii_tree(files, root_path)
|
|
85
|
+
|
|
86
|
+
# Content Processing
|
|
87
|
+
file_blocks = []
|
|
88
|
+
for file_path in files:
|
|
89
|
+
rel_path = file_path.relative_to(root_path).as_posix()
|
|
90
|
+
try:
|
|
91
|
+
content = processor.read_file_content(file_path, add_line_numbers=line_numbers)
|
|
92
|
+
block = tags.xml(t"""
|
|
93
|
+
<file path="{rel_path}">
|
|
94
|
+
{content}
|
|
95
|
+
</file>
|
|
96
|
+
""")
|
|
97
|
+
file_blocks.append(block)
|
|
98
|
+
except Exception:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
source_code_blob = "\n\n".join(file_blocks)
|
|
102
|
+
|
|
103
|
+
# Final Assembly
|
|
104
|
+
try:
|
|
105
|
+
snapshot_content = prompt_engine.render(
|
|
106
|
+
"snapshot",
|
|
107
|
+
project_name=root_path.name,
|
|
108
|
+
tree_structure=tree_str,
|
|
109
|
+
source_code=source_code_blob,
|
|
110
|
+
instruction=message
|
|
111
|
+
)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
console.print(f"[red]❌ Template Rendering Failed:[/red] {e}")
|
|
114
|
+
raise typer.Exit(1)
|
|
115
|
+
|
|
116
|
+
# Update Anchor
|
|
117
|
+
try:
|
|
118
|
+
anchor.update(files)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
console.print(f"[yellow]⚠️ Warning: Failed to update context anchor: {e}[/yellow]")
|
|
121
|
+
|
|
122
|
+
# Save to Disk
|
|
123
|
+
try:
|
|
124
|
+
output_path.write_text(snapshot_content, encoding="utf-8")
|
|
125
|
+
|
|
126
|
+
size_kb = len(snapshot_content) / 1024
|
|
127
|
+
token_count = tokenizer.estimate_tokens(snapshot_content)
|
|
128
|
+
|
|
129
|
+
# Report
|
|
130
|
+
console.print("[bold green]✔ Snapshot Saved![/bold green]")
|
|
131
|
+
console.print(f" 📄 Path: [underline]{output_path}[/underline]")
|
|
132
|
+
console.print(f" 📊 Size: {size_kb:.1f} KB | [bold cyan]~{token_count:,} Tokens[/bold cyan]")
|
|
133
|
+
console.print(f" 📂 Files: {len(files)}")
|
|
134
|
+
|
|
135
|
+
if anchor.has_history():
|
|
136
|
+
pre_diff_path = artifact_dir / "previous_changes.diff"
|
|
137
|
+
if pre_diff_path.exists() and pre_diff_path.stat().st_size > 0:
|
|
138
|
+
console.print(f" [dim]ℹ️ Changes before this scan saved to: {pre_diff_path.name}[/dim]")
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
console.print(f"[bold red]❌ Save Failed:[/bold red] {e}")
|
|
142
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import pyperclip
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
6
|
+
|
|
7
|
+
from ..core import scanner, prompts, shadow, semdiff, tags, tokenizer
|
|
8
|
+
|
|
9
|
+
# Reuse config loader
|
|
10
|
+
from .scan import _load_config_filters
|
|
11
|
+
|
|
12
|
+
app = typer.Typer()
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
@app.callback(invoke_without_command=True)
|
|
16
|
+
def handle(
|
|
17
|
+
target: Path = typer.Argument(Path.cwd(), help="Target directory"),
|
|
18
|
+
copy: bool = typer.Option(True, help="Auto-copy to clipboard"),
|
|
19
|
+
save: bool = typer.Option(True, help="Save to .codigest/semdiff.xml"),
|
|
20
|
+
message: str = typer.Option("", "--message", "-m", help="Add specific instruction"),
|
|
21
|
+
):
|
|
22
|
+
"""
|
|
23
|
+
[Advanced] Generates a Semantic Diff (AST-based) report.
|
|
24
|
+
Shows ADDED/REMOVED/MODIFIED functions & classes instead of raw text lines.
|
|
25
|
+
"""
|
|
26
|
+
root_path = target.resolve()
|
|
27
|
+
anchor = shadow.ContextAnchor(root_path)
|
|
28
|
+
|
|
29
|
+
last_update = anchor.get_last_update_time()
|
|
30
|
+
if last_update == "Never":
|
|
31
|
+
console.print("[yellow]⚠️ No scan history found. Run [bold]codigest scan[/bold] first.[/yellow]")
|
|
32
|
+
raise typer.Exit(1)
|
|
33
|
+
|
|
34
|
+
console.print(f"[dim]Analyzing structural changes since ({last_update})...[/dim]")
|
|
35
|
+
|
|
36
|
+
reports = []
|
|
37
|
+
|
|
38
|
+
with Progress(
|
|
39
|
+
SpinnerColumn(),
|
|
40
|
+
TextColumn("[bold blue]Parsing AST...[/bold blue]"),
|
|
41
|
+
transient=True,
|
|
42
|
+
console=console
|
|
43
|
+
) as progress:
|
|
44
|
+
task = progress.add_task("semdiff", total=None)
|
|
45
|
+
|
|
46
|
+
# 1. Scan files
|
|
47
|
+
extensions, extra_ignores = _load_config_filters(root_path)
|
|
48
|
+
current_files = scanner.scan_project(root_path, extensions, extra_ignores)
|
|
49
|
+
|
|
50
|
+
changed_files_paths = anchor.get_changed_files(list(current_files))
|
|
51
|
+
|
|
52
|
+
target_files = []
|
|
53
|
+
for p in changed_files_paths:
|
|
54
|
+
if p.suffix in (".py", ".pyi"):
|
|
55
|
+
target_files.append(p)
|
|
56
|
+
|
|
57
|
+
# 2. Compare Each File
|
|
58
|
+
for file_path in target_files:
|
|
59
|
+
rel_path = file_path.relative_to(root_path)
|
|
60
|
+
|
|
61
|
+
# Read Content (Handle Missing Files for Deletion)
|
|
62
|
+
try:
|
|
63
|
+
new_code = file_path.read_text(encoding="utf-8") if file_path.exists() else ""
|
|
64
|
+
except: new_code = ""
|
|
65
|
+
|
|
66
|
+
old_code = anchor.read_anchor_file(rel_path)
|
|
67
|
+
|
|
68
|
+
# Case: File Added (No old code) -> All symbols ADDED
|
|
69
|
+
# Case: File Deleted (No new code) -> All symbols REMOVED
|
|
70
|
+
# Case: Modified -> Semantic Compare
|
|
71
|
+
|
|
72
|
+
changes = semdiff.compare(old_code, new_code)
|
|
73
|
+
|
|
74
|
+
if changes:
|
|
75
|
+
change_lines = []
|
|
76
|
+
for ch in changes:
|
|
77
|
+
if ch.change_type == "ADDED": icon = "➕ [ADDED] "
|
|
78
|
+
elif ch.change_type == "REMOVED": icon = "➖ [REMOVED] "
|
|
79
|
+
elif ch.change_type == "MODIFIED": icon = "⚠️ [SIGNATURE]" # 중요: 시그니처 변경
|
|
80
|
+
else: icon = "✏️ [LOGIC] " # 내부 로직 변경
|
|
81
|
+
|
|
82
|
+
detail = f" :: {ch.details}" if ch.details else ""
|
|
83
|
+
line = f"{icon} {ch.symbol.type} {ch.symbol.name}{ch.symbol.signature}{detail}"
|
|
84
|
+
change_lines.append(line)
|
|
85
|
+
|
|
86
|
+
# File Deleted Tagging
|
|
87
|
+
file_status = ""
|
|
88
|
+
if not new_code: file_status = " (DELETED)"
|
|
89
|
+
elif not old_code: file_status = " (NEW)"
|
|
90
|
+
|
|
91
|
+
block = tags.xml(t"""
|
|
92
|
+
<file path="{rel_path.as_posix()}{file_status}">
|
|
93
|
+
{chr(10).join(change_lines)}
|
|
94
|
+
</file>
|
|
95
|
+
""")
|
|
96
|
+
reports.append(block)
|
|
97
|
+
|
|
98
|
+
progress.update(task, completed=100)
|
|
99
|
+
|
|
100
|
+
if not reports:
|
|
101
|
+
console.print("[green]✨ No structural (AST) changes detected.[/green]")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
# Render
|
|
105
|
+
report_content = "\n".join(reports)
|
|
106
|
+
prompt_engine = prompts.get_engine(root_path)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
final_output = prompt_engine.render(
|
|
110
|
+
"semdiff",
|
|
111
|
+
project_name=root_path.name,
|
|
112
|
+
context_message=f"Structural changes since {last_update}",
|
|
113
|
+
semdiff_content=report_content,
|
|
114
|
+
instruction=message
|
|
115
|
+
)
|
|
116
|
+
except Exception as e:
|
|
117
|
+
console.print(f"[red]❌ Template Error:[/red] {e}")
|
|
118
|
+
raise typer.Exit(1)
|
|
119
|
+
|
|
120
|
+
token_count = tokenizer.estimate_tokens(final_output)
|
|
121
|
+
console.print(f"[bold green]✔ SemDiff Generated![/bold green] ([bold cyan]~{token_count:,} Tokens[/bold cyan])")
|
|
122
|
+
|
|
123
|
+
if copy:
|
|
124
|
+
pyperclip.copy(final_output)
|
|
125
|
+
console.print("[dim]📋 Copied to clipboard[/dim]")
|
|
126
|
+
|
|
127
|
+
if save:
|
|
128
|
+
out_path = root_path / ".codigest" / "semdiff.xml"
|
|
129
|
+
out_path.parent.mkdir(exist_ok=True)
|
|
130
|
+
out_path.write_text(final_output, encoding="utf-8")
|
|
131
|
+
console.print(f"[dim]💾 Saved to {out_path}[/dim]")
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import tomllib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.tree import Tree
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from rich.filesize import decimal
|
|
9
|
+
|
|
10
|
+
from ..core.scanner import scan_project
|
|
11
|
+
|
|
12
|
+
app = typer.Typer()
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
def _load_extensions_from_config(root_path: Path) -> Optional[set[str]]:
|
|
16
|
+
"""Attempts to load target extensions from .codigest/config.toml"""
|
|
17
|
+
config_path = root_path / ".codigest" / "config.toml"
|
|
18
|
+
if not config_path.exists():
|
|
19
|
+
return None
|
|
20
|
+
try:
|
|
21
|
+
with open(config_path, "rb") as f:
|
|
22
|
+
data = tomllib.load(f)
|
|
23
|
+
exts = data.get("filter", {}).get("extensions", [])
|
|
24
|
+
return set(exts) if exts else None
|
|
25
|
+
except Exception:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
def _build_rich_tree(root_path: Path, files: list[Path]) -> Tree:
|
|
29
|
+
"""Converts a flat list of Paths into a visual Rich Tree."""
|
|
30
|
+
tree = Tree(
|
|
31
|
+
f":open_file_folder: [bold blue]{root_path.name}[/bold blue]",
|
|
32
|
+
guide_style="bold bright_black",
|
|
33
|
+
)
|
|
34
|
+
dir_nodes = {root_path: tree}
|
|
35
|
+
|
|
36
|
+
for path in files:
|
|
37
|
+
relative = path.relative_to(root_path)
|
|
38
|
+
parts = relative.parts
|
|
39
|
+
current_node = tree
|
|
40
|
+
current_path = root_path
|
|
41
|
+
|
|
42
|
+
for part in parts[:-1]:
|
|
43
|
+
current_path = current_path / part
|
|
44
|
+
if current_path not in dir_nodes:
|
|
45
|
+
dir_nodes[current_path] = current_node.add(f":folder: [bold cyan]{part}[/bold cyan]")
|
|
46
|
+
current_node = dir_nodes[current_path]
|
|
47
|
+
|
|
48
|
+
stat = path.stat()
|
|
49
|
+
size_str = decimal(stat.st_size)
|
|
50
|
+
filename = parts[-1]
|
|
51
|
+
|
|
52
|
+
# Icon logic
|
|
53
|
+
icon, style = "📄", "white"
|
|
54
|
+
if filename.endswith(".py"):
|
|
55
|
+
icon, style = "🐍", "green"
|
|
56
|
+
elif filename.endswith((".js", ".ts", ".tsx")):
|
|
57
|
+
icon, style = "📜", "yellow"
|
|
58
|
+
elif filename.endswith((".html", ".css")):
|
|
59
|
+
icon, style = "🌐", "magenta"
|
|
60
|
+
elif filename.endswith((".json", ".toml", ".yaml")):
|
|
61
|
+
icon, style = "⚙️", "blue"
|
|
62
|
+
elif filename.endswith(".md"):
|
|
63
|
+
icon, style = "📝", "cyan"
|
|
64
|
+
|
|
65
|
+
label = Text(f"{icon} {filename}", style=style)
|
|
66
|
+
label.append(f" ({size_str})", style="dim")
|
|
67
|
+
current_node.add(label)
|
|
68
|
+
|
|
69
|
+
return tree
|
|
70
|
+
|
|
71
|
+
@app.callback(invoke_without_command=True)
|
|
72
|
+
def handle(
|
|
73
|
+
target: Path = typer.Argument(Path.cwd(), help="Target directory"),
|
|
74
|
+
all: bool = typer.Option(False, "--all", "-a", help="Ignore config filters"),
|
|
75
|
+
):
|
|
76
|
+
"""
|
|
77
|
+
[Visual] Print the project directory tree (Respects .gitignore).
|
|
78
|
+
"""
|
|
79
|
+
if not target.exists():
|
|
80
|
+
console.print(f"[red]❌ Error: Path '{target}' does not exist.[/red]")
|
|
81
|
+
raise typer.Exit(code=1)
|
|
82
|
+
|
|
83
|
+
root_path = target.resolve()
|
|
84
|
+
|
|
85
|
+
# 1. Determine Filters
|
|
86
|
+
extensions = None
|
|
87
|
+
if not all:
|
|
88
|
+
extensions = _load_extensions_from_config(root_path)
|
|
89
|
+
filter_status = f"[dim]Filter: {len(extensions)} extensions from config[/dim]" if extensions else "[dim]Filter: Default safelist[/dim]"
|
|
90
|
+
else:
|
|
91
|
+
filter_status = "[bold yellow]Filter: ALL (Gitignore only)[/bold yellow]"
|
|
92
|
+
|
|
93
|
+
console.print(f"[bold]🔍 Scanning:[/bold] {root_path}")
|
|
94
|
+
console.print(filter_status)
|
|
95
|
+
|
|
96
|
+
# 2. Scan
|
|
97
|
+
try:
|
|
98
|
+
files = scan_project(root_path, extensions)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
console.print(f"[red]❌ Scan failed:[/red] {e}")
|
|
101
|
+
raise typer.Exit(code=1)
|
|
102
|
+
|
|
103
|
+
if not files:
|
|
104
|
+
console.print("[yellow]⚠️ No matching files found.[/yellow]")
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
# 3. Visualize
|
|
108
|
+
tree_viz = _build_rich_tree(root_path, files)
|
|
109
|
+
console.print(tree_viz)
|
|
110
|
+
console.print(f"\n[dim]Found {len(files)} files.[/dim]")
|
codigest/config.py
ADDED
|
File without changes
|