Option C Tools
Loading...
Searching...
No Matches
conventional.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/git/conventional.py
4
5"""
6Purpose
7-------
8Conventional Commits parser and validator (Phase 4D — G-D1, G-D2, G-D5).
9
10Responsibilities
11----------------
12- Parse commit messages into structured ``ConventionalCommit`` objects.
13- Validate commit messages against the Conventional Commits specification
14 with Option C extensions (blueprint §6).
15- Format validation errors for human-readable display.
16- Analyse git diffs for ``_dbg`` diagnostics impact (ChatGPT contribution).
17
18Diagnostics
19-----------
20Domain: GIT
21Levels:
22 L2 — validation lifecycle
23 L3 — parsing details
24 L4 — diagnostics impact analysis
25
26Contracts
27---------
28- ``validate_commit_message`` never raises; always returns a list.
29- ``parse_commit_message`` returns None for unparseable messages.
30- ``analyse_diagnostics_impact`` is best-effort; returns empty list on error.
31"""
32
33from __future__ import annotations
34
35import re
36from dataclasses import dataclass, field
37
38
39# =====================================================================
40# Constants (blueprint §6.1)
41# =====================================================================
42
43COMMIT_TYPES: tuple[str, ...] = (
44 "feat", "fix", "docs", "style", "refactor", "perf", "test", "chore", "sec",
45)
46
47MAX_SUBJECT_LENGTH: int = 72
48
49# Regex for the first line of a Conventional Commit:
50# type[(scope)][!]: subject
51_HEADER_RE = re.compile(
52 r"^(?P<type>\w+)" # type word
53 r"(?:\‍((?P<scope>[^)]+)\‍))?" # optional (scope)
54 r"(?P<bang>!)?" # optional breaking indicator
55 r"\s*:\s*" # colon separator
56 r"(?P<subject>.+)$" # subject text
57)
58
59# Footer line detector: "Token: value" or "BREAKING CHANGE: ...".
60# Compiled at module scope so parse_commit_message() doesn't recompile per call.
61_FOOTER_RE = re.compile(r"^[\w-]+\s*:\s*|^BREAKING CHANGE\s*:\s*")
62
63
64# =====================================================================
65# Data structures
66# =====================================================================
67
68
69@dataclass
71 """Parsed representation of a Conventional Commit message."""
72
73 type: str
74 scope: str | None
75 subject: str
76 body: str
77 footer: str
78 breaking: bool
79
80
81# =====================================================================
82# Parsing
83# =====================================================================
84
85
86def parse_commit_message(message: str) -> ConventionalCommit | None:
87 """Parse a commit message into a :class:`ConventionalCommit`.
88
89 Returns ``None`` if the first line does not match the Conventional
90 Commits ``type[(scope)][!]: subject`` pattern.
91 """
92 if not message or not message.strip():
93 return None
94
95 lines = message.strip().splitlines()
96 header = lines[0].strip()
97
98 m = _HEADER_RE.match(header)
99 if m is None:
100 return None
101
102 # Split body and footer from remaining lines.
103 body_lines: list[str] = []
104 footer_lines: list[str] = []
105 rest = lines[1:]
106
107 # Skip leading blank line(s) between header and body.
108 while rest and not rest[0].strip():
109 rest = rest[1:]
110
111 # Footer detection: lines matching "Token: value" or "BREAKING CHANGE: ..."
112 # at the end of the message. Uses module-level _FOOTER_RE (compiled once).
113 # Walk backwards to find footer start.
114 footer_start = len(rest)
115 for i in range(len(rest) - 1, -1, -1):
116 if _FOOTER_RE.match(rest[i]):
117 footer_start = i
118 elif rest[i].strip() == "":
119 # Blank line before footer block — footer starts after it.
120 if footer_start < len(rest):
121 footer_start = i + 1
122 break
123 else:
124 break
125
126 body_lines = rest[:footer_start]
127 footer_lines = rest[footer_start:]
128
129 # Trim trailing blank lines from body.
130 while body_lines and not body_lines[-1].strip():
131 body_lines.pop()
132
133 breaking = bool(m.group("bang"))
134 # Also check for BREAKING CHANGE footer.
135 footer_text = "\n".join(footer_lines)
136 if "BREAKING CHANGE" in footer_text or "BREAKING-CHANGE" in footer_text:
137 breaking = True
138
139 return ConventionalCommit(
140 type=m.group("type"),
141 scope=m.group("scope"),
142 subject=m.group("subject").strip(),
143 body="\n".join(body_lines),
144 footer=footer_text,
145 breaking=breaking,
146 )
147
148
149# =====================================================================
150# Validation (blueprint §6.1)
151# =====================================================================
152
153
154def validate_commit_message(message: str) -> list[str]:
155 """Validate a commit message against Conventional Commits rules.
156
157 Returns a list of human-readable error strings. An empty list
158 means the message is valid.
159
160 Rules checked:
161 1. Must match Conventional Commits pattern.
162 2. Type must be one of :data:`COMMIT_TYPES`.
163 3. Subject must start with a lowercase letter.
164 4. Subject must not end with a period.
165 5. First line (header) must be ≤ :data:`MAX_SUBJECT_LENGTH` characters.
166 6. Subject must not be empty.
167 """
168 errors: list[str] = []
169
170 if not message or not message.strip():
171 errors.append("Commit message is empty.")
172 return errors
173
174 parsed = parse_commit_message(message)
175 if parsed is None:
176 errors.append(
177 "Message does not match Conventional Commits format: "
178 "type[(scope)]: subject"
179 )
180 return errors
181
182 # 2. Type check.
183 if parsed.type not in COMMIT_TYPES:
184 types_str = ", ".join(COMMIT_TYPES)
185 errors.append(
186 f'Type "{parsed.type}" is not valid. '
187 f"Use one of: {types_str}"
188 )
189
190 # 6. Empty subject.
191 if not parsed.subject:
192 errors.append("Subject must not be empty.")
193 return errors # No point checking other subject rules.
194
195 # 3. Lowercase first character.
196 if parsed.subject[0].isupper():
197 errors.append("Subject must start with a lowercase letter.")
198
199 # 4. No trailing period.
200 if parsed.subject.endswith("."):
201 errors.append("Subject must not end with a period.")
202
203 # 5. Header length (type + optional scope + colon + subject).
204 header = message.strip().splitlines()[0].strip()
205 if len(header) > MAX_SUBJECT_LENGTH:
206 errors.append(
207 f"Subject line exceeds {MAX_SUBJECT_LENGTH} characters "
208 f"(was {len(header)})."
209 )
210
211 return errors
212
213
214def format_validation_errors(errors: list[str]) -> str:
215 """Format validation errors for display.
216
217 Returns a multi-line string with bullet points and an example.
218 """
219 lines = ["Commit message validation failed:"]
220 for err in errors:
221 lines.append(f" - {err}")
222 lines.append("")
223 lines.append("Example: fix(auth): prevent token leak in session logs")
224 return "\n".join(lines)
225
226
227# =====================================================================
228# Diagnostics impact analysis (blueprint §6.3 — ChatGPT contribution)
229# =====================================================================
230
231# Regex to extract _dbg("DOMAIN", ..., LEVEL) calls from diff lines.
232_DBG_CALL_RE = re.compile(
233 r'_dbg\‍(\s*["\'](?P<domain>[A-Z_]+)["\']'
234 r'(?:\s*,\s*[^,]+)?' # skip func argument
235 r'(?:\s*,\s*[^,]+)?' # skip message argument
236 r'(?:\s*,\s*(?P<level>\d+))?' # optional level
237)
238
239
240def analyse_diagnostics_impact(diff_text: str) -> list[str]:
241 """Analyse a unified diff for ``_dbg``-related changes.
242
243 Scans added (``+``) and removed (``-``) lines for ``_dbg()`` calls.
244 Returns a list of human-readable impact lines, e.g.::
245
246 + Added: GIT domain (L2)
247 - Removed: AUTH domain (L3)
248
249 Returns an empty list if no ``_dbg`` changes are detected or if the
250 diff cannot be parsed. This function is best-effort and never raises.
251 """
252 try:
253 return _analyse_diagnostics_impact_inner(diff_text)
254 except Exception:
255 return []
256
257
258def _analyse_diagnostics_impact_inner(diff_text: str) -> list[str]:
259 """Inner implementation of diagnostics impact analysis."""
260 added: dict[str, set[str]] = {} # domain -> set of levels
261 removed: dict[str, set[str]] = {}
262
263 for line in diff_text.splitlines():
264 if line.startswith("+++") or line.startswith("---"):
265 continue
266
267 if line.startswith("+") and "_dbg(" in line:
268 m = _DBG_CALL_RE.search(line)
269 if m:
270 domain = m.group("domain")
271 level = m.group("level") or "?"
272 added.setdefault(domain, set()).add(f"L{level}")
273
274 elif line.startswith("-") and "_dbg(" in line:
275 m = _DBG_CALL_RE.search(line)
276 if m:
277 domain = m.group("domain")
278 level = m.group("level") or "?"
279 removed.setdefault(domain, set()).add(f"L{level}")
280
281 # Build impact lines.
282 impact: list[str] = []
283
284 # Domains only in added.
285 for domain in sorted(added.keys() - removed.keys()):
286 levels = ", ".join(sorted(added[domain]))
287 impact.append(f"+ Added: {domain} domain ({levels})")
288
289 # Domains only in removed.
290 for domain in sorted(removed.keys() - added.keys()):
291 levels = ", ".join(sorted(removed[domain]))
292 impact.append(f"- Removed: {domain} domain ({levels})")
293
294 # Domains in both — modifications.
295 for domain in sorted(added.keys() & removed.keys()):
296 old_levels = ", ".join(sorted(removed[domain]))
297 new_levels = ", ".join(sorted(added[domain]))
298 impact.append(f"~ Modified: {domain} ({old_levels} -> {new_levels})")
299
300 return impact
str format_validation_errors(list[str] errors)
list[str] analyse_diagnostics_impact(str diff_text)
ConventionalCommit|None parse_commit_message(str message)
list[str] validate_commit_message(str message)
list[str] _analyse_diagnostics_impact_inner(str diff_text)