codigest 0.3.1__tar.gz → 0.3.2__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 (27) hide show
  1. {codigest-0.3.1 → codigest-0.3.2}/.github/workflows/publish.yml +3 -3
  2. {codigest-0.3.1 → codigest-0.3.2}/PKG-INFO +1 -1
  3. {codigest-0.3.1 → codigest-0.3.2}/pyproject.toml +1 -1
  4. codigest-0.3.2/src/codigest/__main__.py +4 -0
  5. {codigest-0.3.1 → codigest-0.3.2}/src/codigest/commands/diff.py +10 -1
  6. {codigest-0.3.1 → codigest-0.3.2}/src/codigest/commands/digest.py +1 -5
  7. {codigest-0.3.1 → codigest-0.3.2}/src/codigest/commands/init.py +11 -1
  8. {codigest-0.3.1 → codigest-0.3.2}/src/codigest/commands/scan.py +43 -10
  9. {codigest-0.3.1 → codigest-0.3.2}/src/codigest/commands/semdiff.py +20 -16
  10. {codigest-0.3.1 → codigest-0.3.2}/src/codigest/core/prompts.py +89 -78
  11. {codigest-0.3.1 → codigest-0.3.2}/src/codigest/core/scanner.py +40 -8
  12. codigest-0.3.2/src/codigest/core/tags.py +38 -0
  13. {codigest-0.3.1 → codigest-0.3.2}/uv.lock +1 -1
  14. codigest-0.3.1/src/codigest/core/tags.py +0 -75
  15. {codigest-0.3.1 → codigest-0.3.2}/.gitignore +0 -0
  16. {codigest-0.3.1 → codigest-0.3.2}/.python-version +0 -0
  17. {codigest-0.3.1 → codigest-0.3.2}/README.md +0 -0
  18. {codigest-0.3.1 → codigest-0.3.2}/src/codigest/__init__.py +0 -0
  19. {codigest-0.3.1 → codigest-0.3.2}/src/codigest/commands/tree.py +0 -0
  20. {codigest-0.3.1 → codigest-0.3.2}/src/codigest/config.py +0 -0
  21. {codigest-0.3.1 → codigest-0.3.2}/src/codigest/core/git_ops.py +0 -0
  22. {codigest-0.3.1 → codigest-0.3.2}/src/codigest/core/processor.py +0 -0
  23. {codigest-0.3.1 → codigest-0.3.2}/src/codigest/core/semdiff.py +0 -0
  24. {codigest-0.3.1 → codigest-0.3.2}/src/codigest/core/shadow.py +0 -0
  25. {codigest-0.3.1 → codigest-0.3.2}/src/codigest/core/structure.py +0 -0
  26. {codigest-0.3.1 → codigest-0.3.2}/src/codigest/core/tokenizer.py +0 -0
  27. {codigest-0.3.1 → codigest-0.3.2}/src/codigest/main.py +0 -0
@@ -19,15 +19,15 @@ jobs:
19
19
  steps:
20
20
  - uses: actions/checkout@v4
21
21
 
22
- # [변경] uv 설치 (가장 빠르고 확실한 방법)
22
+ # uv 설치 (가장 빠르고 확실한 방법)
23
23
  - name: Install uv
24
24
  uses: astral-sh/setup-uv@v5
25
25
 
26
- # [변경] Python 3.14 설정 (uv 이용)
26
+ # Python 3.14 설정 (uv 이용)
27
27
  - name: Set up Python
28
28
  run: uv python install 3.14
29
29
 
30
- # [변경] 의존성 설치 및 빌드 (uv build 하나로 해결)
30
+ # 의존성 설치 및 빌드 (uv build 하나로 해결)
31
31
  - name: Build package
32
32
  run: uv build
33
33
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codigest
3
- Version: 0.3.1
3
+ Version: 0.3.2
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.1"
3
+ version = "0.3.2"
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"
@@ -0,0 +1,4 @@
1
+ from .main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -12,6 +12,15 @@ console = Console()
12
12
 
13
13
  @app.callback(invoke_without_command=True)
14
14
  def handle(
15
+ # target 인자 추가
16
+ target: Path = typer.Argument(
17
+ Path.cwd(),
18
+ help="Target directory to check diff",
19
+ exists=True,
20
+ file_okay=False,
21
+ dir_okay=True,
22
+ resolve_path=True
23
+ ),
15
24
  copy: bool = typer.Option(True, help="Auto-copy to clipboard"),
16
25
  save: bool = typer.Option(True, help="Save to .codigest/changes.diff"),
17
26
  message: str = typer.Option("", "--message", "-m", help="Add specific instruction context"),
@@ -20,7 +29,7 @@ def handle(
20
29
  [Context Update] Shows changes since the last 'codigest scan'.
21
30
  Useful for updating LLM context without re-uploading everything.
22
31
  """
23
- root_path = Path.cwd()
32
+ root_path = target
24
33
  anchor = shadow.ContextAnchor(root_path)
25
34
 
26
35
  # 1. Check Baseline Existence
@@ -49,11 +49,7 @@ def handle(
49
49
 
50
50
  if summary:
51
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
- """)
52
+ block = tags.file(rel_path, summary)
57
53
  summary_blocks.append(block)
58
54
  except Exception:
59
55
  continue
@@ -35,12 +35,22 @@ structure = "toon"
35
35
 
36
36
  @app.callback(invoke_without_command=True)
37
37
  def handle(
38
+ # target 인자 추가 (기본값: 현재 위치)
39
+ target: Path = typer.Argument(
40
+ Path.cwd(),
41
+ help="Target directory to initialize",
42
+ exists=True,
43
+ file_okay=False,
44
+ dir_okay=True,
45
+ resolve_path=True
46
+ ),
38
47
  force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing config"),
39
48
  ):
40
49
  """
41
50
  Initialize the .codigest environment and capture the initial state.
42
51
  """
43
- root_path = Path.cwd()
52
+ root_path = target # Typer가 이미 resolve_path=True로 절대경로 변환해줌
53
+
44
54
  config_dir = root_path / ".codigest"
45
55
  config_file = config_dir / "config.toml"
46
56
  gitignore_file = root_path / ".gitignore"
@@ -28,22 +28,52 @@ def _load_config_filters(root_path: Path):
28
28
 
29
29
  return extensions, exclude_patterns
30
30
 
31
+ def _find_project_root(start_path: Path) -> Path:
32
+ """
33
+ .codigest 폴더나 .git 폴더가 있는 상위 디렉토리를 찾습니다.
34
+ 못 찾으면 start_path를 반환합니다.
35
+ """
36
+ current = start_path.resolve()
37
+ for parent in [current] + list(current.parents):
38
+ if (parent / ".codigest").exists() or (parent / ".git").exists():
39
+ return parent
40
+ return start_path
41
+
31
42
  @app.callback(invoke_without_command=True)
32
43
  def handle(
33
- target: Path = typer.Argument(Path.cwd(), help="Target directory"),
44
+ # targets를 리스트로 받음 (여러 폴더 지정 가능)
45
+ targets: list[Path] = typer.Argument(
46
+ None,
47
+ help="Specific files or directories to scan (Scope)",
48
+ exists=True,
49
+ resolve_path=True
50
+ ),
34
51
  output: str = typer.Option("snapshot.xml", help="Output filename inside .codigest/"),
35
52
  all: bool = typer.Option(False, "--all", "-a", help="Ignore config filters"),
36
53
  message: str = typer.Option("", "--message", "-m", help="Add specific instruction"),
37
54
  line_numbers: bool = typer.Option(False, "--lines", "-l", help="Add line numbers to code blocks"),
38
55
  ):
39
56
  """
40
- [Legacy Mode] Scans the codebase using the PromptEngine.
57
+ Scans the codebase.
58
+ If TARGETS provided, only scans those paths within the project.
41
59
  """
42
- root_path = target.resolve()
60
+ # 1. 프로젝트 루트 결정 (현재 위치 기준 상위 탐색)
61
+ root_path = _find_project_root(Path.cwd())
62
+
63
+ # 2. Scope 설정
64
+ # 입력된 targets가 있으면 사용, 없으면 [root_path] 전체
65
+ scan_scope = targets if targets else None
66
+
67
+ # 검증: targets가 root_path 밖에 있으면 경고 (옵션)
68
+ if scan_scope:
69
+ for p in scan_scope:
70
+ if not p.is_relative_to(root_path):
71
+ console.print(f"[yellow]⚠️ Warning: {p.name} is outside project root {root_path.name}[/yellow]")
72
+
43
73
  artifact_dir = root_path / ".codigest"
44
74
 
45
75
  if not artifact_dir.exists():
46
- console.print("[yellow]⚠️ .codigest directory missing. Running init...[/yellow]")
76
+ console.print(f"[yellow]⚠️ .codigest directory missing in {root_path.name}. Running init...[/yellow]")
47
77
  try:
48
78
  artifact_dir.mkdir(exist_ok=True)
49
79
  except PermissionError:
@@ -68,7 +98,12 @@ def handle(
68
98
  ):
69
99
 
70
100
  # File Discovery
71
- files = scanner.scan_project(root_path, extensions, extra_ignores)
101
+ files = scanner.scan_project(
102
+ root_path,
103
+ extensions,
104
+ extra_ignores,
105
+ include_paths=scan_scope
106
+ )
72
107
 
73
108
  # Pre-scan Diff Report
74
109
  if anchor.has_history():
@@ -89,11 +124,9 @@ def handle(
89
124
  rel_path = file_path.relative_to(root_path).as_posix()
90
125
  try:
91
126
  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
- """)
127
+
128
+
129
+ block = tags.file(rel_path, content)
97
130
  file_blocks.append(block)
98
131
  except Exception:
99
132
  continue
@@ -61,38 +61,42 @@ def handle(
61
61
  # Read Content (Handle Missing Files for Deletion)
62
62
  try:
63
63
  new_code = file_path.read_text(encoding="utf-8") if file_path.exists() else ""
64
- except: new_code = ""
64
+ except:
65
+ new_code = ""
65
66
 
66
67
  old_code = anchor.read_anchor_file(rel_path)
67
68
 
68
69
  # Case: File Added (No old code) -> All symbols ADDED
69
70
  # Case: File Deleted (No new code) -> All symbols REMOVED
70
71
  # Case: Modified -> Semantic Compare
71
-
72
+
72
73
  changes = semdiff.compare(old_code, new_code)
73
-
74
+
74
75
  if changes:
75
76
  change_lines = []
76
77
  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] " # 내부 로직 변경
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] "
81
86
 
82
87
  detail = f" :: {ch.details}" if ch.details else ""
83
88
  line = f"{icon} {ch.symbol.type} {ch.symbol.name}{ch.symbol.signature}{detail}"
84
89
  change_lines.append(line)
85
-
90
+
86
91
  # File Deleted Tagging
87
92
  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
- """)
93
+ if not new_code:
94
+ file_status = " (DELETED)"
95
+ elif not old_code:
96
+ file_status = " (NEW)"
97
+
98
+ raw_body = chr(10).join(change_lines)
99
+ block = tags.file(rel_path.as_posix(), raw_body, status=file_status)
96
100
  reports.append(block)
97
101
 
98
102
  progress.update(task, completed=100)
@@ -1,6 +1,6 @@
1
1
  """
2
2
  Prompt Management Module.
3
- Philosophy: Defaults are Code (t-strings), Overrides are Data (TOML).
3
+ Templates are aligned flush-left to prevent indentation issues when injecting large code blocks.
4
4
  """
5
5
  import tomllib
6
6
  from pathlib import Path
@@ -14,102 +14,111 @@ RenderFunc = Callable[..., str]
14
14
  def _default_snapshot(project_name: str, tree_structure: str, source_code: str, instruction: str = "") -> str:
15
15
  instruction_block = ""
16
16
  if instruction:
17
+ safe_instruction = tags.escape_xml_value(instruction)
17
18
  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
- """)
19
+ <user_instruction>
20
+ {safe_instruction}
21
+ </user_instruction>
22
+ """)
23
+
24
+ # Note: source_code is assumed to be already formatted via tags.file()
25
+ return tags.dedent(f"""
26
+ [SYSTEM: CODIGEST - INITIAL CONTEXT]
27
+ You are an expert AI developer. I am providing the full context of a project named "{project_name}".
28
+ Please digest this structure and code to build your internal mental model.
29
+ {instruction_block}
30
+
31
+ <project_root>
32
+ {project_name}
33
+ </project_root>
34
+
35
+ <project_structure>
36
+ {tree_structure}
37
+ </project_structure>
38
+
39
+ <source_code>
40
+ {source_code}
41
+ </source_code>
42
+ """)
41
43
 
42
44
  # 2. Diff Template (codigest diff)
43
45
  def _default_diff(project_name: str, context_message: str, diff_content: str, instruction: str = "") -> str:
44
46
  instruction_block = ""
45
47
  if instruction:
48
+ safe_instruction = tags.escape_xml_value(instruction)
46
49
  instruction_block = tags.dedent(f"""
47
- <user_instruction>
48
- {instruction}
49
- </user_instruction>
50
- """)
50
+ <user_instruction>
51
+ {safe_instruction}
52
+ </user_instruction>
53
+ """)
51
54
 
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}
55
+ # Escape raw diff content to prevent XML injection
56
+ safe_diff = tags.escape_xml_value(diff_content)
57
57
 
58
- Focus on these modifications to update your context.
58
+ return tags.dedent(f"""
59
+ [SYSTEM: CODIGEST - INCREMENTAL UPDATE]
60
+ Here are the latest changes for the project "{project_name}".
61
+ {context_message}.
62
+ {instruction_block}
59
63
 
60
- <git_diff>
61
- {diff_content}
62
- </git_diff>
63
- """)
64
+ Focus on these modifications to update your context.
65
+
66
+ <git_diff>
67
+ {safe_diff}
68
+ </git_diff>
69
+ """)
64
70
 
65
71
  # 3. SemDiff Template (codigest semdiff)
66
72
  def _default_semdiff(project_name: str, context_message: str, semdiff_content: str, instruction: str = "") -> str:
67
73
  instruction_block = ""
68
74
  if instruction:
75
+ safe_instruction = tags.escape_xml_value(instruction)
69
76
  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
- """)
77
+ <user_instruction>
78
+ {safe_instruction}
79
+ </user_instruction>
80
+ """)
81
+
82
+ return tags.dedent(f"""
83
+ [SYSTEM: CODIGEST - SEMANTIC ANALYSIS]
84
+ Structural changes detected in "{project_name}".
85
+ {context_message}.
86
+ {instruction_block}
87
+
88
+ This report focuses on High-Level Architecture changes (Classes, Functions, Signatures).
89
+
90
+ <semantic_diff>
91
+ {semdiff_content}
92
+ </semantic_diff>
93
+ """)
87
94
 
88
- # 4. Digest Template (codigest digest) - [Missing Part Fix]
95
+ # 4. Digest Template (codigest digest)
89
96
  def _default_digest(project_name: str, tree_structure: str, digest_content: str, instruction: str = "") -> str:
90
97
  instruction_block = ""
91
98
  if instruction:
99
+ safe_instruction = tags.escape_xml_value(instruction)
92
100
  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
- """)
101
+ <user_instruction>
102
+ {safe_instruction}
103
+ </user_instruction>
104
+ """)
105
+
106
+ return tags.dedent(f"""
107
+ [SYSTEM: CODIGEST - ARCHITECTURE DIGEST]
108
+ Here is the high-level architecture of "{project_name}".
109
+ This contains ONLY definitions (Classes, Functions), no implementation details.
110
+ Use this to understand the project structure and relationships.
111
+ {instruction_block}
112
+
113
+ <project_structure>
114
+ {tree_structure}
115
+ </project_structure>
116
+
117
+ <code_digest>
118
+ {digest_content}
119
+ </code_digest>
120
+ """)
121
+
113
122
 
114
123
  # Registry of default implementations
115
124
  # Ensure ALL keys correspond to prompt_engine.render calls
@@ -117,7 +126,7 @@ DEFAULT_RENDERERS: Dict[str, RenderFunc] = {
117
126
  "snapshot": _default_snapshot,
118
127
  "diff": _default_diff,
119
128
  "semdiff": _default_semdiff,
120
- "digest": _default_digest, # <--- 이 부분이 누락되었을 수 있습니다.
129
+ "digest": _default_digest,
121
130
  }
122
131
 
123
132
  class PromptEngine:
@@ -146,6 +155,7 @@ class PromptEngine:
146
155
  # 1. Check for TOML Override (String-based)
147
156
  if key in self.overrides:
148
157
  raw_template = self.overrides[key]
158
+ # Safety: Automatically escape values when using user-defined templates
149
159
  safe_kwargs = {
150
160
  k: tags.escape_xml_value(v) for k, v in kwargs.items()
151
161
  }
@@ -156,9 +166,10 @@ class PromptEngine:
156
166
 
157
167
  # 2. Use Default (Code-based t-string)
158
168
  if key in DEFAULT_RENDERERS:
169
+ # Default functions handle their own escaping logic internally where needed
159
170
  return DEFAULT_RENDERERS[key](**kwargs)
160
171
 
161
172
  return f"Error: Prompt template '{key}' not found."
162
173
 
163
174
  def get_engine(root_path: Path) -> PromptEngine:
164
- return PromptEngine(root_path)
175
+ return PromptEngine(root_path)
@@ -16,11 +16,18 @@ ALWAYS_IGNORE = [
16
16
  ]
17
17
 
18
18
  class ProjectScanner:
19
- def __init__(self, root_path: Path, extensions: Optional[set[str]] = None, extra_ignores: Optional[list[str]] = None):
19
+ def __init__(
20
+ self,
21
+ root_path: Path,
22
+ extensions: Optional[set[str]] = None,
23
+ extra_ignores: Optional[list[str]] = None,
24
+ include_paths: Optional[list[Path]] = None
25
+ ):
20
26
  self.root_path = root_path
21
27
  self.extensions = extensions
22
28
  self.extra_ignores = extra_ignores or []
23
29
  self.ignore_spec = self._load_gitignore()
30
+ self.include_paths = include_paths
24
31
 
25
32
  def _load_gitignore(self) -> pathspec.PathSpec:
26
33
  """Loads .gitignore and combines with ALWAYS_IGNORE and extra_ignores."""
@@ -56,17 +63,36 @@ class ProjectScanner:
56
63
 
57
64
  return self.ignore_spec.match_file(rel_path)
58
65
 
66
+ def _is_included(self, entry: Path) -> bool:
67
+ """파일이 지정된 include_paths 범위 안에 있는지 확인"""
68
+ if not self.include_paths:
69
+ return True
70
+
71
+ for path in self.include_paths:
72
+ try:
73
+ entry.relative_to(path)
74
+ return True
75
+ except ValueError:
76
+ continue
77
+ return False
78
+
59
79
  def scan(self) -> list[Path]:
60
80
  """
61
81
  Walks the directory tree and returns valid files.
62
82
  (Modernized from old core.py's stack-based approach)
63
83
  """
64
84
  valid_files = []
65
-
85
+ start_dirs = self.include_paths if self.include_paths else [self.root_path]
66
86
  # Use rglob for simplicity, but we must manually filter dirs to respect gitignore
67
87
  # For better performance on large repos, we use a manual walk similar to the old code
68
- dirs_stack = [self.root_path]
69
-
88
+ dirs_stack = []
89
+ for p in start_dirs:
90
+ if p.is_file():
91
+ if not self.is_ignored(p):
92
+ valid_files.append(p)
93
+ elif p.is_dir():
94
+ dirs_stack.append(p)
95
+
70
96
  while dirs_stack:
71
97
  current = dirs_stack.pop()
72
98
 
@@ -83,6 +109,7 @@ class ProjectScanner:
83
109
 
84
110
  if entry.is_dir():
85
111
  dirs_stack.append(entry)
112
+
86
113
  elif entry.is_file():
87
114
  # Extension Filter Check
88
115
  if self.extensions and entry.suffix.lower() not in self.extensions:
@@ -91,9 +118,14 @@ class ProjectScanner:
91
118
  continue
92
119
 
93
120
  valid_files.append(entry)
94
-
95
- return sorted(valid_files)
96
121
 
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)
122
+ return sorted(list(set(valid_files)))
123
+
124
+ def scan_project(
125
+ root_path: Path,
126
+ extensions: set[str] = None,
127
+ extra_ignores: list[str] = None,
128
+ include_paths: list[Path] = None
129
+ ) -> list[Path]:
130
+ scanner = ProjectScanner(root_path, extensions, extra_ignores, include_paths)
99
131
  return scanner.scan()
@@ -0,0 +1,38 @@
1
+ """
2
+ Core Template Engine & XML Factory.
3
+ Centralizes XML escaping and block generation logic.
4
+ """
5
+ import html
6
+ import textwrap
7
+ from typing import Any
8
+
9
+ def escape_xml_value(value: Any) -> str:
10
+ """[XML] Escapes characters for XML safety."""
11
+ raw_value = str(value)
12
+ safe_value = html.escape(raw_value, quote=False)
13
+ if "</" in safe_value:
14
+ safe_value = safe_value.replace("</", "&lt;/")
15
+ return safe_value
16
+
17
+ def dedent(template: str) -> str:
18
+ """[Plain] Removes common leading whitespace."""
19
+ if not isinstance(template, str):
20
+ template = str(template)
21
+ return textwrap.dedent(template.expandtabs(4)).strip()
22
+
23
+ def xml(template: str) -> str:
24
+ """[XML] Wrapper for dedent (legacy support)."""
25
+ return dedent(template)
26
+
27
+ # 파일 블록 생성기 (이스케이프 자동화)
28
+ def file(path: str, content: str, status: str = "") -> str:
29
+ """
30
+ Generates a safe <file> block.
31
+ Automatically escapes the content to prevent XML injection.
32
+ """
33
+ safe_content = escape_xml_value(content)
34
+
35
+ # f-string을 왼쪽 벽에 붙여 들여쓰기 문제 원천 차단
36
+ return f"""<file path="{path}{status}">
37
+ {safe_content}
38
+ </file>"""
@@ -16,7 +16,7 @@ wheels = [
16
16
 
17
17
  [[package]]
18
18
  name = "codigest"
19
- version = "0.3.0"
19
+ version = "0.3.1"
20
20
  source = { editable = "." }
21
21
  dependencies = [
22
22
  { name = "loguru" },
@@ -1,75 +0,0 @@
1
- """
2
- Core Template Engine using Python Tag Strings.
3
- Advanced Logic: Structure-Aware Dedent & XML Safety.
4
- """
5
- import html
6
- import textwrap
7
- from typing import Any, Callable
8
-
9
- def _get_common_indent(parts: list[str]) -> int:
10
- """Calculates minimum indentation from static template parts."""
11
- min_indent = None
12
- for part in parts:
13
- for line in part.splitlines():
14
- if not line.strip():
15
- continue
16
- indent = len(line) - len(line.lstrip())
17
- if min_indent is None or indent < min_indent:
18
- min_indent = indent
19
- return min_indent or 0
20
-
21
- def _dedent_line(line: str, indent: int) -> str:
22
- """
23
- Safely removes indentation from a single line.
24
- """
25
- if not line.strip():
26
- return "\n" if line.endswith("\n") else ""
27
-
28
- if line.startswith(" " * indent):
29
- return line[indent:]
30
-
31
- return line.lstrip(" ")
32
-
33
- def escape_xml_value(value: Any) -> str:
34
- """[XML] Escapes characters for XML safety."""
35
- raw_value = str(value)
36
- safe_value = html.escape(raw_value, quote=False)
37
- if "</" in safe_value:
38
- safe_value = safe_value.replace("</", "&lt;/")
39
- return safe_value
40
-
41
- def no_escape(value: Any) -> str:
42
- """[Plain] Returns string as-is (for dedent)."""
43
- return str(value)
44
-
45
- def _render_structure_aware(template: Any, escape_fn: Callable[[Any], str]) -> str:
46
- """
47
- Core Logic: Dedents based on static structure, then injects processed values.
48
- """
49
- if isinstance(template, str):
50
- return textwrap.dedent(template).strip()
51
-
52
- static_parts = [p for p in template if isinstance(p, str)]
53
- common_indent = _get_common_indent(static_parts)
54
-
55
- result = []
56
-
57
- for part in template:
58
- if isinstance(part, str):
59
- # Dedent static parts
60
- lines = part.splitlines(keepends=True)
61
- dedented_lines = [_dedent_line(line, common_indent) for line in lines]
62
- result.append("".join(dedented_lines))
63
- else:
64
- # Inject dynamic value
65
- result.append(escape_fn(part.value))
66
-
67
- return "".join(result).lstrip()
68
-
69
- def xml(template: Any) -> str:
70
- """[Python 3.14 Native Tag] XML Generator."""
71
- return _render_structure_aware(template, escape_xml_value)
72
-
73
- def dedent(template: Any) -> str:
74
- """[Python 3.14 Native Tag] Plain Text Generator."""
75
- return _render_structure_aware(template, no_escape)
File without changes
File without changes
File without changes
File without changes