codigest 0.3.3__tar.gz → 0.3.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {codigest-0.3.3 → codigest-0.3.5}/PKG-INFO +1 -1
  2. {codigest-0.3.3 → codigest-0.3.5}/pyproject.toml +2 -2
  3. {codigest-0.3.3 → codigest-0.3.5}/src/codigest/commands/diff.py +26 -25
  4. {codigest-0.3.3 → codigest-0.3.5}/src/codigest/commands/digest.py +18 -13
  5. {codigest-0.3.3 → codigest-0.3.5}/src/codigest/commands/scan.py +20 -90
  6. {codigest-0.3.3 → codigest-0.3.5}/src/codigest/commands/semdiff.py +43 -37
  7. codigest-0.3.5/src/codigest/commands/tree.py +82 -0
  8. codigest-0.3.5/src/codigest/core/common.py +118 -0
  9. {codigest-0.3.3 → codigest-0.3.5}/src/codigest/core/prompts.py +2 -2
  10. codigest-0.3.5/src/codigest/core/resolver.py +151 -0
  11. {codigest-0.3.3 → codigest-0.3.5}/src/codigest/core/scanner.py +3 -3
  12. {codigest-0.3.3 → codigest-0.3.5}/src/codigest/core/semdiff.py +2 -2
  13. {codigest-0.3.3 → codigest-0.3.5}/src/codigest/core/shadow.py +30 -3
  14. {codigest-0.3.3 → codigest-0.3.5}/uv.lock +5 -1
  15. codigest-0.3.3/src/codigest/commands/tree.py +0 -108
  16. codigest-0.3.3/src/codigest/core/resolver.py +0 -107
  17. {codigest-0.3.3 → codigest-0.3.5}/.github/workflows/publish.yml +0 -0
  18. {codigest-0.3.3 → codigest-0.3.5}/.gitignore +0 -0
  19. {codigest-0.3.3 → codigest-0.3.5}/.python-version +0 -0
  20. {codigest-0.3.3 → codigest-0.3.5}/README.md +0 -0
  21. {codigest-0.3.3 → codigest-0.3.5}/src/codigest/__init__.py +0 -0
  22. {codigest-0.3.3 → codigest-0.3.5}/src/codigest/__main__.py +0 -0
  23. {codigest-0.3.3 → codigest-0.3.5}/src/codigest/commands/init.py +0 -0
  24. {codigest-0.3.3 → codigest-0.3.5}/src/codigest/config.py +0 -0
  25. {codigest-0.3.3 → codigest-0.3.5}/src/codigest/core/git_ops.py +0 -0
  26. {codigest-0.3.3 → codigest-0.3.5}/src/codigest/core/processor.py +0 -0
  27. {codigest-0.3.3 → codigest-0.3.5}/src/codigest/core/structure.py +0 -0
  28. {codigest-0.3.3 → codigest-0.3.5}/src/codigest/core/tags.py +0 -0
  29. {codigest-0.3.3 → codigest-0.3.5}/src/codigest/core/tokenizer.py +0 -0
  30. {codigest-0.3.3 → codigest-0.3.5}/src/codigest/main.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codigest
3
- Version: 0.3.3
3
+ Version: 0.3.5
4
4
  Summary: Human-Centered Codebase Context Manager for LLMs
5
5
  Project-URL: Homepage, https://github.com/SJB777/codigest
6
6
  Project-URL: Repository, https://github.com/SJB777/codigest
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codigest"
3
- version = "0.3.3"
3
+ version = "0.3.5"
4
4
  description = "Human-Centered Codebase Context Manager for LLMs"
5
5
  authors = [{name = "Codigest Team", email = "sngd024@google.com"}]
6
6
  readme = "README.md"
@@ -36,4 +36,4 @@ cdg = "codigest.main:app"
36
36
 
37
37
  [build-system]
38
38
  requires = ["hatchling"]
39
- build-backend = "hatchling.build"
39
+ build-backend = "hatchling.build"
@@ -4,15 +4,13 @@ from pathlib import Path
4
4
  from rich.console import Console
5
5
  from rich.progress import Progress, SpinnerColumn, TextColumn
6
6
 
7
- from ..core import scanner, prompts, shadow
8
- from .scan import _load_config_filters
7
+ from ..core import prompts, shadow, tags, common
9
8
 
10
9
  app = typer.Typer()
11
10
  console = Console()
12
11
 
13
12
  @app.callback(invoke_without_command=True)
14
13
  def handle(
15
- # target 인자 추가
16
14
  target: Path = typer.Argument(
17
15
  Path.cwd(),
18
16
  help="Target directory to check diff",
@@ -24,24 +22,29 @@ def handle(
24
22
  copy: bool = typer.Option(True, help="Auto-copy to clipboard"),
25
23
  save: bool = typer.Option(True, help="Save to .codigest/changes.diff"),
26
24
  message: str = typer.Option("", "--message", "-m", help="Add specific instruction context"),
25
+ # [추가] resolve 옵션
26
+ resolve: bool = typer.Option(False, "-r", "--resolve", help="Recursively resolve imports"),
27
27
  ):
28
28
  """
29
29
  [Context Update] Shows changes since the last 'codigest scan'.
30
30
  Useful for updating LLM context without re-uploading everything.
31
31
  """
32
- root_path = target
33
- anchor = shadow.ContextAnchor(root_path)
32
+ # [1] Context Setup
33
+ ctx = common.get_context(target)
34
+ root_path = ctx.root_path
34
35
 
35
- # 1. Check Baseline Existence
36
+ anchor = shadow.ContextAnchor(root_path)
37
+
38
+ # Check Baseline
36
39
  last_update = anchor.get_last_update_time()
37
40
  if last_update == "Never":
38
- console.print("[yellow]No scan history found.[/yellow]")
39
- console.print(" Run [bold cyan]codigest scan[/bold cyan] first to establish a baseline.")
41
+ console.print("[yellow]⚠️ No scan history found.[/yellow]")
42
+ console.print(" Run [bold cyan]cdg scan[/bold cyan] first to establish a baseline.")
40
43
  raise typer.Exit(1)
41
44
 
42
45
  console.print(f"[dim]Checking changes since last scan ({last_update})...[/dim]")
43
46
 
44
- # 2. Calculate Diff
47
+ # [2] Calculate Diff via Context
45
48
  with Progress(
46
49
  SpinnerColumn(),
47
50
  TextColumn("[bold blue]Analyzing changes...[/bold blue]"),
@@ -49,23 +52,20 @@ def handle(
49
52
  console=console
50
53
  ) as progress:
51
54
  task = progress.add_task("diff", total=None)
52
-
53
- # Load same filters as scan
54
- extensions, extra_ignores = _load_config_filters(root_path)
55
-
56
- # Get current valid files (Working Tree)
57
- current_files = scanner.scan_project(root_path, extensions, extra_ignores)
58
-
55
+
56
+ # ★ Use common context to get files (handles config & resolve)
57
+ current_files = ctx.get_target_files(resolve_deps=resolve)
58
+
59
59
  # Compare Working Tree vs Anchor
60
60
  diff_content = anchor.get_changes(current_files)
61
-
61
+
62
62
  progress.update(task, completed=100)
63
63
 
64
64
  if not diff_content.strip():
65
- console.print("[green]✨ No changes detected since last scan.[/green]")
65
+ console.print("[green]No changes detected since last scan.[/green]")
66
66
  return
67
67
 
68
- # 3. Render
68
+ # [3] Render
69
69
  prompt_engine = prompts.get_engine(root_path)
70
70
  try:
71
71
  formatted_diff = prompt_engine.render(
@@ -76,18 +76,19 @@ def handle(
76
76
  instruction=message
77
77
  )
78
78
  except Exception:
79
- # Fallback if template fails
80
- formatted_diff = f"<diff>\n{diff_content}\n</diff>"
79
+ # Fallback if template fails (using safe escaping via tags)
80
+ safe_content = tags.escape_xml_value(diff_content)
81
+ formatted_diff = f"<diff>\n{safe_content}\n</diff>"
81
82
 
82
- # 4. Output
83
- console.print(f"[bold green]✔ Changes Detected![/bold green] ({len(formatted_diff)} chars)")
83
+ # [4] Output
84
+ console.print(f"[bold green]Changes Detected![/bold green] ({len(formatted_diff)} chars)")
84
85
 
85
86
  if copy:
86
87
  pyperclip.copy(formatted_diff)
87
- console.print("[dim]Copied to clipboard[/dim]")
88
+ console.print("[dim]Clipboard copied[/dim]")
88
89
 
89
90
  if save:
90
91
  out_path = root_path / ".codigest" / "changes.diff"
91
92
  out_path.parent.mkdir(exist_ok=True)
92
93
  out_path.write_text(formatted_diff, encoding="utf-8")
93
- console.print(f"[dim]Saved to {out_path}[/dim]")
94
+ console.print(f"[dim]Saved to {out_path}[/dim]")
@@ -4,10 +4,7 @@ from pathlib import Path
4
4
  from rich.console import Console
5
5
  from rich.progress import Progress, SpinnerColumn, TextColumn
6
6
 
7
- from ..core import scanner, structure, prompts, semdiff, tags, tokenizer
8
-
9
- # Reuse config loader
10
- from .scan import _load_config_filters
7
+ from ..core import structure, prompts, semdiff, tags, tokenizer, common
11
8
 
12
9
  app = typer.Typer()
13
10
  console = Console()
@@ -18,14 +15,17 @@ def handle(
18
15
  copy: bool = typer.Option(True, help="Auto-copy to clipboard"),
19
16
  save: bool = typer.Option(True, help="Save to .codigest/digest.xml"),
20
17
  message: str = typer.Option("", "--message", "-m", help="Add specific instruction"),
18
+ # [추가]
19
+ resolve: bool = typer.Option(False, "-r", "--resolve", help="Recursively resolve imports"),
21
20
  ):
22
21
  """
23
22
  [Architectural View] Summarizes the codebase structure (Classes/Functions only).
24
23
  """
25
- root_path = target.resolve()
24
+ # [1] Context Setup
25
+ ctx = common.get_context(target)
26
+ root_path = ctx.root_path
26
27
 
27
28
  prompt_engine = prompts.get_engine(root_path)
28
- extensions, extra_ignores = _load_config_filters(root_path)
29
29
 
30
30
  with Progress(
31
31
  SpinnerColumn(),
@@ -36,19 +36,24 @@ def handle(
36
36
 
37
37
  task = progress.add_task("digest", total=None)
38
38
 
39
- files = scanner.scan_project(root_path, extensions, extra_ignores)
39
+ # [2] File Discovery via Context
40
+ files = ctx.get_target_files(ignore_config=False, resolve_deps=resolve)
41
+
40
42
  tree_str = structure.generate_ascii_tree(files, root_path)
41
43
 
42
44
  summary_blocks = []
43
45
  for file_path in files:
44
- # Only summarize Python files via AST
45
46
  if file_path.suffix in (".py", ".pyi"):
46
47
  try:
47
48
  content = file_path.read_text(encoding="utf-8")
48
49
  summary = semdiff.summarize(content)
49
50
 
50
51
  if summary:
51
- rel_path = file_path.relative_to(root_path).as_posix()
52
+ try:
53
+ rel_path = file_path.relative_to(root_path).as_posix()
54
+ except ValueError:
55
+ rel_path = f"[EXTERNAL]/{file_path.name}"
56
+
52
57
  block = tags.file(rel_path, summary)
53
58
  summary_blocks.append(block)
54
59
  except Exception:
@@ -65,20 +70,20 @@ def handle(
65
70
  instruction=message
66
71
  )
67
72
  except Exception as e:
68
- console.print(f"[red]Rendering Failed:[/red] {e}")
73
+ console.print(f"[red]❌ Rendering Failed:[/red] {e}")
69
74
  raise typer.Exit(1)
70
75
 
71
76
  progress.update(task, completed=100)
72
77
 
73
78
  token_count = tokenizer.estimate_tokens(digest_content)
74
- console.print(f"[bold green]✔ Digest Generated![/bold green] ([bold cyan]~{token_count:,} Tokens[/bold cyan])")
79
+ console.print(f"[bold green]Digest Generated![/bold green] ([bold cyan]~{token_count:,} Tokens[/bold cyan])")
75
80
 
76
81
  if copy:
77
82
  pyperclip.copy(digest_content)
78
- console.print("[dim]Copied to clipboard[/dim]")
83
+ console.print("[dim]📋 Copied to clipboard[/dim]")
79
84
 
80
85
  if save:
81
86
  out_path = root_path / ".codigest" / "digest.xml"
82
87
  out_path.parent.mkdir(exist_ok=True)
83
88
  out_path.write_text(digest_content, encoding="utf-8")
84
- console.print(f"[dim]Saved to {out_path}[/dim]")
89
+ console.print(f"[dim]💾 Saved to {out_path}[/dim]")
@@ -1,43 +1,15 @@
1
1
  import typer
2
- import tomllib
3
2
  from pathlib import Path
4
3
  from rich.console import Console
5
4
  from rich.progress import Progress, SpinnerColumn, TextColumn
6
5
  from rich.panel import Panel
7
6
  from rich.filesize import decimal
8
7
 
9
- # [변경] resolver 추가
10
- from ..core import scanner, structure, tags, prompts, processor, shadow, tokenizer, resolver
8
+ from ..core import structure, tags, prompts, processor, shadow, tokenizer, common
11
9
 
12
10
  app = typer.Typer()
13
11
  console = Console()
14
12
 
15
- def _load_config_filters(root_path: Path):
16
- config_path = root_path / ".codigest" / "config.toml"
17
- extensions = None
18
- exclude_patterns = []
19
-
20
- if config_path.exists():
21
- try:
22
- with open(config_path, "rb") as f:
23
- data = tomllib.load(f)
24
- filters = data.get("filter", {})
25
- ext_list = filters.get("extensions", [])
26
- if ext_list:
27
- extensions = set(ext_list)
28
- exclude_patterns = filters.get("exclude_patterns", [])
29
- except Exception:
30
- pass
31
-
32
- return extensions, exclude_patterns
33
-
34
- def _find_project_root(start_path: Path) -> Path:
35
- current = start_path.resolve()
36
- for parent in [current] + list(current.parents):
37
- if (parent / ".codigest").exists() or (parent / ".git").exists():
38
- return parent
39
- return start_path
40
-
41
13
  @app.callback(invoke_without_command=True)
42
14
  def handle(
43
15
  targets: list[Path] = typer.Argument(
@@ -51,32 +23,23 @@ def handle(
51
23
  message: str = typer.Option("", "--message", "-m", help="Add specific instruction"),
52
24
  line_numbers: bool = typer.Option(False, "--lines", "-l", help="Add line numbers to code blocks"),
53
25
  yes: bool = typer.Option(False, "-y", "--yes", help="Skip confirmation prompt"),
54
- # [추가] 의존성 해결 옵션
55
26
  resolve: bool = typer.Option(False, "-r", "--resolve", help="Recursively resolve imports for local files"),
56
27
  ):
57
28
  """
58
29
  Scans the codebase.
59
30
  If TARGETS provided, only scans those paths within the project.
60
31
  """
61
- root_path = _find_project_root(Path.cwd())
62
- scan_scope = targets if targets else None
63
-
64
- # Scope validation
65
- if scan_scope:
66
- for p in scan_scope:
67
- if not p.is_relative_to(root_path):
68
- # [수정] 이모티콘 제거
69
- console.print(f"[yellow][Warning] {p.name} is outside project root {root_path.name}[/yellow]")
32
+ # [1] Context Setup (Centralized)
33
+ ctx = common.get_context(targets)
34
+ root_path = ctx.root_path
70
35
 
71
36
  # Init check
72
37
  artifact_dir = root_path / ".codigest"
73
38
  if not artifact_dir.exists():
74
- # [수정] 이모티콘 제거
75
39
  console.print(f"[yellow][Warning] .codigest directory missing in {root_path.name}. Running init...[/yellow]")
76
40
  try:
77
41
  artifact_dir.mkdir(exist_ok=True)
78
42
  except PermissionError:
79
- # [수정] 이모티콘 제거
80
43
  console.print(f"[red][Error] Cannot create .codigest at {root_path}[/red]")
81
44
  raise typer.Exit(1)
82
45
 
@@ -84,83 +47,57 @@ def handle(
84
47
  prompt_engine = prompts.get_engine(root_path)
85
48
  anchor = shadow.ContextAnchor(root_path)
86
49
 
87
- extensions, extra_ignores = (None, [])
88
- if not all:
89
- extensions, extra_ignores = _load_config_filters(root_path)
90
-
91
- # ----------------------------------------------------------------
92
- # 1. Pre-flight Scan (Fast)
93
- # ----------------------------------------------------------------
50
+ # [2] File Discovery via Context
94
51
  with Progress(
95
52
  SpinnerColumn(),
96
- # [수정] 이모티콘 제거
97
- TextColumn("[bold blue]Scanning file structure...[/bold blue]"),
53
+ TextColumn("[bold blue]Scanning...[/bold blue]"),
98
54
  transient=True,
99
55
  console=console
100
56
  ) as progress:
101
57
  task = progress.add_task("scanning", total=None)
102
- files = scanner.scan_project(
103
- root_path,
104
- extensions,
105
- extra_ignores,
106
- include_paths=scan_scope
58
+
59
+ # ★ All logic delegated to common.py
60
+ files = ctx.get_target_files(
61
+ targets=targets,
62
+ ignore_config=all,
63
+ resolve_deps=resolve
107
64
  )
108
65
 
109
- # [추가] 의존성 해결 로직 (Resolver)
110
- if resolve:
111
- progress.update(task, description="[bold blue]Resolving dependencies...[/bold blue]")
112
- initial_count = len(files)
113
- files = resolver.resolve_dependencies(root_path, files)
114
- resolved_count = len(files)
115
- if resolved_count > initial_count:
116
- console.print(f"[dim]Resolved {resolved_count - initial_count} dependency files.[/dim]")
117
-
118
66
  progress.update(task, completed=100)
119
67
 
120
- # Calculate Stats
68
+ # [3] Pre-flight Check
121
69
  total_files = len(files)
122
70
  total_size = sum(f.stat().st_size for f in files)
123
71
  est_tokens = int(total_size / 4)
124
72
 
125
- # Display Plan
126
- # [수정] 이모티콘 제거 및 레이아웃 정리
127
73
  console.print(Panel(f"""[bold]Scan Plan[/bold]
128
74
  Target: [cyan]{root_path}[/cyan]
129
75
  Scope: {total_files} files
130
76
  Est. Size: {decimal(total_size)}
131
77
  Est. Tokens: ~{est_tokens:,}""", expand=False))
132
78
 
133
- # --- Smart Confirmation Logic ---
134
79
  TOKEN_THRESHOLD = 30000
135
80
  FILE_COUNT_THRESHOLD = 100
136
-
137
81
  is_large_context = est_tokens > TOKEN_THRESHOLD or total_files > FILE_COUNT_THRESHOLD
138
82
 
139
83
  if yes:
140
- pass # Explicit override
84
+ pass
141
85
  elif is_large_context:
142
- # [수정] 이모티콘 제거
143
86
  console.print(f"[yellow][Warning] Large context detected (> {TOKEN_THRESHOLD:,} tokens or > {FILE_COUNT_THRESHOLD} files).[/yellow]")
144
- # [수정] 이모티콘 제거
145
87
  if not typer.confirm("Proceed with digestion?"):
146
88
  console.print("[red]Aborted.[/red]")
147
89
  raise typer.Exit()
148
90
  else:
149
- # [수정] 이모티콘 제거
150
91
  console.print("[dim]Small context detected. Automatically proceeding...[/dim]")
151
92
 
152
- # ----------------------------------------------------------------
153
- # 2. Execution (Heavy)
154
- # ----------------------------------------------------------------
93
+ # [4] Execution
155
94
  with Progress(
156
95
  SpinnerColumn(),
157
- # [수정] 이모티콘 제거
158
96
  TextColumn("[bold blue]Generating Snapshot...[/bold blue]"),
159
97
  transient=True,
160
98
  console=console
161
99
  ) as progress:
162
100
 
163
- # Diff Report
164
101
  if anchor.has_history():
165
102
  try:
166
103
  diff_content = anchor.get_changes(files)
@@ -170,16 +107,17 @@ def handle(
170
107
  except Exception:
171
108
  pass
172
109
 
173
- # Tree Generation
174
110
  tree_str = structure.generate_ascii_tree(files, root_path)
175
111
 
176
- # Content Reading & Tagging
177
112
  file_blocks = []
178
113
  for file_path in files:
179
- rel_path = file_path.relative_to(root_path).as_posix()
114
+ try:
115
+ rel_path = file_path.relative_to(root_path).as_posix()
116
+ except ValueError:
117
+ rel_path = f"[EXTERNAL]/{file_path.name}"
118
+
180
119
  try:
181
120
  content = processor.read_file_content(file_path, add_line_numbers=line_numbers)
182
-
183
121
  block = tags.file(rel_path, content)
184
122
  file_blocks.append(block)
185
123
  except Exception:
@@ -187,7 +125,6 @@ def handle(
187
125
 
188
126
  source_code_blob = "\n\n".join(file_blocks)
189
127
 
190
- # Template Rendering
191
128
  try:
192
129
  snapshot_content = prompt_engine.render(
193
130
  "snapshot",
@@ -197,23 +134,18 @@ def handle(
197
134
  instruction=message
198
135
  )
199
136
  except Exception as e:
200
- # [수정] 이모티콘 제거
201
137
  console.print(f"[red][Error] Template Rendering Failed:[/red] {e}")
202
138
  raise typer.Exit(1)
203
139
 
204
- # Final Actions
205
140
  try:
206
141
  anchor.update(files)
207
142
  except Exception as e:
208
- # [수정] 이모티콘 제거
209
143
  console.print(f"[yellow][Warning] Failed to update context anchor: {e}[/yellow]")
210
144
 
211
145
  try:
212
146
  output_path.write_text(snapshot_content, encoding="utf-8")
213
-
214
147
  final_token_count = tokenizer.estimate_tokens(snapshot_content)
215
148
 
216
- # [수정] 이모티콘 제거 및 메시지 단순화
217
149
  console.print("[bold green]Snapshot Saved![/bold green]")
218
150
  console.print(f" Path: [underline]{output_path}[/underline]")
219
151
  console.print(f" Final Tokens: [bold cyan]~{final_token_count:,}[/bold cyan]")
@@ -221,10 +153,8 @@ def handle(
221
153
  if anchor.has_history():
222
154
  pre_diff_path = artifact_dir / "previous_changes.diff"
223
155
  if pre_diff_path.exists() and pre_diff_path.stat().st_size > 0:
224
- # [수정] 이모티콘 제거
225
156
  console.print(f" [dim]Changes before this scan saved to: {pre_diff_path.name}[/dim]")
226
157
 
227
158
  except Exception as e:
228
- # [수정] 이모티콘 제거
229
159
  console.print(f"[bold red][Error] Save Failed:[/bold red] {e}")
230
160
  raise typer.Exit(1)
@@ -4,10 +4,7 @@ from pathlib import Path
4
4
  from rich.console import Console
5
5
  from rich.progress import Progress, SpinnerColumn, TextColumn
6
6
 
7
- from ..core import scanner, prompts, shadow, semdiff, tags, tokenizer
8
-
9
- # Reuse config loader
10
- from .scan import _load_config_filters
7
+ from ..core import prompts, shadow, semdiff, tags, tokenizer, common
11
8
 
12
9
  app = typer.Typer()
13
10
  console = Console()
@@ -18,17 +15,22 @@ def handle(
18
15
  copy: bool = typer.Option(True, help="Auto-copy to clipboard"),
19
16
  save: bool = typer.Option(True, help="Save to .codigest/semdiff.xml"),
20
17
  message: str = typer.Option("", "--message", "-m", help="Add specific instruction"),
18
+ # [추가] resolve 옵션
19
+ resolve: bool = typer.Option(False, "-r", "--resolve", help="Recursively resolve imports"),
21
20
  ):
22
21
  """
23
22
  [Advanced] Generates a Semantic Diff (AST-based) report.
24
23
  Shows ADDED/REMOVED/MODIFIED functions & classes instead of raw text lines.
25
24
  """
26
- root_path = target.resolve()
25
+ # [1] Context Setup
26
+ ctx = common.get_context(target)
27
+ root_path = ctx.root_path
28
+
27
29
  anchor = shadow.ContextAnchor(root_path)
28
30
 
29
31
  last_update = anchor.get_last_update_time()
30
32
  if last_update == "Never":
31
- console.print("[yellow]No scan history found. Run [bold]codigest scan[/bold] first.[/yellow]")
33
+ console.print("[yellow]⚠️ No scan history found. Run [bold]cdg scan[/bold] first.[/yellow]")
32
34
  raise typer.Exit(1)
33
35
 
34
36
  console.print(f"[dim]Analyzing structural changes since ({last_update})...[/dim]")
@@ -43,10 +45,10 @@ def handle(
43
45
  ) as progress:
44
46
  task = progress.add_task("semdiff", total=None)
45
47
 
46
- # 1. Scan files
47
- extensions, extra_ignores = _load_config_filters(root_path)
48
- current_files = scanner.scan_project(root_path, extensions, extra_ignores)
48
+ # [2] Get Files via Context
49
+ current_files = ctx.get_target_files(resolve_deps=resolve)
49
50
 
51
+ # Only check files that have text changes first (Optimization)
50
52
  changed_files_paths = anchor.get_changed_files(list(current_files))
51
53
 
52
54
  target_files = []
@@ -54,58 +56,62 @@ def handle(
54
56
  if p.suffix in (".py", ".pyi"):
55
57
  target_files.append(p)
56
58
 
57
- # 2. Compare Each File
59
+ # [3] Compare Each File
58
60
  for file_path in target_files:
59
- rel_path = file_path.relative_to(root_path)
61
+ # Handle Path String (Support External Files)
62
+ try:
63
+ rel_path = file_path.relative_to(root_path)
64
+ rel_path_str = rel_path.as_posix()
65
+ except ValueError:
66
+ # External files tracked via resolve logic
67
+ rel_path = None # Anchor might not support reading external files yet
68
+ rel_path_str = f"[EXTERNAL]/{file_path.name}"
60
69
 
61
- # Read Content (Handle Missing Files for Deletion)
70
+ # Read New Content
62
71
  try:
63
72
  new_code = file_path.read_text(encoding="utf-8") if file_path.exists() else ""
64
73
  except:
65
74
  new_code = ""
66
-
67
- old_code = anchor.read_anchor_file(rel_path)
68
75
 
69
- # Case: File Added (No old code) -> All symbols ADDED
70
- # Case: File Deleted (No new code) -> All symbols REMOVED
71
- # Case: Modified -> Semantic Compare
76
+ # Read Old Content (From Anchor)
77
+ # Note: Anchor only stores files inside root. External files will likely return empty old_code.
78
+ if rel_path:
79
+ old_code = anchor.read_anchor_file(rel_path)
80
+ else:
81
+ old_code = ""
72
82
 
73
83
  changes = semdiff.compare(old_code, new_code)
74
84
 
75
85
  if changes:
76
86
  change_lines = []
77
87
  for ch in changes:
78
- if ch.change_type == "ADDED":
79
- icon = "➕ [ADDED] "
80
- elif ch.change_type == "REMOVED":
81
- icon = "➖ [REMOVED] "
82
- elif ch.change_type == "MODIFIED":
83
- icon = "⚠️ [SIGNATURE]" # 중요: 시그니처 변경
84
- else:
85
- icon = "✏️ [LOGIC] "
86
-
88
+ if ch.change_type == "ADDED": icon = "➕ [ADDED] "
89
+ elif ch.change_type == "REMOVED": icon = "➖ [REMOVED] "
90
+ elif ch.change_type == "MODIFIED": icon = "⚠️ [SIGNATURE]"
91
+ else: icon = "✏️ [LOGIC] "
92
+
87
93
  detail = f" :: {ch.details}" if ch.details else ""
88
94
  line = f"{icon} {ch.symbol.type} {ch.symbol.name}{ch.symbol.signature}{detail}"
89
95
  change_lines.append(line)
90
96
 
91
- # File Deleted Tagging
97
+ # File Status Tag
92
98
  file_status = ""
93
- if not new_code:
94
- file_status = " (DELETED)"
95
- elif not old_code:
96
- file_status = " (NEW)"
99
+ if not new_code: file_status = " (DELETED)"
100
+ elif not old_code: file_status = " (NEW)"
97
101
 
98
102
  raw_body = chr(10).join(change_lines)
99
- block = tags.file(rel_path.as_posix(), raw_body, status=file_status)
103
+
104
+ # Use unified tags factory
105
+ block = tags.file(rel_path_str, raw_body, status=file_status)
100
106
  reports.append(block)
101
107
 
102
108
  progress.update(task, completed=100)
103
109
 
104
110
  if not reports:
105
- console.print("[green]✨ No structural (AST) changes detected.[/green]")
111
+ console.print("[green]No structural (AST) changes detected.[/green]")
106
112
  return
107
113
 
108
- # Render
114
+ # [4] Render
109
115
  report_content = "\n".join(reports)
110
116
  prompt_engine = prompts.get_engine(root_path)
111
117
 
@@ -122,14 +128,14 @@ def handle(
122
128
  raise typer.Exit(1)
123
129
 
124
130
  token_count = tokenizer.estimate_tokens(final_output)
125
- console.print(f"[bold green]✔ SemDiff Generated![/bold green] ([bold cyan]~{token_count:,} Tokens[/bold cyan])")
131
+ console.print(f"[bold green]SemDiff Generated![/bold green] ([bold cyan]~{token_count:,} Tokens[/bold cyan])")
126
132
 
127
133
  if copy:
128
134
  pyperclip.copy(final_output)
129
- console.print("[dim]Copied to clipboard[/dim]")
135
+ console.print("[dim]Clipboard copied[/dim]")
130
136
 
131
137
  if save:
132
138
  out_path = root_path / ".codigest" / "semdiff.xml"
133
139
  out_path.parent.mkdir(exist_ok=True)
134
140
  out_path.write_text(final_output, encoding="utf-8")
135
- console.print(f"[dim]Saved to {out_path}[/dim]")
141
+ console.print(f"[dim]Saved to {out_path}[/dim]")