lgit-cli 3.7.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.
- lgit/__init__.py +75 -0
- lgit/__main__.py +8 -0
- lgit/analysis.py +326 -0
- lgit/api.py +1077 -0
- lgit/cache.py +338 -0
- lgit/changelog.py +523 -0
- lgit/cli.py +1104 -0
- lgit/compose.py +2110 -0
- lgit/config.py +437 -0
- lgit/diffing.py +384 -0
- lgit/errors.py +137 -0
- lgit/git.py +852 -0
- lgit/map_reduce.py +508 -0
- lgit/markdown_output.py +709 -0
- lgit/models.py +924 -0
- lgit/normalization.py +411 -0
- lgit/patch.py +784 -0
- lgit/profile.py +426 -0
- lgit/py.typed +0 -0
- lgit/repo.py +287 -0
- lgit/resources/__init__.py +1 -0
- lgit/resources/commit_types.json +242 -0
- lgit/resources/prompts/analysis/default.md +237 -0
- lgit/resources/prompts/analysis/markdown.md +112 -0
- lgit/resources/prompts/changelog/default.md +89 -0
- lgit/resources/prompts/changelog/markdown.md +60 -0
- lgit/resources/prompts/compose-bind/default.md +40 -0
- lgit/resources/prompts/compose-bind/markdown.md +41 -0
- lgit/resources/prompts/compose-intent/default.md +63 -0
- lgit/resources/prompts/compose-intent/markdown.md +59 -0
- lgit/resources/prompts/fast/default.md +46 -0
- lgit/resources/prompts/fast/markdown.md +51 -0
- lgit/resources/prompts/map/default.md +67 -0
- lgit/resources/prompts/map/markdown.md +63 -0
- lgit/resources/prompts/reduce/default.md +81 -0
- lgit/resources/prompts/reduce/markdown.md +68 -0
- lgit/resources/prompts/summary/default.md +74 -0
- lgit/resources/prompts/summary/markdown.md +77 -0
- lgit/resources/validation_data.json +1 -0
- lgit/rewrite.py +392 -0
- lgit/style.py +295 -0
- lgit/templates.py +385 -0
- lgit/testing/__init__.py +62 -0
- lgit/testing/compare.py +57 -0
- lgit/testing/fixture.py +386 -0
- lgit/testing/report.py +201 -0
- lgit/testing/runner.py +256 -0
- lgit/tokens.py +90 -0
- lgit/validation.py +545 -0
- lgit_cli-3.7.0.dist-info/METADATA +288 -0
- lgit_cli-3.7.0.dist-info/RECORD +54 -0
- lgit_cli-3.7.0.dist-info/WHEEL +4 -0
- lgit_cli-3.7.0.dist-info/entry_points.txt +2 -0
- lgit_cli-3.7.0.dist-info/licenses/LICENSE +21 -0
lgit/templates.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Prompt template loading and rendering helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from jinja2 import Environment
|
|
13
|
+
|
|
14
|
+
from .errors import ConfigError
|
|
15
|
+
|
|
16
|
+
USER_SEPARATOR_MARKER = "======USER======="
|
|
17
|
+
PROMPT_CATEGORIES = (
|
|
18
|
+
"analysis",
|
|
19
|
+
"summary",
|
|
20
|
+
"changelog",
|
|
21
|
+
"map",
|
|
22
|
+
"reduce",
|
|
23
|
+
"fast",
|
|
24
|
+
"compose-intent",
|
|
25
|
+
"compose-bind",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_ENV = Environment(autoescape=False, keep_trailing_newline=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class PromptParts:
|
|
33
|
+
"""Rendered prompt split into static system text and rendered user text."""
|
|
34
|
+
|
|
35
|
+
system: str
|
|
36
|
+
user: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class AnalysisParams:
|
|
41
|
+
"""Parameters for rendering the analysis prompt."""
|
|
42
|
+
|
|
43
|
+
variant: str = "default"
|
|
44
|
+
stat: str = ""
|
|
45
|
+
diff: str = ""
|
|
46
|
+
scope_candidates: str = ""
|
|
47
|
+
recent_commits: str | None = None
|
|
48
|
+
common_scopes: str | None = None
|
|
49
|
+
types_description: str | None = None
|
|
50
|
+
project_context: str | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class MapFile:
|
|
55
|
+
"""One file diff passed to the map prompt."""
|
|
56
|
+
|
|
57
|
+
path: str
|
|
58
|
+
diff: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class ComposeIntentPromptParams:
|
|
63
|
+
"""Parameters for rendering the compose-intent prompt."""
|
|
64
|
+
|
|
65
|
+
variant: str = "default"
|
|
66
|
+
max_commits: int = 1
|
|
67
|
+
stat: str = ""
|
|
68
|
+
snapshot_summary: str = ""
|
|
69
|
+
planning_targets: str = ""
|
|
70
|
+
planning_notes: str = ""
|
|
71
|
+
split_bias: str = ""
|
|
72
|
+
types_description: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class ComposeBindPromptParams:
|
|
77
|
+
"""Parameters for rendering the compose-bind prompt."""
|
|
78
|
+
|
|
79
|
+
variant: str = "default"
|
|
80
|
+
groups: str = ""
|
|
81
|
+
ambiguous_files: str = ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class FastPromptParams:
|
|
86
|
+
"""Parameters for rendering the fast-mode prompt."""
|
|
87
|
+
|
|
88
|
+
variant: str = "default"
|
|
89
|
+
stat: str = ""
|
|
90
|
+
diff: str = ""
|
|
91
|
+
scope_candidates: str = ""
|
|
92
|
+
user_context: str | None = None
|
|
93
|
+
types_description: str | None = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_user_prompts_dir() -> Path | None:
|
|
97
|
+
"""Return the user prompt override directory, if a home directory is known."""
|
|
98
|
+
home = os.environ.get("HOME") or os.environ.get("USERPROFILE")
|
|
99
|
+
if not home:
|
|
100
|
+
return None
|
|
101
|
+
return Path(home).joinpath(".llm-git", "prompts")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def ensure_prompts_dir() -> Path | None:
|
|
105
|
+
"""Create the user prompt directory and unpack package prompt files."""
|
|
106
|
+
user_prompts_dir = get_user_prompts_dir()
|
|
107
|
+
if user_prompts_dir is None:
|
|
108
|
+
return None
|
|
109
|
+
try:
|
|
110
|
+
user_prompts_dir.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
except OSError as exc:
|
|
112
|
+
raise ConfigError(f"Failed to create prompts directory {user_prompts_dir}: {exc}") from exc
|
|
113
|
+
|
|
114
|
+
for relative_path, content in _iter_package_prompt_files():
|
|
115
|
+
destination = user_prompts_dir.joinpath(relative_path)
|
|
116
|
+
try:
|
|
117
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
if not destination.exists():
|
|
119
|
+
destination.write_text(content, encoding="utf-8")
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
raise ConfigError(f"Failed to write prompt file {destination}: {exc}") from exc
|
|
122
|
+
return user_prompts_dir
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def split_prompt_template(template_content: str) -> tuple[str | None, str]:
|
|
126
|
+
"""Split a prompt into optional system text and templated user text."""
|
|
127
|
+
separator = _find_user_separator(template_content)
|
|
128
|
+
if separator is None:
|
|
129
|
+
return None, template_content
|
|
130
|
+
system_end, user_start = separator
|
|
131
|
+
return template_content[:system_end], template_content[user_start:]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def render_prompt_parts(
|
|
135
|
+
template_name: str,
|
|
136
|
+
template_content: str,
|
|
137
|
+
context: Mapping[str, Any],
|
|
138
|
+
) -> PromptParts:
|
|
139
|
+
"""Render one prompt while enforcing a static system section."""
|
|
140
|
+
system_template, user_template = split_prompt_template(template_content)
|
|
141
|
+
system = ""
|
|
142
|
+
if system_template is not None:
|
|
143
|
+
_ensure_static_system_prompt(system_template, template_name)
|
|
144
|
+
system = system_template.strip()
|
|
145
|
+
try:
|
|
146
|
+
rendered_user = _ENV.from_string(user_template).render(**context)
|
|
147
|
+
except Exception as exc: # jinja2 has several template-specific subclasses.
|
|
148
|
+
raise ConfigError(f"Failed to render {template_name} prompt template: {exc}") from exc
|
|
149
|
+
return PromptParts(system=system, user=rendered_user.strip())
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def render_analysis_prompt(params: AnalysisParams | None = None, **kwargs: Any) -> PromptParts:
|
|
153
|
+
"""Render the analysis prompt variant."""
|
|
154
|
+
p = params if params is not None else AnalysisParams(**kwargs)
|
|
155
|
+
template_content = load_template_file("analysis", p.variant)
|
|
156
|
+
context = {
|
|
157
|
+
"stat": p.stat,
|
|
158
|
+
"diff": p.diff,
|
|
159
|
+
"scope_candidates": p.scope_candidates,
|
|
160
|
+
"recent_commits": p.recent_commits,
|
|
161
|
+
"common_scopes": p.common_scopes,
|
|
162
|
+
"types_description": p.types_description,
|
|
163
|
+
"project_context": p.project_context,
|
|
164
|
+
}
|
|
165
|
+
return render_prompt_parts(f"analysis/{p.variant}.md", template_content, context)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def render_summary_prompt(
|
|
169
|
+
variant: str,
|
|
170
|
+
commit_type: str,
|
|
171
|
+
scope: str,
|
|
172
|
+
chars: str,
|
|
173
|
+
details: str,
|
|
174
|
+
stat: str,
|
|
175
|
+
user_context: str | None = None,
|
|
176
|
+
) -> PromptParts:
|
|
177
|
+
"""Render the summary prompt variant."""
|
|
178
|
+
template_content = load_template_file("summary", variant)
|
|
179
|
+
context = {
|
|
180
|
+
"commit_type": commit_type,
|
|
181
|
+
"scope": scope,
|
|
182
|
+
"chars": chars,
|
|
183
|
+
"details": details,
|
|
184
|
+
"stat": stat,
|
|
185
|
+
"user_context": user_context,
|
|
186
|
+
}
|
|
187
|
+
return render_prompt_parts(f"summary/{variant}.md", template_content, context)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def render_changelog_prompt(
|
|
191
|
+
variant: str,
|
|
192
|
+
changelog_path: str,
|
|
193
|
+
is_package_changelog: bool,
|
|
194
|
+
stat: str,
|
|
195
|
+
diff: str,
|
|
196
|
+
existing_entries: str | None = None,
|
|
197
|
+
) -> PromptParts:
|
|
198
|
+
"""Render the changelog prompt variant."""
|
|
199
|
+
template_content = load_template_file("changelog", variant)
|
|
200
|
+
context = {
|
|
201
|
+
"changelog_path": changelog_path,
|
|
202
|
+
"is_package_changelog": is_package_changelog,
|
|
203
|
+
"stat": stat,
|
|
204
|
+
"diff": diff,
|
|
205
|
+
"existing_entries": existing_entries,
|
|
206
|
+
}
|
|
207
|
+
return render_prompt_parts(f"changelog/{variant}.md", template_content, context)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def render_map_prompt(
|
|
211
|
+
variant: str,
|
|
212
|
+
files: Iterable[MapFile | Mapping[str, str]],
|
|
213
|
+
context_header: str = "",
|
|
214
|
+
) -> PromptParts:
|
|
215
|
+
"""Render the map prompt for batched file-observation extraction."""
|
|
216
|
+
template_content = load_template_file("map", variant)
|
|
217
|
+
context = {
|
|
218
|
+
"files": [_mapping_for_file(file) for file in files],
|
|
219
|
+
"context_header": context_header,
|
|
220
|
+
}
|
|
221
|
+
return render_prompt_parts(f"map/{variant}.md", template_content, context)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def render_reduce_prompt(
|
|
225
|
+
variant: str,
|
|
226
|
+
observations: str,
|
|
227
|
+
stat: str,
|
|
228
|
+
scope_candidates: str,
|
|
229
|
+
types_description: str | None = None,
|
|
230
|
+
) -> PromptParts:
|
|
231
|
+
"""Render the reduce prompt for synthesizing map observations."""
|
|
232
|
+
template_content = load_template_file("reduce", variant)
|
|
233
|
+
context = {
|
|
234
|
+
"observations": observations,
|
|
235
|
+
"stat": stat,
|
|
236
|
+
"scope_candidates": scope_candidates,
|
|
237
|
+
"types_description": types_description,
|
|
238
|
+
}
|
|
239
|
+
return render_prompt_parts(f"reduce/{variant}.md", template_content, context)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def render_fast_prompt(params: FastPromptParams | None = None, **kwargs: Any) -> PromptParts:
|
|
243
|
+
"""Render the fast-mode single-call prompt variant."""
|
|
244
|
+
p = params if params is not None else FastPromptParams(**kwargs)
|
|
245
|
+
template_content = load_template_file("fast", p.variant)
|
|
246
|
+
context = {
|
|
247
|
+
"stat": p.stat,
|
|
248
|
+
"diff": p.diff,
|
|
249
|
+
"scope_candidates": p.scope_candidates,
|
|
250
|
+
"user_context": p.user_context,
|
|
251
|
+
"types_description": p.types_description,
|
|
252
|
+
}
|
|
253
|
+
return render_prompt_parts(f"fast/{p.variant}.md", template_content, context)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def render_compose_intent_prompt(
|
|
257
|
+
params: ComposeIntentPromptParams | None = None,
|
|
258
|
+
**kwargs: Any,
|
|
259
|
+
) -> PromptParts:
|
|
260
|
+
"""Render the compose-intent planning prompt variant."""
|
|
261
|
+
p = params if params is not None else ComposeIntentPromptParams(**kwargs)
|
|
262
|
+
template_content = load_template_file("compose-intent", p.variant)
|
|
263
|
+
context = {
|
|
264
|
+
"max_commits": p.max_commits,
|
|
265
|
+
"stat": p.stat,
|
|
266
|
+
"snapshot_summary": p.snapshot_summary,
|
|
267
|
+
"planning_targets": p.planning_targets,
|
|
268
|
+
"planning_notes": p.planning_notes,
|
|
269
|
+
"split_bias": p.split_bias,
|
|
270
|
+
"types_description": p.types_description,
|
|
271
|
+
}
|
|
272
|
+
return render_prompt_parts(f"compose-intent/{p.variant}.md", template_content, context)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def render_compose_bind_prompt(
|
|
276
|
+
params: ComposeBindPromptParams | None = None,
|
|
277
|
+
**kwargs: Any,
|
|
278
|
+
) -> PromptParts:
|
|
279
|
+
"""Render the compose-bind hunk-assignment prompt variant."""
|
|
280
|
+
p = params if params is not None else ComposeBindPromptParams(**kwargs)
|
|
281
|
+
template_content = load_template_file("compose-bind", p.variant)
|
|
282
|
+
context = {"groups": p.groups, "ambiguous_files": p.ambiguous_files}
|
|
283
|
+
return render_prompt_parts(f"compose-bind/{p.variant}.md", template_content, context)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def load_template_file(category: str, variant: str) -> str:
|
|
287
|
+
"""Load a user override prompt first, then the packaged prompt resource."""
|
|
288
|
+
if category not in PROMPT_CATEGORIES:
|
|
289
|
+
raise ConfigError(f"Unknown prompt category {category!r}")
|
|
290
|
+
_validate_variant(variant)
|
|
291
|
+
if prompts_dir := get_user_prompts_dir():
|
|
292
|
+
user_template = prompts_dir.joinpath(category, f"{variant}.md")
|
|
293
|
+
if user_template.exists():
|
|
294
|
+
try:
|
|
295
|
+
return user_template.read_text(encoding="utf-8")
|
|
296
|
+
except OSError as exc:
|
|
297
|
+
raise ConfigError(f"Failed to read template file {user_template}: {exc}") from exc
|
|
298
|
+
|
|
299
|
+
resource = resources.files("lgit.resources").joinpath("prompts", category, f"{variant}.md")
|
|
300
|
+
if resource.is_file():
|
|
301
|
+
try:
|
|
302
|
+
return resource.read_text(encoding="utf-8")
|
|
303
|
+
except OSError as exc:
|
|
304
|
+
raise ConfigError(f"Failed to read package template {category}/{variant}.md: {exc}") from exc
|
|
305
|
+
raise ConfigError(
|
|
306
|
+
f"Template variant {variant!r} in category {category!r} not found as user override or package resource"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _find_user_separator(content: str) -> tuple[int, int] | None:
|
|
311
|
+
marker_pos = content.find(USER_SEPARATOR_MARKER)
|
|
312
|
+
if marker_pos == -1:
|
|
313
|
+
return None
|
|
314
|
+
if marker_pos >= 2 and content[marker_pos - 2 : marker_pos] == "\r\n":
|
|
315
|
+
system_end = marker_pos - 2
|
|
316
|
+
elif marker_pos >= 1 and content[marker_pos - 1 : marker_pos] == "\n":
|
|
317
|
+
system_end = marker_pos - 1
|
|
318
|
+
else:
|
|
319
|
+
system_end = marker_pos
|
|
320
|
+
after_marker = marker_pos + len(USER_SEPARATOR_MARKER)
|
|
321
|
+
if content[after_marker : after_marker + 2] == "\r\n":
|
|
322
|
+
user_start = after_marker + 2
|
|
323
|
+
elif content[after_marker : after_marker + 1] == "\n":
|
|
324
|
+
user_start = after_marker + 1
|
|
325
|
+
else:
|
|
326
|
+
user_start = after_marker
|
|
327
|
+
return system_end, user_start
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _ensure_static_system_prompt(system_template: str, template_name: str) -> None:
|
|
331
|
+
if "{{" in system_template or "{%" in system_template or "{#" in system_template:
|
|
332
|
+
raise ConfigError(
|
|
333
|
+
f"Template {template_name!r} contains dynamic tags in system section. "
|
|
334
|
+
f"Move interpolated content below {USER_SEPARATOR_MARKER}."
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _validate_variant(variant: str) -> None:
|
|
339
|
+
if not variant or "/" in variant or "\\" in variant or variant in {".", ".."}:
|
|
340
|
+
raise ConfigError(f"Invalid prompt variant {variant!r}")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _mapping_for_file(file: MapFile | Mapping[str, str]) -> Mapping[str, str]:
|
|
344
|
+
if isinstance(file, MapFile):
|
|
345
|
+
return {"path": file.path, "diff": file.diff}
|
|
346
|
+
return file
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _iter_package_prompt_files() -> Iterable[tuple[Path, str]]:
|
|
350
|
+
prompts_root = resources.files("lgit.resources").joinpath("prompts")
|
|
351
|
+
yield from _walk_prompt_resources(prompts_root, Path())
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _walk_prompt_resources(root: Any, prefix: Path) -> Iterable[tuple[Path, str]]:
|
|
355
|
+
for child in sorted(root.iterdir(), key=lambda item: item.name):
|
|
356
|
+
child_prefix = prefix / child.name
|
|
357
|
+
if child.is_dir():
|
|
358
|
+
yield from _walk_prompt_resources(child, child_prefix)
|
|
359
|
+
elif child.name.endswith(".md"):
|
|
360
|
+
yield child_prefix, child.read_text(encoding="utf-8")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
__all__ = [
|
|
364
|
+
"USER_SEPARATOR_MARKER",
|
|
365
|
+
"PROMPT_CATEGORIES",
|
|
366
|
+
"PromptParts",
|
|
367
|
+
"AnalysisParams",
|
|
368
|
+
"MapFile",
|
|
369
|
+
"ComposeIntentPromptParams",
|
|
370
|
+
"ComposeBindPromptParams",
|
|
371
|
+
"FastPromptParams",
|
|
372
|
+
"get_user_prompts_dir",
|
|
373
|
+
"ensure_prompts_dir",
|
|
374
|
+
"split_prompt_template",
|
|
375
|
+
"render_prompt_parts",
|
|
376
|
+
"load_template_file",
|
|
377
|
+
"render_analysis_prompt",
|
|
378
|
+
"render_summary_prompt",
|
|
379
|
+
"render_changelog_prompt",
|
|
380
|
+
"render_map_prompt",
|
|
381
|
+
"render_reduce_prompt",
|
|
382
|
+
"render_fast_prompt",
|
|
383
|
+
"render_compose_intent_prompt",
|
|
384
|
+
"render_compose_bind_prompt",
|
|
385
|
+
]
|
lgit/testing/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Fixture-based testing harness for lgit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .compare import CompareResult, compare_analysis
|
|
8
|
+
from .fixture import (
|
|
9
|
+
FIXTURES_DIR,
|
|
10
|
+
Fixture,
|
|
11
|
+
FixtureContext,
|
|
12
|
+
FixtureEntry,
|
|
13
|
+
FixtureInput,
|
|
14
|
+
FixtureMeta,
|
|
15
|
+
Golden,
|
|
16
|
+
Manifest,
|
|
17
|
+
add_fixture,
|
|
18
|
+
discover_fixtures,
|
|
19
|
+
load_fixtures,
|
|
20
|
+
)
|
|
21
|
+
from .report import generate_html_report
|
|
22
|
+
from .runner import RunResult, TestRunner, TestSummary, run_test_mode
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def fixtures_dir() -> Path:
|
|
26
|
+
"""Return the default fixtures directory path."""
|
|
27
|
+
|
|
28
|
+
return Path(FIXTURES_DIR)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def list_fixtures(path: str | Path | None = None) -> list[str]:
|
|
32
|
+
"""List available fixtures from the manifest when present, else discover directories."""
|
|
33
|
+
|
|
34
|
+
root = fixtures_dir() if path is None else Path(path)
|
|
35
|
+
manifest = Manifest.load(root)
|
|
36
|
+
if manifest.fixtures:
|
|
37
|
+
return sorted(manifest.fixtures)
|
|
38
|
+
return discover_fixtures(root)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"CompareResult",
|
|
43
|
+
"FIXTURES_DIR",
|
|
44
|
+
"Fixture",
|
|
45
|
+
"FixtureContext",
|
|
46
|
+
"FixtureEntry",
|
|
47
|
+
"FixtureInput",
|
|
48
|
+
"FixtureMeta",
|
|
49
|
+
"Golden",
|
|
50
|
+
"Manifest",
|
|
51
|
+
"RunResult",
|
|
52
|
+
"TestRunner",
|
|
53
|
+
"TestSummary",
|
|
54
|
+
"add_fixture",
|
|
55
|
+
"compare_analysis",
|
|
56
|
+
"discover_fixtures",
|
|
57
|
+
"fixtures_dir",
|
|
58
|
+
"generate_html_report",
|
|
59
|
+
"list_fixtures",
|
|
60
|
+
"load_fixtures",
|
|
61
|
+
"run_test_mode",
|
|
62
|
+
]
|
lgit/testing/compare.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Comparison logic for fixture golden analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from lgit.models import ConventionalAnalysis
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class CompareResult:
|
|
12
|
+
"""Result of comparing actual analysis to a golden analysis."""
|
|
13
|
+
|
|
14
|
+
type_match: bool
|
|
15
|
+
scope_match: bool
|
|
16
|
+
scope_diff: str | None
|
|
17
|
+
golden_detail_count: int
|
|
18
|
+
actual_detail_count: int
|
|
19
|
+
passed: bool
|
|
20
|
+
summary: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compare_analysis(golden: ConventionalAnalysis, actual: ConventionalAnalysis) -> CompareResult:
|
|
24
|
+
"""Compare two analyses using Rust harness-compatible pass rules."""
|
|
25
|
+
|
|
26
|
+
type_match = golden.commit_type == actual.commit_type
|
|
27
|
+
scope_match = golden.scope == actual.scope
|
|
28
|
+
scope_diff = None if scope_match else f"{_scope_text(golden)} -> {_scope_text(actual)}"
|
|
29
|
+
golden_detail_count = len(golden.details)
|
|
30
|
+
actual_detail_count = len(actual.details)
|
|
31
|
+
|
|
32
|
+
passed = type_match
|
|
33
|
+
if passed and scope_match:
|
|
34
|
+
summary = (
|
|
35
|
+
f"PASS {actual.commit_type} | {_scope_text(actual, none='(no scope)')} | {actual_detail_count} details"
|
|
36
|
+
)
|
|
37
|
+
elif passed:
|
|
38
|
+
summary = f"WARN {actual.commit_type} | scope: {scope_diff} | {actual_detail_count} details"
|
|
39
|
+
else:
|
|
40
|
+
summary = f"FAIL type: {golden.commit_type} -> {actual.commit_type} | {actual_detail_count} details"
|
|
41
|
+
|
|
42
|
+
return CompareResult(
|
|
43
|
+
type_match=type_match,
|
|
44
|
+
scope_match=scope_match,
|
|
45
|
+
scope_diff=scope_diff,
|
|
46
|
+
golden_detail_count=golden_detail_count,
|
|
47
|
+
actual_detail_count=actual_detail_count,
|
|
48
|
+
passed=passed,
|
|
49
|
+
summary=summary,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _scope_text(analysis: ConventionalAnalysis, *, none: str = "null") -> str:
|
|
54
|
+
return none if analysis.scope is None else str(analysis.scope)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = ["CompareResult", "compare_analysis"]
|