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.
- {codigest-0.3.1 → codigest-0.3.2}/.github/workflows/publish.yml +3 -3
- {codigest-0.3.1 → codigest-0.3.2}/PKG-INFO +1 -1
- {codigest-0.3.1 → codigest-0.3.2}/pyproject.toml +1 -1
- codigest-0.3.2/src/codigest/__main__.py +4 -0
- {codigest-0.3.1 → codigest-0.3.2}/src/codigest/commands/diff.py +10 -1
- {codigest-0.3.1 → codigest-0.3.2}/src/codigest/commands/digest.py +1 -5
- {codigest-0.3.1 → codigest-0.3.2}/src/codigest/commands/init.py +11 -1
- {codigest-0.3.1 → codigest-0.3.2}/src/codigest/commands/scan.py +43 -10
- {codigest-0.3.1 → codigest-0.3.2}/src/codigest/commands/semdiff.py +20 -16
- {codigest-0.3.1 → codigest-0.3.2}/src/codigest/core/prompts.py +89 -78
- {codigest-0.3.1 → codigest-0.3.2}/src/codigest/core/scanner.py +40 -8
- codigest-0.3.2/src/codigest/core/tags.py +38 -0
- {codigest-0.3.1 → codigest-0.3.2}/uv.lock +1 -1
- codigest-0.3.1/src/codigest/core/tags.py +0 -75
- {codigest-0.3.1 → codigest-0.3.2}/.gitignore +0 -0
- {codigest-0.3.1 → codigest-0.3.2}/.python-version +0 -0
- {codigest-0.3.1 → codigest-0.3.2}/README.md +0 -0
- {codigest-0.3.1 → codigest-0.3.2}/src/codigest/__init__.py +0 -0
- {codigest-0.3.1 → codigest-0.3.2}/src/codigest/commands/tree.py +0 -0
- {codigest-0.3.1 → codigest-0.3.2}/src/codigest/config.py +0 -0
- {codigest-0.3.1 → codigest-0.3.2}/src/codigest/core/git_ops.py +0 -0
- {codigest-0.3.1 → codigest-0.3.2}/src/codigest/core/processor.py +0 -0
- {codigest-0.3.1 → codigest-0.3.2}/src/codigest/core/semdiff.py +0 -0
- {codigest-0.3.1 → codigest-0.3.2}/src/codigest/core/shadow.py +0 -0
- {codigest-0.3.1 → codigest-0.3.2}/src/codigest/core/structure.py +0 -0
- {codigest-0.3.1 → codigest-0.3.2}/src/codigest/core/tokenizer.py +0 -0
- {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
|
-
#
|
|
22
|
+
# uv 설치 (가장 빠르고 확실한 방법)
|
|
23
23
|
- name: Install uv
|
|
24
24
|
uses: astral-sh/setup-uv@v5
|
|
25
25
|
|
|
26
|
-
#
|
|
26
|
+
# Python 3.14 설정 (uv 이용)
|
|
27
27
|
- name: Set up Python
|
|
28
28
|
run: uv python install 3.14
|
|
29
29
|
|
|
30
|
-
#
|
|
30
|
+
# 의존성 설치 및 빌드 (uv build 하나로 해결)
|
|
31
31
|
- name: Build package
|
|
32
32
|
run: uv build
|
|
33
33
|
|
|
@@ -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 =
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
57
|
+
Scans the codebase.
|
|
58
|
+
If TARGETS provided, only scans those paths within the project.
|
|
41
59
|
"""
|
|
42
|
-
|
|
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(
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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:
|
|
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":
|
|
78
|
-
|
|
79
|
-
elif ch.change_type == "
|
|
80
|
-
|
|
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:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
<user_instruction>
|
|
51
|
+
{safe_instruction}
|
|
52
|
+
</user_instruction>
|
|
53
|
+
""")
|
|
51
54
|
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return tags.dedent(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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)
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return tags.dedent(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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__(
|
|
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 = [
|
|
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
|
-
|
|
98
|
-
|
|
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("</", "</")
|
|
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>"""
|
|
@@ -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("</", "</")
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|