8Encode the Phase 4B profile-aware enforcement rules for the ``oct git``
9quality gate. Maps a combination of profile (``proto`` / ``compact`` /
10``strict``), branch context, and raw check findings to a semantic exit
11code in the range 0..5 defined by blueprint §5.3.
15- Resolve the effective lint profile from CLI / octrc / branch context,
16 honouring the protected-branch override (blueprint §7.2).
17- Apply the profile enforcement matrix (blueprint §5.3 / §7.1) to a
18 :class:`~oct.git.quality_gate.QualityGateResult` and return the
20- Enforce the invariant "secrets always blocks" regardless of profile
21 (blueprint §5.3 final clause).
22- Manage the OCT-managed block in ``.gitignore`` — add/remove/list
23 patterns inside the fenced block, leaving user-managed lines alone.
29 L3 — profile resolution and policy decisions
30 L4 — per-field raw counts during exit-code derivation
34- This module is pure: no filesystem, no subprocess, no Click.
35- ``resolve_effective_profile`` never raises; unknown values collapse
36 to the documented default.
37- ``apply_profile_policy`` depends only on the ``QualityGateResult``
38 dataclass defined in :mod:`oct.git.quality_gate`.
39- Precedence for the exit code is strictly: 5 > 4 > 3 > 2 > 1 > 0.
42from __future__
import annotations
46from pathlib
import Path
47from typing
import TYPE_CHECKING, Literal
49from oct.core.diagnostics
import _dbg
60Profile = Literal[
"proto",
"compact",
"strict"]
64VALID_PROFILES: tuple[Profile, ...] = (
"proto",
"compact",
"strict")
69DEFAULT_PROFILE: Profile =
"compact"
74DEFAULT_PROTECTED_BRANCHES: tuple[str, ...] = (
"main",
"master")
85PROFILE_MATRIX: dict[Profile, dict[str, str]] = {
89 "secrets":
"blocking",
95 "secrets":
"blocking",
100 "format":
"blocking",
101 "secrets":
"blocking",
109SECRETS_ALWAYS_BLOCKS: bool =
True
114DEFAULT_BRANCH_PATTERNS: tuple[str, ...] = (
115 r"^(main|master|develop)$",
116 r"^(release|hotfix|feature|bugfix|docs|chore)/.+$",
122_AUTO_PROFILE_MAP: dict[str, Profile] = {
125 "release/":
"strict",
127 "feature/":
"compact",
128 "bugfix/":
"compact",
135_COMMIT_TYPE_TO_PREFIX: dict[str, str] = {
148_BRANCH_SLUG_MAX_LEN: int = 50
157 """Derive a valid branch name from a Conventional Commits *message*.
159 Returns a branch name like ``feature/auth-add-jwt-tokens`` or
160 ``None`` when the message cannot be parsed.
162 The generated name is guaranteed to pass
163 :func:`validate_branch_name` against the default patterns.
165 func =
"generate_branch_name_from_message"
166 _dbg(
"GIT", func, f
"message={message[:60]!r}", 3)
170 parsed = parse_commit_message(message)
172 _dbg(
"GIT", func,
"parse failed — returning None", 4)
175 prefix = _COMMIT_TYPE_TO_PREFIX.get(parsed.type,
"chore")
178 parts: list[str] = []
180 parts.append(parsed.scope)
181 parts.append(parsed.subject)
182 raw_slug =
"-".join(parts).lower()
185 slug = re.sub(
r"[^a-z0-9-]",
"-", raw_slug)
186 slug = re.sub(
r"-{2,}",
"-", slug)
187 slug = slug.strip(
"-")
190 _dbg(
"GIT", func,
"empty slug — returning None", 4)
194 if len(slug) > _BRANCH_SLUG_MAX_LEN:
195 slug = slug[:_BRANCH_SLUG_MAX_LEN].rstrip(
"-")
197 name = f
"{prefix}/{slug}"
202 _dbg(
"GIT", func, f
"generated name {name!r} invalid — returning None", 3)
205 _dbg(
"GIT", func, f
"result={name!r}", 4)
215 """Return True if ``branch`` is listed in ``git.protected_branches``.
217 A missing ``octrc`` or a missing ``git.protected_branches`` key
218 falls back to :data:`DEFAULT_PROTECTED_BRANCHES`. Detached HEAD
219 (``branch is None``) is never considered protected — detached
220 workflows are per-definition outside branch policy.
222 func =
"is_protected_branch"
223 _dbg(
"GIT", func, f
"branch={branch!r}", 3)
224 if branch
is None or not branch:
230 protected: list[str] | tuple[str, ...] = DEFAULT_PROTECTED_BRANCHES
231 if isinstance(octrc, dict):
232 git_cfg = octrc.get(
"git")
233 if isinstance(git_cfg, dict):
234 candidate = git_cfg.get(
"protected_branches")
235 if isinstance(candidate, list):
238 protected = [b
for b
in candidate
if isinstance(b, str)]
239 result = branch
in protected
240 _dbg(
"GIT", func, f
"result={result} protected={list(protected)}", 4)
252) -> tuple[bool, str]:
253 """Validate *branch* against the configured naming patterns.
255 Returns ``(valid, message)``:
257 - ``git.branch_naming.enabled`` is ``False`` → ``(True, "")``
258 - ``branch`` is ``None`` (detached HEAD) → ``(True, "")``
259 - Branch matches at least one pattern → ``(True, "")``
260 - No pattern matches → ``(False, "Branch name '...' does not ...")``
262 Patterns default to :data:`DEFAULT_BRANCH_PATTERNS` when not
263 overridden in ``.octrc.json``.
265 func =
"validate_branch_name"
266 _dbg(
"GIT", func, f
"branch={branch!r}", 3)
268 if branch
is None or branch ==
"HEAD":
273 patterns: list[str] = list(DEFAULT_BRANCH_PATTERNS)
275 if isinstance(octrc, dict):
276 git_cfg = octrc.get(
"git")
277 if isinstance(git_cfg, dict):
278 naming_cfg = git_cfg.get(
"branch_naming")
279 if isinstance(naming_cfg, dict):
280 raw_enabled = naming_cfg.get(
"enabled")
281 if isinstance(raw_enabled, bool):
282 enabled = raw_enabled
283 raw_patterns = naming_cfg.get(
"patterns")
284 if isinstance(raw_patterns, list):
285 patterns = [p
for p
in raw_patterns
if isinstance(p, str)]
288 _dbg(
"GIT", func,
"branch naming disabled", 4)
295 for pattern
in patterns:
297 if re.match(pattern, branch):
298 _dbg(
"GIT", func, f
"matched: {pattern!r}", 4)
304 f
"Branch name '{branch}' does not match any allowed pattern. "
305 f
"Expected patterns: {', '.join(patterns)}"
307 _dbg(
"GIT", func, f
"invalid: {msg}", 3)
317 """Map *branch* to a profile using :data:`_AUTO_PROFILE_MAP`.
319 Exact matches (``main``, ``master``) are tried first, then prefix
320 matches (``feature/``, ``release/``). Returns :data:`DEFAULT_PROFILE`
321 for unrecognised patterns or ``None`` / ``"HEAD"`` branch.
323 func =
"_resolve_auto_profile"
324 if branch
is None or branch ==
"HEAD":
325 return DEFAULT_PROFILE
328 exact = _AUTO_PROFILE_MAP.get(branch)
329 if exact
is not None:
330 _dbg(
"GIT", func, f
"exact match: {branch!r} -> {exact}", 4)
334 for prefix, profile
in _AUTO_PROFILE_MAP.items():
335 if prefix.endswith(
"/")
and branch.startswith(prefix):
336 _dbg(
"GIT", func, f
"prefix match: {prefix!r} -> {profile}", 4)
339 _dbg(
"GIT", func, f
"no match for {branch!r}, fallback -> {DEFAULT_PROFILE}", 4)
340 return DEFAULT_PROFILE
346 cli_override: str |
None,
348 """Return the profile that should be applied for the current run.
350 Precedence (highest wins):
352 1. **Protected-branch override** — if ``branch`` is listed in the
353 project's protected-branches set, the result is always
354 ``"strict"``. This cannot be overridden by CLI or config.
355 2. **CLI ``--profile``** — explicit user override from the command
356 line. Must be one of :data:`VALID_PROFILES`.
357 3. **``.octrc.json → git.pre_commit_profile``** — the persistent
359 4. **``.octrc.json → linter.profile``** — if the git-specific key
360 is absent, fall back to the linter-wide profile.
361 5. **:data:`DEFAULT_PROFILE`** — final fallback if nothing above
364 Unknown profile strings (from any source) are silently discarded in
365 favour of the next lower level of precedence. Malformed ``octrc``
366 values are also ignored — never raise.
368 func =
"resolve_effective_profile"
371 f
"branch={branch!r} cli_override={cli_override!r}",
377 _dbg(
"GIT", func,
"protected branch -> strict", 4)
381 if cli_override
and cli_override
in VALID_PROFILES:
382 _dbg(
"GIT", func, f
"cli override -> {cli_override}", 4)
386 if isinstance(octrc, dict):
387 git_cfg = octrc.get(
"git")
388 if isinstance(git_cfg, dict):
389 candidate = git_cfg.get(
"pre_commit_profile")
390 if isinstance(candidate, str):
391 if candidate ==
"auto":
394 f
"Auto-selected profile: {resolved} (branch: {branch})",
397 _dbg(
"GIT", func, f
"auto -> {resolved}", 4)
399 if candidate
in VALID_PROFILES:
400 _dbg(
"GIT", func, f
"git.pre_commit_profile -> {candidate}", 4)
402 linter_cfg = octrc.get(
"linter")
403 if isinstance(linter_cfg, dict):
404 candidate = linter_cfg.get(
"profile")
405 if isinstance(candidate, str)
and candidate
in VALID_PROFILES:
406 _dbg(
"GIT", func, f
"linter.profile -> {candidate}", 4)
410 _dbg(
"GIT", func, f
"fallback -> {DEFAULT_PROFILE}", 4)
411 return DEFAULT_PROFILE
420 """Derive the semantic exit code for a quality-gate result.
422 Exit code table (blueprint §5.3, verbatim):
424 ==== ==========================================================
426 ==== ==========================================================
428 1 lint violations (blocking under this profile)
429 2 format violations (blocking under this profile)
430 3 both lint and format violations
431 4 test failures (when ``--include-tests``)
432 5 secrets detected — always blocking
433 ==== ==========================================================
435 Precedence: ``5 > 4 > 3 > 2 > 1 > 0``. The first rule that matches
436 wins. Because secrets takes precedence, a profile that would
437 otherwise return 0 (e.g. ``proto`` with warnings-only lint) still
438 returns 5 if secrets are present. This is the "secrets always
441 For profiles where a check is not blocking (``proto`` lint is
442 ``warn``; ``proto`` format is ``skip``), the corresponding violation
443 counter never contributes to the exit code even if non-zero.
448 One of ``"proto"``, ``"compact"``, ``"strict"``. Unknown values
449 degrade to :data:`DEFAULT_PROFILE`.
451 A :class:`~oct.git.quality_gate.QualityGateResult` with the raw
452 counts from a completed gate run.
454 func =
"apply_profile_policy"
455 if profile
not in PROFILE_MATRIX:
456 profile = DEFAULT_PROFILE
457 matrix = PROFILE_MATRIX[profile]
459 lint_blocking = matrix[
"lint"] ==
"blocking"
460 format_blocking = matrix[
"format"] ==
"blocking"
464 tests_blocking = matrix[
"tests"] ==
"blocking"
466 has_secrets = bool(result.secrets_findings)
467 has_lint = result.lint_violations > 0
and lint_blocking
468 has_format = result.format_violations > 0
and format_blocking
469 has_tests = result.test_failures > 0
and tests_blocking
473 f
"profile={profile} secrets={has_secrets} lint={has_lint} "
474 f
"format={has_format} tests={has_tests}",
483 if has_lint
and has_format:
501) -> tuple[bool, str]:
502 """Check if committing directly to a protected branch.
504 Returns ``(blocked, message)``:
506 - On a **non-protected branch**: ``(False, "")``.
507 - On a protected branch in ``strict`` profile: ``(True, error_msg)``.
508 - On a protected branch in other profiles: ``(False, warning_msg)``.
510 The caller is responsible for printing the message and aborting if
511 ``blocked`` is ``True``.
513 func =
"check_protected_branch_commit"
518 f
"Warning: committing directly to protected branch '{branch}'."
520 _dbg(
"GIT", func, f
"branch={branch!r} profile={profile}", 3)
522 if profile ==
"strict":
524 f
"Blocked: direct commit to protected branch '{branch}' "
525 f
"is not allowed under strict profile."
537OC_GITIGNORE_MARKER_START: str =
"# --- Option C defaults ---"
538OC_GITIGNORE_MARKER_END: str =
"# --- end Option C ---"
543_VALID_PATTERN_RE: re.Pattern[str] = re.compile(
r"^[A-Za-z0-9_./*?\[\]\-]+$")
547 """Return ``(valid, message)`` for a candidate gitignore pattern.
549 Rejects empty strings, patterns containing newlines, leading ``!``
550 (negation — too easy to weaponise via ``oct git ignore``), absolute
551 paths (leading ``/`` is interpreted as anchored to repo root which
552 is allowed by gitignore but our policy is to require relative
553 patterns), and any character outside :data:`_VALID_PATTERN_RE`.
555 func =
"validate_ignore_pattern"
556 if not isinstance(pattern, str):
557 return False, f
"pattern must be a string, got {type(pattern).__name__}"
558 if not pattern.strip():
559 return False,
"pattern is empty"
560 if "\n" in pattern
or "\r" in pattern:
561 return False,
"pattern contains a newline"
562 if pattern.startswith(
"!"):
563 return False,
"negation patterns ('!...') are not allowed"
564 if pattern.startswith(
"/"):
565 return False,
"absolute patterns (leading '/') are not allowed"
566 if not _VALID_PATTERN_RE.match(pattern):
568 "pattern contains characters outside the safe set "
569 "[A-Za-z0-9_./*?[]-]"
571 _dbg(
"GIT", func, f
"pattern={pattern!r} ok", 4)
576 """Return the lines of ``.gitignore`` (no trailing newlines)."""
577 path = Path(repo_root) /
".gitignore"
578 if not path.is_file():
580 return path.read_text(encoding=
"utf-8").splitlines()
584 """Write *lines* back to ``.gitignore`` with trailing newline."""
585 path = Path(repo_root) /
".gitignore"
586 body =
"\n".join(lines)
587 if body
and not body.endswith(
"\n"):
589 path.write_text(body, encoding=
"utf-8")
593 """Return ``(start, end)`` line indices of the OCT block in *lines*.
595 ``start`` is the index of the start marker, ``end`` the index of
596 the end marker (inclusive). Returns ``(-1, -1)`` if the block is
597 absent or malformed (start without end).
601 for idx, line
in enumerate(lines):
602 stripped = line.strip()
603 if stripped == OC_GITIGNORE_MARKER_START:
605 elif stripped == OC_GITIGNORE_MARKER_END
and start != -1:
608 if start == -1
or end == -1:
614 """Return ``(oct_managed, other)`` patterns from ``.gitignore``.
616 Patterns inside the OCT-managed block are returned in the first
617 list; everything else (user-managed) is returned in the second.
618 Comment lines and blank lines are excluded from both.
620 func =
"list_ignore_patterns"
621 _dbg(
"GIT", func, f
"repo_root={repo_root}", 3)
626 oct_managed: list[str] = []
627 other: list[str] = []
628 for idx, line
in enumerate(lines):
629 stripped = line.strip()
630 if not stripped
or stripped.startswith(
"#"):
632 if start != -1
and start < idx < end:
633 oct_managed.append(stripped)
635 other.append(stripped)
638 f
"oct={len(oct_managed)} other={len(other)}",
641 return oct_managed, other
648 """Append *patterns* to the OCT-managed block of ``.gitignore``.
650 Creates the block (with markers) if it does not exist. Patterns
651 already present anywhere in the file are skipped. Returns the list
652 of patterns actually added (in input order, after dedup).
654 Caller is responsible for validating each pattern via
655 :func:`validate_ignore_pattern` first.
657 func =
"add_ignore_patterns"
658 _dbg(
"GIT", func, f
"repo_root={repo_root} patterns={len(patterns)}", 3)
660 existing: set[str] = {
661 line.strip()
for line
in lines
662 if line.strip()
and not line.strip().startswith(
"#")
664 to_add = [p
for p
in patterns
if p
not in existing]
666 _dbg(
"GIT", func,
"no new patterns to add", 4)
672 new_lines = list(lines)
673 if new_lines
and new_lines[-1].strip() !=
"":
675 new_lines.append(OC_GITIGNORE_MARKER_START)
676 new_lines.extend(to_add)
677 new_lines.append(OC_GITIGNORE_MARKER_END)
680 new_lines = list(lines[:end]) + to_add + list(lines[end:])
683 _dbg(
"GIT", func, f
"added {len(to_add)} pattern(s)", 4)
690) -> tuple[bool, str]:
691 """Remove *pattern* from the OCT-managed block.
693 Returns ``(removed, message)``. Refuses to touch patterns located
694 outside the OCT block (those are user-managed); the caller should
695 surface the message to the user.
697 func =
"remove_ignore_pattern"
698 _dbg(
"GIT", func, f
"repo_root={repo_root} pattern={pattern!r}", 3)
701 return False,
".gitignore does not exist"
704 return False,
"OCT-managed block not found in .gitignore"
707 for idx
in range(start + 1, end):
708 if lines[idx].strip() == pattern:
714 for idx, line
in enumerate(lines):
715 if line.strip() == pattern
and not (start < idx < end):
717 f
"pattern '{pattern}' is outside the OCT-managed "
718 f
"block; remove it manually with a text editor"
720 return False, f
"pattern '{pattern}' not found in OCT-managed block"
722 new_lines = list(lines[:target_idx]) + list(lines[target_idx + 1:])
724 _dbg(
"GIT", func, f
"removed pattern at line {target_idx}", 4)
str|None generate_branch_name_from_message(str message)
Profile _resolve_auto_profile(str|None branch)
tuple[int, int] _find_oct_block(list[str] lines)
tuple[bool, str] remove_ignore_pattern(Path repo_root, str pattern)
int apply_profile_policy(str profile, "QualityGateResult" result)
tuple[bool, str] validate_branch_name(str|None branch, dict|None octrc)
list[str] add_ignore_patterns(Path repo_root, list[str] patterns)
list[str] _read_gitignore(Path repo_root)
bool is_protected_branch(str|None branch, dict|None octrc)
Profile resolve_effective_profile(str|None branch, dict|None octrc, str|None cli_override)
None _write_gitignore(Path repo_root, list[str] lines)
tuple[bool, str] validate_ignore_pattern(str pattern)
tuple[bool, str] check_protected_branch_commit(str|None branch, dict|None octrc, str profile)
tuple[list[str], list[str]] list_ignore_patterns(Path repo_root)