oct
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
32Dependencies
33------------
34- oct.core.diagnostics (_dbg structured logger)
35"""
36
37from __future__ import annotations
38
39import re
40from dataclasses import dataclass, field
41from oct.core.diagnostics import _dbg
42
43
44# =====================================================================
45# Constants (blueprint §6.1)
46# =====================================================================
47
48COMMIT_TYPES: tuple[str, ...] = (
49 "feat", "fix", "docs", "style", "refactor", "perf", "test", "chore", "sec",
50)
51
52MAX_SUBJECT_LENGTH: int = 72
53
54# Regex for the first line of a Conventional Commit:
55# type[(scope)][!]: subject
56_HEADER_RE = re.compile(
57 # OCT-LINT: disable=no_hardcoded_secrets reason="Regex patterns and commit-message remediation strings are high-entropy but not credentials"
58 r"^(?P<type>\w+)" # type word
59 r"(?:\‍((?P<scope>[^)]+)\‍))?" # optional (scope)
60 r"(?P<bang>!)?" # optional breaking indicator
61 r"\s*:\s*" # colon separator
62 r"(?P<subject>.+)$" # subject text
63)
64
65# Footer line detector: "Token: value" or "BREAKING CHANGE: ...".
66# Compiled at module scope so parse_commit_message() doesn't recompile per call.
67_FOOTER_RE = re.compile(r"^[\w-]+\s*:\s*|^BREAKING CHANGE\s*:\s*")
68
69# Option C plan-prefix extension (Plan 151 T6 - routed oct fix ii).
70# Commit subjects in Option C repos carry a leading bracketed plan/area
71# prefix ahead of the Conventional Commits header, e.g.
72# "[151.T6][oct] feat: bound the root walk-up"
73# The prefix is project metadata (plan id / tranche / area), not part of
74# the Conventional Commits subject; the header is parsed and length-checked
75# on the text that FOLLOWS this optional prefix. One or more adjacent
76# ``[...]`` groups are accepted (each non-empty, single-line), plus any
77# trailing whitespace. Anchored at start, so a bracket that merely appears
78# inside a subject (e.g. "feat: add [beta] flag") is never treated as a
79# prefix.
80_PLAN_PREFIX_RE = re.compile(r"^(?:\‍[[^\‍]\n]+\‍]\s*)+")
81
82# AI-Model trailer detector (Plan 151 T6 - inventory item 4 / R03 F-08).
83# The Option C commit-forensics convention requires every commit body to
84# carry an ``AI-Model: <name> <version>`` trailer line identifying the LLM
85# that did the reasoning. Presence validation only - the value must name at
86# least a model and a version (>= 2 whitespace-separated tokens).
87_AI_MODEL_TRAILER_RE = re.compile(
88 # OCT-LINT: disable=no_hardcoded_secrets reason="Regex patterns and commit-message remediation strings are high-entropy but not credentials"
89 r"(?m)^[ \t]*AI-Model:[ \t]*(?P<value>\S.*)$"
90)
91
92
93def _strip_plan_prefix(header: str) -> str:
94 """Return *header* without an optional leading Option C plan/area prefix.
95
96 ``"[151.T6][oct] feat: x"`` -> ``"feat: x"``; a header with no leading
97 ``[...]`` prefix is returned unchanged. Used by both the parser and the
98 header-length validator so the Conventional Commits rules apply to the
99 conventional portion, not the project metadata.
100 """
101 m = _PLAN_PREFIX_RE.match(header)
102 return header[m.end():] if m else header
103
104
105# =====================================================================
106# Data structures
107# =====================================================================
108
109
110@dataclass
112 """Parsed representation of a Conventional Commit message."""
113
114 type: str
115 scope: str | None
116 subject: str
117 body: str
118 footer: str
119 breaking: bool
120
121
122# =====================================================================
123# Parsing
124# =====================================================================
125
126
127def parse_commit_message(message: str) -> ConventionalCommit | None:
128 """Parse a commit message into a :class:`ConventionalCommit`.
129
130 Returns ``None`` if the first line does not match the Conventional
131 Commits ``type[(scope)][!]: subject`` pattern.
132 """
133 func = "parse_commit_message"
134 _dbg("GIT", func, f"parsing a {len(message)}-char commit message", 4)
135 if not message or not message.strip():
136 return None
137
138 lines = message.strip().splitlines()
139 header = lines[0].strip()
140
141 # Strip an optional Option C plan/area bracket prefix before matching
142 # the Conventional Commits header (Plan 151 T6, routed fix ii).
143 m = _HEADER_RE.match(_strip_plan_prefix(header))
144 if m is None:
145 return None
146
147 # Split body and footer from remaining lines.
148 body_lines: list[str] = []
149 footer_lines: list[str] = []
150 rest = lines[1:]
151
152 # Skip leading blank line(s) between header and body.
153 while rest and not rest[0].strip():
154 rest = rest[1:]
155
156 # Footer detection: lines matching "Token: value" or "BREAKING CHANGE: ..."
157 # at the end of the message. Uses module-level _FOOTER_RE (compiled once).
158 # Walk backwards to find footer start.
159 footer_start = len(rest)
160 for i in range(len(rest) - 1, -1, -1):
161 if _FOOTER_RE.match(rest[i]):
162 footer_start = i
163 elif rest[i].strip() == "":
164 # Blank line before footer block - footer starts after it.
165 if footer_start < len(rest):
166 footer_start = i + 1
167 break
168 else:
169 break
170
171 body_lines = rest[:footer_start]
172 footer_lines = rest[footer_start:]
173
174 # Trim trailing blank lines from body.
175 while body_lines and not body_lines[-1].strip():
176 body_lines.pop()
177
178 breaking = bool(m.group("bang"))
179 # Also check for BREAKING CHANGE footer.
180 footer_text = "\n".join(footer_lines)
181 if "BREAKING CHANGE" in footer_text or "BREAKING-CHANGE" in footer_text:
182 breaking = True
183
184 return ConventionalCommit(
185 type=m.group("type"),
186 scope=m.group("scope"),
187 subject=m.group("subject").strip(),
188 body="\n".join(body_lines),
189 footer=footer_text,
190 breaking=breaking,
191 )
192
193
194# =====================================================================
195# Validation (blueprint §6.1)
196# =====================================================================
197
198
199def validate_commit_message(message: str) -> list[str]:
200 """Validate a commit message against Conventional Commits rules.
201
202 Returns a list of human-readable error strings. An empty list
203 means the message is valid.
204
205 Rules checked:
206 1. Must match Conventional Commits pattern.
207 2. Type must be one of :data:`COMMIT_TYPES`.
208 3. Subject must start with a lowercase letter.
209 4. Subject must not end with a period.
210 5. First line (header) must be ≤ :data:`MAX_SUBJECT_LENGTH` characters.
211 6. Subject must not be empty.
212 """
213 func = "validate_commit_message"
214 _dbg("GIT", func, "validating the commit message", 3)
215 errors: list[str] = []
216
217 if not message or not message.strip():
218 errors.append("Commit message is empty.")
219 return errors
220
221 parsed = parse_commit_message(message)
222 if parsed is None:
223 errors.append(
224 "Message does not match Conventional Commits format: "
225 "type[(scope)]: subject"
226 )
227 return errors
228
229 # 2. Type check.
230 if parsed.type not in COMMIT_TYPES:
231 types_str = ", ".join(COMMIT_TYPES)
232 errors.append(
233 f'Type "{parsed.type}" is not valid. '
234 f"Use one of: {types_str}"
235 )
236
237 # 6. Empty subject.
238 if not parsed.subject:
239 errors.append("Subject must not be empty.")
240 return errors # No point checking other subject rules.
241
242 # 3. Lowercase first character.
243 if parsed.subject[0].isupper():
244 errors.append("Subject must start with a lowercase letter.")
245
246 # 4. No trailing period.
247 if parsed.subject.endswith("."):
248 errors.append("Subject must not end with a period.")
249
250 # 5. Header length (type + optional scope + colon + subject). The
251 # optional Option C plan/area bracket prefix is metadata and does not
252 # count against the Conventional Commits header budget (Plan 151 T6).
253 header = _strip_plan_prefix(message.strip().splitlines()[0].strip())
254 if len(header) > MAX_SUBJECT_LENGTH:
255 errors.append(
256 f"Subject line exceeds {MAX_SUBJECT_LENGTH} characters "
257 f"(was {len(header)})."
258 )
259
260 return errors
261
262
263def validate_ai_model_trailer(message: str) -> list[str]:
264 """Validate presence of the Option C ``AI-Model:`` commit trailer.
265
266 Returns a list of human-readable error strings; an empty list means an
267 ``AI-Model: <name> <version>`` trailer is present and well-formed. This
268 is the Option C commit-forensics convention (Plan 151 T6, inventory
269 item 4): every commit body must name the LLM that did the reasoning.
270
271 Presence validation only - the trailer value must contain at least a
272 model name and a version (two or more whitespace-separated tokens).
273 Never raises; always returns a list.
274 """
275 errors: list[str] = []
276
277 if not message or not message.strip():
278 errors.append("Commit message is empty (no AI-Model trailer).")
279 return errors
280
281 m = _AI_MODEL_TRAILER_RE.search(message)
282 if m is None:
283 errors.append(
284 # OCT-LINT: disable=no_hardcoded_secrets reason="Regex patterns and commit-message remediation strings are high-entropy but not credentials"
285 "Missing required 'AI-Model: <name> <version>' trailer "
286 "(Option C commit-forensics convention)."
287 )
288 return errors
289
290 value = m.group("value").strip()
291 if len(value.split()) < 2:
292 errors.append(
293 # OCT-LINT: disable=no_hardcoded_secrets reason="Regex patterns and commit-message remediation strings are high-entropy but not credentials"
294 "'AI-Model:' trailer must name both a model and a version "
295 "(e.g. 'AI-Model: Claude Opus 4.8')."
296 )
297
298 return errors
299
300
301def format_validation_errors(errors: list[str]) -> str:
302 """Format validation errors for display.
303
304 Returns a multi-line string with bullet points and an example.
305 """
306 lines = ["Commit message validation failed:"]
307 for err in errors:
308 lines.append(f" - {err}")
309 lines.append("")
310 lines.append("Example: fix(auth): prevent token leak in session logs")
311 return "\n".join(lines)
312
313
314# =====================================================================
315# Diagnostics impact analysis (blueprint §6.3 - ChatGPT contribution)
316# =====================================================================
317
318# Regex to extract _dbg("DOMAIN", ..., LEVEL) calls from diff lines.
319_DBG_CALL_RE = re.compile(
320 # OCT-LINT: disable=no_hardcoded_secrets reason="Regex patterns and commit-message remediation strings are high-entropy but not credentials"
321 r'_dbg\‍(\s*["\'](?P<domain>[A-Z_]+)["\']'
322 r'(?:\s*,\s*[^,]+)?' # skip func argument
323 r'(?:\s*,\s*[^,]+)?' # skip message argument
324 r'(?:\s*,\s*(?P<level>\d+))?' # optional level
325)
326
327
328def analyse_diagnostics_impact(diff_text: str) -> list[str]:
329 """Analyse a unified diff for ``_dbg``-related changes.
330
331 Scans added (``+``) and removed (``-``) lines for ``_dbg()`` calls.
332 Returns a list of human-readable impact lines, e.g.::
333
334 + Added: GIT domain (L2)
335 - Removed: AUTH domain (L3)
336
337 Returns an empty list if no ``_dbg`` changes are detected or if the
338 diff cannot be parsed. This function is best-effort and never raises.
339 """
340 func = "analyse_diagnostics_impact"
341 _dbg("GIT", func, "analysing the diff for diagnostics impact", 4)
342 try:
343 return _analyse_diagnostics_impact_inner(diff_text)
344 except Exception:
345 return []
346
347
348def _analyse_diagnostics_impact_inner(diff_text: str) -> list[str]:
349 """Inner implementation of diagnostics impact analysis."""
350 added: dict[str, set[str]] = {} # domain -> set of levels
351 removed: dict[str, set[str]] = {}
352
353 for line in diff_text.splitlines():
354 if line.startswith("+++") or line.startswith("---"):
355 continue
356
357 if line.startswith("+") and "_dbg(" in line:
358 m = _DBG_CALL_RE.search(line)
359 if m:
360 domain = m.group("domain")
361 level = m.group("level") or "?"
362 added.setdefault(domain, set()).add(f"L{level}")
363
364 elif line.startswith("-") and "_dbg(" in line:
365 m = _DBG_CALL_RE.search(line)
366 if m:
367 domain = m.group("domain")
368 level = m.group("level") or "?"
369 removed.setdefault(domain, set()).add(f"L{level}")
370
371 # Build impact lines.
372 impact: list[str] = []
373
374 # Domains only in added.
375 for domain in sorted(added.keys() - removed.keys()):
376 levels = ", ".join(sorted(added[domain]))
377 impact.append(f"+ Added: {domain} domain ({levels})")
378
379 # Domains only in removed.
380 for domain in sorted(removed.keys() - added.keys()):
381 levels = ", ".join(sorted(removed[domain]))
382 impact.append(f"- Removed: {domain} domain ({levels})")
383
384 # Domains in both - modifications.
385 for domain in sorted(added.keys() & removed.keys()):
386 old_levels = ", ".join(sorted(removed[domain]))
387 new_levels = ", ".join(sorted(added[domain]))
388 impact.append(f"~ Modified: {domain} ({old_levels} -> {new_levels})")
389
390 return impact
str format_validation_errors(list[str] errors)
list[str] analyse_diagnostics_impact(str diff_text)
str _strip_plan_prefix(str header)
ConventionalCommit|None parse_commit_message(str message)
list[str] validate_commit_message(str message)
list[str] validate_ai_model_trailer(str message)
list[str] _analyse_diagnostics_impact_inner(str diff_text)