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.
Files changed (54) hide show
  1. lgit/__init__.py +75 -0
  2. lgit/__main__.py +8 -0
  3. lgit/analysis.py +326 -0
  4. lgit/api.py +1077 -0
  5. lgit/cache.py +338 -0
  6. lgit/changelog.py +523 -0
  7. lgit/cli.py +1104 -0
  8. lgit/compose.py +2110 -0
  9. lgit/config.py +437 -0
  10. lgit/diffing.py +384 -0
  11. lgit/errors.py +137 -0
  12. lgit/git.py +852 -0
  13. lgit/map_reduce.py +508 -0
  14. lgit/markdown_output.py +709 -0
  15. lgit/models.py +924 -0
  16. lgit/normalization.py +411 -0
  17. lgit/patch.py +784 -0
  18. lgit/profile.py +426 -0
  19. lgit/py.typed +0 -0
  20. lgit/repo.py +287 -0
  21. lgit/resources/__init__.py +1 -0
  22. lgit/resources/commit_types.json +242 -0
  23. lgit/resources/prompts/analysis/default.md +237 -0
  24. lgit/resources/prompts/analysis/markdown.md +112 -0
  25. lgit/resources/prompts/changelog/default.md +89 -0
  26. lgit/resources/prompts/changelog/markdown.md +60 -0
  27. lgit/resources/prompts/compose-bind/default.md +40 -0
  28. lgit/resources/prompts/compose-bind/markdown.md +41 -0
  29. lgit/resources/prompts/compose-intent/default.md +63 -0
  30. lgit/resources/prompts/compose-intent/markdown.md +59 -0
  31. lgit/resources/prompts/fast/default.md +46 -0
  32. lgit/resources/prompts/fast/markdown.md +51 -0
  33. lgit/resources/prompts/map/default.md +67 -0
  34. lgit/resources/prompts/map/markdown.md +63 -0
  35. lgit/resources/prompts/reduce/default.md +81 -0
  36. lgit/resources/prompts/reduce/markdown.md +68 -0
  37. lgit/resources/prompts/summary/default.md +74 -0
  38. lgit/resources/prompts/summary/markdown.md +77 -0
  39. lgit/resources/validation_data.json +1 -0
  40. lgit/rewrite.py +392 -0
  41. lgit/style.py +295 -0
  42. lgit/templates.py +385 -0
  43. lgit/testing/__init__.py +62 -0
  44. lgit/testing/compare.py +57 -0
  45. lgit/testing/fixture.py +386 -0
  46. lgit/testing/report.py +201 -0
  47. lgit/testing/runner.py +256 -0
  48. lgit/tokens.py +90 -0
  49. lgit/validation.py +545 -0
  50. lgit_cli-3.7.0.dist-info/METADATA +288 -0
  51. lgit_cli-3.7.0.dist-info/RECORD +54 -0
  52. lgit_cli-3.7.0.dist-info/WHEEL +4 -0
  53. lgit_cli-3.7.0.dist-info/entry_points.txt +2 -0
  54. lgit_cli-3.7.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,386 @@
1
+ """Fixture manifests, on-disk fixture loading, and golden-file I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tomllib
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass, field
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+ from typing import Any, Self
12
+
13
+ from lgit.models import AnalysisDetail, ChangelogCategory, ConventionalAnalysis
14
+
15
+ FIXTURES_DIR = "tests/fixtures"
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class FixtureEntry:
20
+ """Manifest entry for one fixture."""
21
+
22
+ description: str
23
+ tags: list[str] = field(default_factory=list)
24
+
25
+ @classmethod
26
+ def from_mapping(cls, data: Mapping[str, Any]) -> Self:
27
+ return cls(description=str(data.get("description", "")), tags=_string_list(data.get("tags", [])))
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class Manifest:
32
+ """Fixture manifest loaded from ``manifest.toml``."""
33
+
34
+ fixtures: dict[str, FixtureEntry] = field(default_factory=dict)
35
+
36
+ @classmethod
37
+ def load(cls, fixtures_dir: str | Path) -> Self:
38
+ path = Path(fixtures_dir) / "manifest.toml"
39
+ if not path.exists():
40
+ return cls()
41
+ with path.open("rb") as handle:
42
+ data = tomllib.load(handle)
43
+ raw = data.get("fixtures", {})
44
+ if not isinstance(raw, Mapping):
45
+ raise ValueError(f"invalid fixture manifest: {path}")
46
+ return cls(
47
+ {
48
+ str(name): FixtureEntry.from_mapping(entry if isinstance(entry, Mapping) else {})
49
+ for name, entry in raw.items()
50
+ }
51
+ )
52
+
53
+ def save(self, fixtures_dir: str | Path) -> None:
54
+ path = Path(fixtures_dir) / "manifest.toml"
55
+ path.parent.mkdir(parents=True, exist_ok=True)
56
+ lines: list[str] = []
57
+ for name in sorted(self.fixtures):
58
+ entry = self.fixtures[name]
59
+ lines.append(f"[fixtures.{name}]")
60
+ lines.append(f"description = {_toml_value(entry.description)}")
61
+ lines.append(f"tags = {_toml_value(entry.tags)}")
62
+ lines.append("")
63
+ path.write_text("\n".join(lines), encoding="utf-8")
64
+
65
+ def add(self, name: str, entry: FixtureEntry) -> None:
66
+ self.fixtures[name] = entry
67
+
68
+
69
+ @dataclass(slots=True)
70
+ class FixtureMeta:
71
+ """Metadata captured with a fixture."""
72
+
73
+ source_repo: str
74
+ source_commit: str
75
+ description: str
76
+ captured_at: str
77
+ tags: list[str] = field(default_factory=list)
78
+
79
+ @classmethod
80
+ def from_mapping(cls, data: Mapping[str, Any]) -> Self:
81
+ return cls(
82
+ source_repo=str(data.get("source_repo", "")),
83
+ source_commit=str(data.get("source_commit", "")),
84
+ description=str(data.get("description", "")),
85
+ captured_at=str(data.get("captured_at", "")),
86
+ tags=_string_list(data.get("tags", [])),
87
+ )
88
+
89
+
90
+ @dataclass(slots=True)
91
+ class FixtureContext:
92
+ """Analysis context captured for deterministic fixture runs."""
93
+
94
+ recent_commits: str | None = None
95
+ common_scopes: str | None = None
96
+ project_context: str | None = None
97
+ user_context: str | None = None
98
+
99
+ @classmethod
100
+ def from_mapping(cls, data: Mapping[str, Any]) -> Self:
101
+ return cls(
102
+ recent_commits=_optional_str(data.get("recent_commits")),
103
+ common_scopes=_optional_str(data.get("common_scopes")),
104
+ project_context=_optional_str(data.get("project_context")),
105
+ user_context=_optional_str(data.get("user_context")),
106
+ )
107
+
108
+ def to_mapping(self) -> dict[str, str]:
109
+ return {
110
+ key: value
111
+ for key, value in {
112
+ "recent_commits": self.recent_commits,
113
+ "common_scopes": self.common_scopes,
114
+ "project_context": self.project_context,
115
+ "user_context": self.user_context,
116
+ }.items()
117
+ if value is not None
118
+ }
119
+
120
+
121
+ @dataclass(slots=True)
122
+ class FixtureInput:
123
+ """Frozen inputs used by the analysis harness."""
124
+
125
+ diff: str
126
+ stat: str
127
+ scope_candidates: str = ""
128
+ context: FixtureContext = field(default_factory=FixtureContext)
129
+
130
+
131
+ @dataclass(slots=True)
132
+ class Golden:
133
+ """Expected fixture output."""
134
+
135
+ analysis: ConventionalAnalysis
136
+ final_message: str = ""
137
+
138
+
139
+ @dataclass(slots=True)
140
+ class Fixture:
141
+ """A complete fixture loaded from or saved to disk."""
142
+
143
+ name: str
144
+ meta: FixtureMeta
145
+ input: FixtureInput
146
+ golden: Golden | None = None
147
+
148
+ @classmethod
149
+ def load(cls, fixtures_dir: str | Path, name: str) -> Self:
150
+ fixture_dir = Path(fixtures_dir) / name
151
+ if not fixture_dir.exists():
152
+ raise FileNotFoundError(f"fixture {name!r} not found at {fixture_dir}")
153
+
154
+ meta_path = fixture_dir / "meta.toml"
155
+ if not meta_path.exists():
156
+ raise FileNotFoundError(f"fixture {name!r} missing meta.toml")
157
+ meta = FixtureMeta.from_mapping(_read_toml(meta_path))
158
+
159
+ input_dir = fixture_dir / "input"
160
+ diff = _read_text(input_dir / "diff.patch")
161
+ stat = _read_text(input_dir / "stat.txt")
162
+ scope_candidates = _read_text(input_dir / "scope_candidates.txt", missing_ok=True)
163
+ context_path = input_dir / "context.toml"
164
+ context = FixtureContext.from_mapping(_read_toml(context_path)) if context_path.exists() else FixtureContext()
165
+
166
+ golden = None
167
+ golden_dir = fixture_dir / "golden"
168
+ analysis_path = golden_dir / "analysis.json"
169
+ if analysis_path.exists():
170
+ analysis = analysis_from_json(analysis_path.read_text(encoding="utf-8"))
171
+ final_message = _read_text(golden_dir / "final.txt", missing_ok=True)
172
+ golden = Golden(analysis=analysis, final_message=final_message)
173
+
174
+ return cls(name=name, meta=meta, input=FixtureInput(diff, stat, scope_candidates, context), golden=golden)
175
+
176
+ def save(self, fixtures_dir: str | Path) -> None:
177
+ fixture_dir = Path(fixtures_dir) / self.name
178
+ input_dir = fixture_dir / "input"
179
+ golden_dir = fixture_dir / "golden"
180
+ input_dir.mkdir(parents=True, exist_ok=True)
181
+
182
+ _write_toml(
183
+ fixture_dir / "meta.toml",
184
+ {
185
+ "source_repo": self.meta.source_repo,
186
+ "source_commit": self.meta.source_commit,
187
+ "description": self.meta.description,
188
+ "captured_at": self.meta.captured_at,
189
+ "tags": self.meta.tags,
190
+ },
191
+ )
192
+ (input_dir / "diff.patch").write_text(self.input.diff, encoding="utf-8")
193
+ (input_dir / "stat.txt").write_text(self.input.stat, encoding="utf-8")
194
+ (input_dir / "scope_candidates.txt").write_text(self.input.scope_candidates, encoding="utf-8")
195
+ _write_toml(input_dir / "context.toml", self.input.context.to_mapping())
196
+
197
+ if self.golden is not None:
198
+ golden_dir.mkdir(parents=True, exist_ok=True)
199
+ (golden_dir / "analysis.json").write_text(analysis_to_json(self.golden.analysis), encoding="utf-8")
200
+ (golden_dir / "final.txt").write_text(self.golden.final_message, encoding="utf-8")
201
+
202
+ def update_golden(self, analysis: ConventionalAnalysis, final_message: str) -> None:
203
+ self.golden = Golden(analysis=analysis, final_message=final_message)
204
+
205
+
206
+ async def add_fixture(
207
+ fixtures_dir: str | Path, commit_hash: str, name: str, repo_dir: str | Path = ".", config: Any | None = None
208
+ ) -> Fixture:
209
+ """Create a fixture from a commit and add it to the manifest."""
210
+
211
+ from lgit.analysis import extract_scope_candidates
212
+ from lgit.git import extract_style_patterns, get_common_scopes, get_git_diff, get_git_stat, get_recent_commits
213
+ from lgit.repo import RepoMetadata
214
+
215
+ diff = get_git_diff("commit", commit_hash, repo_dir, config)
216
+ stat = get_git_stat("commit", commit_hash, repo_dir, config)
217
+ scope_candidates, _ = extract_scope_candidates("commit", commit_hash, str(repo_dir), config)
218
+
219
+ recent_commits = None
220
+ common_scopes = None
221
+ try:
222
+ commits = get_recent_commits(repo_dir, 20)
223
+ patterns = extract_style_patterns(commits)
224
+ recent_commits = None if patterns is None else patterns.format_for_prompt()
225
+ except Exception:
226
+ recent_commits = None
227
+ try:
228
+ scopes = get_common_scopes(repo_dir, 100)
229
+ common_scopes = ", ".join(f"{scope} ({count})" for scope, count in scopes[:10]) or None
230
+ except Exception:
231
+ common_scopes = None
232
+ project_context = RepoMetadata.detect(repo_dir).format_for_prompt()
233
+
234
+ fixture = Fixture(
235
+ name=name,
236
+ meta=FixtureMeta(
237
+ source_repo=str(repo_dir),
238
+ source_commit=commit_hash,
239
+ description=f"Fixture from commit {commit_hash}",
240
+ captured_at=datetime.now(UTC).isoformat(),
241
+ tags=[],
242
+ ),
243
+ input=FixtureInput(
244
+ diff=diff,
245
+ stat=stat,
246
+ scope_candidates=scope_candidates,
247
+ context=FixtureContext(
248
+ recent_commits=recent_commits, common_scopes=common_scopes, project_context=project_context
249
+ ),
250
+ ),
251
+ )
252
+ root = Path(fixtures_dir)
253
+ root.mkdir(parents=True, exist_ok=True)
254
+ fixture.save(root)
255
+ manifest = Manifest.load(root)
256
+ manifest.add(name, FixtureEntry(description=f"From commit {commit_hash}", tags=[]))
257
+ manifest.save(root)
258
+ return fixture
259
+
260
+
261
+ def discover_fixtures(fixtures_dir: str | Path) -> list[str]:
262
+ """Return sorted fixture directory names under ``fixtures_dir``."""
263
+
264
+ root = Path(fixtures_dir)
265
+ if not root.exists():
266
+ return []
267
+ return sorted(path.name for path in root.iterdir() if path.is_dir() and (path / "meta.toml").exists())
268
+
269
+
270
+ def load_fixtures(fixtures_dir: str | Path, names: list[str] | None = None) -> list[Fixture]:
271
+ selected = discover_fixtures(fixtures_dir) if names is None else names
272
+ return [Fixture.load(fixtures_dir, name) for name in selected]
273
+
274
+
275
+ def analysis_from_json(content: str) -> ConventionalAnalysis:
276
+ data = json.loads(content)
277
+ if not isinstance(data, Mapping):
278
+ raise ValueError("analysis JSON must be an object")
279
+ details = tuple(_detail_from_json(item) for item in (data.get("details") or ()))
280
+ issue_refs = tuple(str(item) for item in (data.get("issue_refs") or ()))
281
+ return ConventionalAnalysis(
282
+ commit_type=str(data.get("type", data.get("commit_type", "chore"))),
283
+ scope=_optional_str(data.get("scope")),
284
+ summary=_optional_str(data.get("summary")),
285
+ details=details,
286
+ issue_refs=issue_refs,
287
+ )
288
+
289
+
290
+ def analysis_to_json(analysis: ConventionalAnalysis) -> str:
291
+ return json.dumps(analysis_to_mapping(analysis), indent=2, ensure_ascii=False) + "\n"
292
+
293
+
294
+ def analysis_to_mapping(analysis: ConventionalAnalysis) -> dict[str, Any]:
295
+ data: dict[str, Any] = {"type": str(analysis.commit_type)}
296
+ if analysis.scope is not None:
297
+ data["scope"] = str(analysis.scope)
298
+ if analysis.summary:
299
+ data["summary"] = analysis.summary
300
+ data["details"] = [_detail_to_json(detail) for detail in analysis.details]
301
+ data["issue_refs"] = list(analysis.issue_refs)
302
+ return data
303
+
304
+
305
+ def _detail_from_json(item: Any) -> AnalysisDetail:
306
+ if isinstance(item, Mapping):
307
+ raw_category = item.get("changelog_category")
308
+ category = ChangelogCategory.from_name(str(raw_category)) if raw_category not in (None, "") else None
309
+ return AnalysisDetail(
310
+ text=str(item.get("text", "")),
311
+ changelog_category=category,
312
+ user_visible=bool(item.get("user_visible", False)),
313
+ )
314
+ return AnalysisDetail.simple(str(item))
315
+
316
+
317
+ def _detail_to_json(detail: AnalysisDetail) -> dict[str, Any]:
318
+ data: dict[str, Any] = {"text": detail.text}
319
+ if detail.changelog_category is not None:
320
+ data["changelog_category"] = detail.changelog_category.value
321
+ data["user_visible"] = bool(detail.user_visible)
322
+ return data
323
+
324
+
325
+ def _read_toml(path: Path) -> dict[str, Any]:
326
+ with path.open("rb") as handle:
327
+ data = tomllib.load(handle)
328
+ return dict(data)
329
+
330
+
331
+ def _read_text(path: Path, *, missing_ok: bool = False) -> str:
332
+ if missing_ok and not path.exists():
333
+ return ""
334
+ return path.read_text(encoding="utf-8")
335
+
336
+
337
+ def _write_toml(path: Path, data: Mapping[str, Any]) -> None:
338
+ path.parent.mkdir(parents=True, exist_ok=True)
339
+ lines = [f"{key} = {_toml_value(value)}" for key, value in data.items() if value is not None]
340
+ path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
341
+
342
+
343
+ def _toml_value(value: Any) -> str:
344
+ if isinstance(value, bool):
345
+ return "true" if value else "false"
346
+ if isinstance(value, int | float):
347
+ return str(value)
348
+ if isinstance(value, list | tuple):
349
+ return "[" + ", ".join(_toml_value(item) for item in value) + "]"
350
+ text = str(value)
351
+ if "\n" in text:
352
+ return '"""' + text.replace('"""', '\\"\\"\\"') + '"""'
353
+ return json.dumps(text, ensure_ascii=False)
354
+
355
+
356
+ def _optional_str(value: Any) -> str | None:
357
+ if value is None:
358
+ return None
359
+ text = str(value)
360
+ return text if text else None
361
+
362
+
363
+ def _string_list(value: Any) -> list[str]:
364
+ if value is None:
365
+ return []
366
+ if isinstance(value, str):
367
+ return [value]
368
+ return [str(item) for item in value]
369
+
370
+
371
+ __all__ = [
372
+ "FIXTURES_DIR",
373
+ "Fixture",
374
+ "FixtureContext",
375
+ "FixtureEntry",
376
+ "FixtureInput",
377
+ "FixtureMeta",
378
+ "Golden",
379
+ "Manifest",
380
+ "add_fixture",
381
+ "analysis_from_json",
382
+ "analysis_to_json",
383
+ "analysis_to_mapping",
384
+ "discover_fixtures",
385
+ "load_fixtures",
386
+ ]
lgit/testing/report.py ADDED
@@ -0,0 +1,201 @@
1
+ """Dark HTML report generation for fixture test results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from datetime import UTC, datetime
7
+ from html import escape
8
+ from pathlib import Path
9
+
10
+ from .fixture import Fixture
11
+ from .runner import RunResult, TestSummary
12
+
13
+
14
+ def generate_html_report(results: Sequence[RunResult], fixtures: Sequence[Fixture], output_path: str | Path) -> Path:
15
+ """Write a dark HTML fixture report and return the output path."""
16
+
17
+ path = Path(output_path)
18
+ path.parent.mkdir(parents=True, exist_ok=True)
19
+ path.write_text(render_report(results, fixtures, TestSummary.from_results(list(results))), encoding="utf-8")
20
+ return path
21
+
22
+
23
+ def render_report(results: Sequence[RunResult], fixtures: Sequence[Fixture], summary: TestSummary | None = None) -> str:
24
+ summary = TestSummary.from_results(list(results)) if summary is None else summary
25
+ fixture_by_name = {fixture.name: fixture for fixture in fixtures}
26
+ rows = "\n".join(_render_fixture_result(result, fixture_by_name.get(result.name)) for result in results)
27
+ generated = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
28
+ return f"""<!DOCTYPE html>
29
+ <html lang="en">
30
+ <head>
31
+ <meta charset="UTF-8">
32
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
33
+ <title>lgit Fixture Test Report</title>
34
+ <style>
35
+ :root {{
36
+ --bg: #0d1117;
37
+ --fg: #c9d1d9;
38
+ --muted: #8b949e;
39
+ --border: #30363d;
40
+ --card: #161b22;
41
+ --green: #3fb950;
42
+ --red: #f85149;
43
+ --yellow: #d29922;
44
+ --blue: #58a6ff;
45
+ --purple: #a371f7;
46
+ }}
47
+ * {{ box-sizing: border-box; }}
48
+ body {{ margin: 0; padding: 2rem; background: var(--bg); color: var(--fg); font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
49
+ .container {{ max-width: 1400px; margin: 0 auto; }}
50
+ h1 {{ margin: 0 0 .5rem; font-size: 1.8rem; }}
51
+ .timestamp {{ color: var(--muted); margin: 0 0 1.5rem; }}
52
+ .summary {{ display: flex; flex-wrap: wrap; gap: 1rem; margin-bottom: 2rem; }}
53
+ .stat {{ min-width: 120px; padding: 1rem 1.25rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; }}
54
+ .stat-value {{ font-size: 2rem; font-weight: 700; }}
55
+ .stat-label {{ color: var(--muted); font-size: .85rem; }}
56
+ .passed .stat-value, .match {{ color: var(--green); }}
57
+ .failed .stat-value, .mismatch, .error-color {{ color: var(--red); }}
58
+ .no-golden .stat-value, .warn {{ color: var(--yellow); }}
59
+ .fixture {{ margin-bottom: 1rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }}
60
+ .fixture-header {{ display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 1rem 1.25rem; cursor: pointer; border-bottom: 1px solid var(--border); }}
61
+ .fixture-header:hover {{ background: rgba(255, 255, 255, .035); }}
62
+ .fixture-name {{ font-weight: 650; word-break: break-word; }}
63
+ .badge {{ white-space: nowrap; padding: .25rem .7rem; border-radius: 999px; font-size: .8rem; font-weight: 600; }}
64
+ .badge.passed {{ color: var(--green); background: rgba(63, 185, 80, .13); }}
65
+ .badge.failed, .badge.error {{ color: var(--red); background: rgba(248, 81, 73, .13); }}
66
+ .badge.no-golden {{ color: var(--yellow); background: rgba(210, 153, 34, .13); }}
67
+ .fixture-content {{ display: none; padding: 1.25rem; }}
68
+ .fixture.expanded .fixture-content {{ display: block; }}
69
+ .diff-row {{ display: flex; gap: 1rem; margin-bottom: .45rem; }}
70
+ .diff-label {{ width: 6rem; min-width: 6rem; color: var(--muted); font-weight: 600; }}
71
+ .comparison {{ display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1.25rem; }}
72
+ @media (max-width: 900px) {{ .comparison {{ grid-template-columns: 1fr; }} }}
73
+ h3 {{ margin: 0 0 .5rem; text-transform: uppercase; letter-spacing: .05em; font-size: .78rem; }}
74
+ h3.golden {{ color: var(--purple); }}
75
+ h3.actual {{ color: var(--blue); }}
76
+ .message-box, .error-message {{ white-space: pre-wrap; word-break: break-word; padding: 1rem; border-radius: 8px; font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }}
77
+ .message-box {{ background: var(--bg); border: 1px solid var(--border); }}
78
+ .error-message {{ color: var(--red); background: rgba(248, 81, 73, .08); border: 1px solid rgba(248, 81, 73, .5); }}
79
+ </style>
80
+ </head>
81
+ <body>
82
+ <div class="container">
83
+ <h1>lgit Fixture Test Report</h1>
84
+ <p class="timestamp">Generated: {escape(generated)}</p>
85
+ <div class="summary">
86
+ {_stat("Total", summary.total)}
87
+ {_stat("Passed", summary.passed, "passed")}
88
+ {_stat("Failed", summary.failed, "failed")}
89
+ {_stat("No Golden", summary.no_golden, "no-golden")}
90
+ {_stat("Errors", summary.errors, "failed")}
91
+ </div>
92
+ {rows}
93
+ </div>
94
+ <script>
95
+ document.querySelectorAll('.fixture-header').forEach((header) => {{
96
+ header.addEventListener('click', () => header.parentElement.classList.toggle('expanded'));
97
+ }});
98
+ document.querySelectorAll('.fixture.failed, .fixture.error').forEach((fixture) => fixture.classList.add('expanded'));
99
+ </script>
100
+ </body>
101
+ </html>
102
+ """
103
+
104
+
105
+ def _stat(label: str, value: int, css: str = "") -> str:
106
+ return f'<div class="stat {css}"><div class="stat-value">{value}</div><div class="stat-label">{escape(label)}</div></div>'
107
+
108
+
109
+ def _render_fixture_result(result: RunResult, fixture: Fixture | None) -> str:
110
+ status_class, status_text = _status(result)
111
+ body = _render_error(result.error) if result.error else _render_success_body(result, fixture)
112
+ return f"""
113
+ <section class="fixture {status_class}">
114
+ <div class="fixture-header">
115
+ <span class="fixture-name">{escape(result.name)}</span>
116
+ <span class="badge {status_class}">{status_text}</span>
117
+ </div>
118
+ <div class="fixture-content">{body}</div>
119
+ </section>"""
120
+
121
+
122
+ def _render_success_body(result: RunResult, fixture: Fixture | None) -> str:
123
+ if result.comparison is None:
124
+ return _render_actual_only(result)
125
+ cmp = result.comparison
126
+ golden = fixture.golden if fixture is not None else None
127
+ type_row = ""
128
+ golden_message = ""
129
+ if golden is not None:
130
+ type_row = _diff_row(
131
+ "Type",
132
+ f"{escape(str(golden.analysis.commit_type))} &rarr; {escape(str(result.analysis.commit_type))}",
133
+ "match" if cmp.type_match else "mismatch",
134
+ )
135
+ golden_message = f"""
136
+ <div>
137
+ <h3 class="golden">Golden (Expected)</h3>
138
+ <div class="message-box">{escape(golden.final_message or _analysis_summary(golden.analysis))}</div>
139
+ </div>"""
140
+ scope_value = escape(cmp.scope_diff or _scope_text(result.analysis, none="(none)"))
141
+ details_value = f"{cmp.golden_detail_count} golden &rarr; {cmp.actual_detail_count} actual"
142
+ return f"""
143
+ <div>
144
+ {type_row}
145
+ {_diff_row("Scope", scope_value, "match" if cmp.scope_match else "mismatch")}
146
+ {_diff_row("Details", details_value)}
147
+ </div>
148
+ <div class="comparison">
149
+ {golden_message}
150
+ <div>
151
+ <h3 class="actual">Actual (Current)</h3>
152
+ <div class="message-box">{escape(result.final_message)}</div>
153
+ </div>
154
+ </div>"""
155
+
156
+
157
+ def _render_actual_only(result: RunResult) -> str:
158
+ return f"""
159
+ <div>
160
+ {_diff_row("Type", escape(str(result.analysis.commit_type)))}
161
+ {_diff_row("Scope", escape(_scope_text(result.analysis, none="(none)")))}
162
+ {_diff_row("Details", f"{len(result.analysis.details)} points")}
163
+ <h3 class="actual" style="margin-top: 1rem;">Generated Message</h3>
164
+ <div class="message-box">{escape(result.final_message)}</div>
165
+ </div>"""
166
+
167
+
168
+ def _render_error(error: str | None) -> str:
169
+ return f'<div class="error-message">{escape(error or "")}</div>'
170
+
171
+
172
+ def _diff_row(label: str, value: str, css: str = "") -> str:
173
+ return f'<div class="diff-row"><span class="diff-label">{escape(label)}:</span><span class="{css}">{value}</span></div>'
174
+
175
+
176
+ def _status(result: RunResult) -> tuple[str, str]:
177
+ if result.error is not None:
178
+ return "error", "Error"
179
+ if result.comparison is None:
180
+ return "no-golden", "No Golden"
181
+ return ("passed", "Passed") if result.comparison.passed else ("failed", "Failed")
182
+
183
+
184
+ def _scope_text(analysis: object, *, none: str = "null") -> str:
185
+ scope = getattr(analysis, "scope", None)
186
+ return none if scope is None else str(scope)
187
+
188
+
189
+ def _analysis_summary(analysis: object) -> str:
190
+ commit_type = str(getattr(analysis, "commit_type", ""))
191
+ scope = getattr(analysis, "scope", None)
192
+ summary = getattr(analysis, "summary", None)
193
+ details = getattr(analysis, "details", ())
194
+ header = commit_type + (f"({scope})" if scope else "")
195
+ if summary:
196
+ header += f": {summary}"
197
+ detail_lines = "\n".join(f"- {getattr(detail, 'text', detail)}" for detail in details)
198
+ return header + ("\n\n" + detail_lines if detail_lines else "")
199
+
200
+
201
+ __all__ = ["generate_html_report", "render_report"]