Option C Tools
Loading...
Searching...
No Matches
quality_gate.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/git/quality_gate.py
4
5"""
6Purpose
7-------
8Orchestrate the Phase 4B unified quality gate: lint + format dry-run +
9secrets detection on a scoped set of files. Returns a structured
10:class:`QualityGateResult` that downstream consumers (``oct git check``,
11``oct git commit``, hooks, MCP) can inspect and render.
12
13Responsibilities
14----------------
15- Resolve file scope (staged / changed / all) via :mod:`oct.core.git`.
16- Run the linter on Python files via :func:`oct.linter.oct_lint._lint_single_file`.
17- Run the formatter in dry-run mode via :func:`oct.formatter.oct_format.format_file`.
18- Run secrets detection (content + filename pattern) via
19 :func:`oct.linter.oct_lint.check_no_hardcoded_secrets` and
20 :data:`oct.core.exclusions.NEVER_EXPORT_FILE_PATTERNS`.
21- Optionally run pytest via subprocess (``--include-tests``).
22- Support ``--fix`` two-pass mode: detect → fix → re-verify.
23
24Diagnostics
25-----------
26Domain: GIT
27Levels:
28 L2 — lifecycle: gate start/end, scope resolution
29 L3 — details: per-pass summary counts
30 L4 — deep trace: per-file check outcomes
31
32Contracts
33---------
34- This module does not import ``click``. It is a pure orchestrator.
35- It calls existing library functions (linter, formatter, exclusions)
36 directly; it never shells out to ``oct lint`` or ``oct format``.
37- The only subprocess call is for pytest (``--include-tests``), which is
38 a distinct tool boundary.
39- :func:`run_quality_gate` never raises on check failures; it returns
40 a :class:`QualityGateResult` with the findings. Only infrastructure
41 errors (missing git repo, broken imports) may propagate.
42"""
43
44from __future__ import annotations
45
46import fnmatch
47import subprocess
48import sys
49import time
50from dataclasses import dataclass, field
51from pathlib import Path
52from typing import Literal
53
54from oct.core.diagnostics import _dbg
55from oct.core.paths import to_project_relative
56
57
58# ---------------------------------------------------------------------
59# Data model
60# ---------------------------------------------------------------------
61
62
63@dataclass
65 """Structured output from a quality-gate run.
66
67 Every field is JSON-serialisable (primitives, lists of tuples/dicts).
68 Field names must match the attribute access in
69 :func:`oct.git.policy.apply_profile_policy`.
70 """
71
72 files_checked: int = 0
73 lint_violations: int = 0
74 format_violations: int = 0
75 secrets_findings: list = field(default_factory=list)
76 """List of ``(path_str, line, reason)`` tuples."""
77 test_failures: int = 0
78 duration_ms: int = 0
79 exit_code: int = 0
80 per_file: list = field(default_factory=list)
81 """Per-file dicts suitable for ``--json`` rendering."""
82
83
84@dataclass(frozen=True)
86 """OI-527: structured outcome of the three pre-gate checks that run
87 before ``oct git commit`` executes the quality gate.
88
89 When :attr:`ok` is True the caller proceeds to the quality gate using
90 :attr:`staged_files`; the other fields carry context the command
91 needs regardless (branch, effective_profile). When :attr:`ok` is
92 False the caller emits :attr:`errors` on stderr (or as JSON) and
93 exits with :attr:`exit_code`.
94
95 :attr:`pb_advisory` is a protected-branch advisory string that must
96 be echoed *whether or not* the check blocked the commit; callers
97 treat it as a side-channel warning.
98 """
99
100 ok: bool
101 exit_code: int
102 errors: list
103 branch: str
104 effective_profile: str
105 staged_files: list
106 pb_advisory: str = ""
107
108
110 project_root: Path,
111 branch: str,
112 message: str,
113 force_commit: bool,
114 profile: str,
115 octrc: dict,
116) -> PreCommitResult:
117 """OI-527: run the three pre-gate checks and collapse their outcomes
118 into a single :class:`PreCommitResult`.
119
120 The helper replaces the four sequential early-return blocks that
121 previously lived in ``git_commit_cmd`` (commit-message validation,
122 staged-files presence, protected-branch guard). Exit codes and side
123 effects match the original CLI behaviour byte-for-byte:
124 * ``exit_code == 3`` — invalid commit message (skipped when
125 ``force_commit`` is True).
126 * ``exit_code == 4`` — no staged files.
127 * ``exit_code == 1`` — protected-branch block.
128 """
129 from oct.core.git import git_staged_files
130 from oct.git.conventional import validate_commit_message
131 from oct.git.policy import (
132 check_protected_branch_commit,
133 resolve_effective_profile,
134 )
135
136 effective_profile = resolve_effective_profile(branch, octrc, profile)
137
138 if not force_commit:
139 msg_errors = validate_commit_message(message)
140 if msg_errors:
141 return PreCommitResult(
142 ok=False,
143 exit_code=3,
144 errors=list(msg_errors),
145 branch=branch,
146 effective_profile=effective_profile,
147 staged_files=[],
148 )
149
150 staged = git_staged_files(project_root)
151 if not staged:
152 return PreCommitResult(
153 ok=False,
154 exit_code=4,
155 errors=["Nothing to commit."],
156 branch=branch,
157 effective_profile=effective_profile,
158 staged_files=[],
159 )
160
161 blocked, pb_message = check_protected_branch_commit(
162 branch, octrc, effective_profile,
163 )
164 if blocked:
165 return PreCommitResult(
166 ok=False,
167 exit_code=1,
168 errors=[pb_message] if pb_message else [],
169 branch=branch,
170 effective_profile=effective_profile,
171 staged_files=list(staged),
172 pb_advisory=pb_message or "",
173 )
174
175 return PreCommitResult(
176 ok=True,
177 exit_code=0,
178 errors=[],
179 branch=branch,
180 effective_profile=effective_profile,
181 staged_files=list(staged),
182 pb_advisory=pb_message or "",
183 )
184
185
186# ---------------------------------------------------------------------
187# Scope resolution
188# ---------------------------------------------------------------------
189
190
192 project_root: Path,
193 scope: Literal["staged", "changed", "all"],
194) -> list[Path]:
195 """Return the list of files to check based on *scope*.
196
197 For ``"staged"`` and ``"changed"`` we return **all** file types so
198 that filename-pattern secrets detection can flag non-Python files
199 like ``.env`` or ``*.pem``. The caller filters to ``.py`` for the
200 lint and format passes.
201
202 For ``"all"`` we use the linter's own ``find_python_files`` for the
203 Python set, but also walk the tree for non-Python secret-filename
204 checks.
205 """
206 func = "_resolve_scope"
207 _dbg("GIT", func, f"scope={scope} root={project_root}", 2)
208
209 if scope == "staged":
210 from oct.core.git import git_staged_files
211 files = git_staged_files(project_root)
212 _dbg("GIT", func, f"staged files={len(files)}", 3)
213 return files
214
215 if scope == "changed":
216 from oct.core.git import git_changed_files
217 files = git_changed_files(project_root)
218 _dbg("GIT", func, f"changed files={len(files)}", 3)
219 return files
220
221 # scope == "all"
222 from oct.core.exclusions import (
223 LINTER_EXCLUDE_DIRS,
224 NEVER_EXPORT_FILE_PATTERNS,
225 WILDCARD_EXCLUDE_DIRS,
226 )
227 from oct.linter.oct_lint import find_python_files
228 py_files = list(find_python_files(
229 project_root,
230 exclude_dirs=LINTER_EXCLUDE_DIRS,
231 wildcard_exclude_dirs=WILDCARD_EXCLUDE_DIRS,
232 ))
233 # OI-505: the secrets pass uses filename-pattern matching to catch
234 # ``.env``, ``*.pem``, etc. At ``scope="all"`` we previously only
235 # returned ``.py`` files, so those files were invisible to the gate.
236 # Walk the tree once more for any filename that matches the secret
237 # patterns, honouring the same exclusion lists as the linter.
238 secret_files = list(_walk_secret_filenames(
239 project_root,
240 exclude_dirs=LINTER_EXCLUDE_DIRS,
241 wildcard_exclude_dirs=WILDCARD_EXCLUDE_DIRS,
242 patterns=NEVER_EXPORT_FILE_PATTERNS,
243 ))
244 _dbg(
245 "GIT", func,
246 f"all python files={len(py_files)} secret filename candidates="
247 f"{len(secret_files)}",
248 3,
249 )
250 return py_files + secret_files
251
252
254 project_root: Path,
255 *,
256 exclude_dirs,
257 wildcard_exclude_dirs,
258 patterns,
259) -> list[Path]:
260 """Yield non-Python files whose name matches any ``patterns`` glob.
261
262 Uses ``os.walk`` with in-place pruning of ``dirnames`` so excluded
263 trees are never descended into. Mirrors the exclusion semantics of
264 :func:`oct.linter.oct_lint.find_python_files` — if a directory name
265 is in ``exclude_dirs`` (exact) or matches one of the wildcard entries
266 (prefix/suffix/glob), it is pruned from the walk.
267 """
268 import os
269
270 exact = set(exclude_dirs or ())
271 wildcards = tuple(wildcard_exclude_dirs or ())
272 results: list[Path] = []
273
274 def _should_exclude_dir(name: str) -> bool:
275 if name in exact:
276 return True
277 for pat in wildcards:
278 if fnmatch.fnmatch(name, pat):
279 return True
280 return False
281
282 for dirpath, dirnames, filenames in os.walk(project_root):
283 dirnames[:] = [d for d in dirnames if not _should_exclude_dir(d)]
284 for name in filenames:
285 if name.endswith(".py"):
286 continue # Already captured by find_python_files.
287 for pat in patterns:
288 if fnmatch.fnmatch(name, pat):
289 results.append(Path(dirpath) / name)
290 break
291 return results
292
293
294# ---------------------------------------------------------------------
295# Individual check passes
296# ---------------------------------------------------------------------
297
298
299def _build_linter_context(project_root: Path):
300 """Build a minimal ``LinterContext`` for the quality gate.
301
302 We import lazily to avoid pulling Click and the full linter on
303 import of this module.
304 """
305 from oct.linter.oct_lint import LinterContext
306
307 project_name = project_root.name
308 return LinterContext(
309 project_root=project_root,
310 project_name=project_name,
311 diagnostics_dir=project_root / "oc_diagnostics",
312 tests_dir=project_root / "tests",
313 docs_dir=project_root / "docs",
314 )
315
316
317def _build_formatter_context(project_root: Path, *, fix_mode: bool = False):
318 """Build a ``FormatterContext`` for dry-run (or fix) mode."""
319 from oct.formatter.oct_format import FormatterContext
320
321 return FormatterContext(
322 project_root=project_root,
323 project_name=project_root.name,
324 diagnostics_dir=project_root / "oc_diagnostics",
325 tests_dir=project_root / "tests",
326 docs_dir=project_root / "docs",
327 fix_mode=fix_mode,
328 dry_run_mode=not fix_mode,
329 json_mode=False,
330 verbose=False,
331 )
332
333
335 all_files: list[Path],
336 project_root: Path,
337 *,
338 code_entropy_threshold: float = 4.5,
339 docstring_entropy_threshold: float = 5.0,
340 comment_entropy_threshold: float = 5.0,
341 secret_match_mode: str = "substring", # OCT-LINT: disable=no_hardcoded_secrets reason="Param documents the matcher mode, not a credential"
342 entropy_exclusions: list[dict] | None = None,
343 excluded_field_patterns: list[str] | None = None,
344) -> list[tuple[str, int, str]]:
345 """Check files for hardcoded secrets (content) and secret filenames.
346
347 Returns a list of ``(relative_path, line, reason)`` tuples.
348 """
349 func = "_run_secrets_pass"
350 from oct.core.exclusions import NEVER_EXPORT_FILE_PATTERNS
351 # OI-514: call the standalone scanner directly — no detour through the
352 # linter module just to reach these helpers.
353 from oct.tools.secret_scanner import check_no_hardcoded_secrets
354
355 findings: list[tuple[str, int, str]] = []
356
357 for path in all_files:
358 rel = to_project_relative(path, project_root)
359
360 # Filename-pattern check (catches .env, *.pem, *.key, etc.).
361 name = path.name
362 for pattern in NEVER_EXPORT_FILE_PATTERNS:
363 if fnmatch.fnmatch(name, pattern):
364 findings.append((rel, 0, f"secret file pattern: {pattern}"))
365 _dbg("GIT", func, f"secret filename {rel} ~ {pattern}", 4)
366 break
367
368 # Content check (Python files only — AST-based detection).
369 if path.suffix == ".py" and path.is_file():
370 try:
371 text = path.read_text(encoding="utf-8")
372
373 # OI-540: path-based entropy exclusions. Both
374 # ``test_directory`` and ``module_self_doc`` contexts
375 # honour the same ``exempt_entropy_only`` /
376 # ``require_waiver_for_secret_patterns`` restriction
377 # semantics. ``module_self_doc`` is for source files
378 # whose code is dominated by help-text / template /
379 # scaffold string constants.
380 file_entropy = code_entropy_threshold
381 require_waiver = False
382 for excl in (entropy_exclusions or []):
383 if excl.get("context") in ("test_directory", "module_self_doc"):
384 pattern_str = excl.get("path_pattern", "")
385 restrictions = excl.get("restrictions", {})
386 if pattern_str and rel.replace("\\", "/").startswith(pattern_str):
387 if restrictions.get("exempt_entropy_only"):
388 file_entropy = 0
389 if restrictions.get("require_waiver_for_secret_patterns"):
390 require_waiver = True
391 break
392
393 passed, message = check_no_hardcoded_secrets(
394 text,
395 mode=secret_match_mode,
396 code_entropy_threshold=file_entropy,
397 docstring_entropy_threshold=docstring_entropy_threshold,
398 comment_entropy_threshold=comment_entropy_threshold,
399 excluded_field_patterns=excluded_field_patterns,
400 )
401 if not passed:
402 # OI-540: honour inline waivers universally in the
403 # quality gate, matching the linter's existing
404 # behaviour. Previously waivers were only checked
405 # when ``require_waiver`` was set by a test_directory
406 # exclusion — a gap exposed when the waiver regex
407 # was extracted to oct.core.waivers (its own pattern
408 # string legitimately exceeds the entropy threshold).
409 from oct.core.waivers import parse_inline_waivers
410 waivers = parse_inline_waivers(text)
411 waived = any(
412 w["rule"] == "no_hardcoded_secrets" and w["reason"]
413 for w in waivers.values()
414 )
415 if waived:
416 _dbg("GIT", func, f"waiver accepted {rel}", 4)
417 continue
418 findings.append((rel, 0, message))
419 _dbg("GIT", func, f"secret content {rel}: {message}", 4)
420 except (OSError, UnicodeDecodeError) as exc:
421 _dbg("GIT", func, f"read error {rel}: {exc}", 1)
422
423 _dbg("GIT", func, f"total findings={len(findings)}", 3)
424 return findings
425
426
428 py_files: list[Path],
429 project_root: Path,
430 profile: str,
431 fix_mode: bool = False,
432) -> tuple[int, list[dict]]:
433 """Run the linter on *py_files* and return (violation_count, per_file).
434
435 Uses ``_lint_single_file`` directly, bypassing the CLI. The profile
436 selects which rules are active via ``_PROFILES``.
437 """
438 func = "_run_lint_pass"
439 from oct.linter.oct_lint import _PROFILES, _lint_single_file
440
441 ctx = _build_linter_context(project_root)
442 active_rules = dict(_PROFILES.get(profile, _PROFILES.get("compact", {})))
443
444 violations = 0
445 per_file: list[dict] = []
446
447 for path in py_files:
448 if not path.is_file():
449 continue
450 try:
451 result = _lint_single_file(path, ctx, active_rules, fix_mode)
452 if not result.get("compliant", True):
453 violations += 1
454 per_file.append(result)
455 _dbg(
456 "GIT", func,
457 f"lint {result.get('rel', '?')} compliant={result.get('compliant')}",
458 4,
459 )
460 except Exception as exc: # pragma: no cover - defensive
461 _dbg("GIT", func, f"lint error {path}: {exc}", 1)
462
463 _dbg("GIT", func, f"violations={violations} files={len(py_files)}", 3)
464 return violations, per_file
465
466
468 py_files: list[Path],
469 project_root: Path,
470 fix_mode: bool = False,
471) -> int:
472 """Run the formatter in dry-run (or fix) mode, return violation count.
473
474 A "violation" is a file where ``format_file`` reports ``changed=True``
475 (i.e. the formatter would modify it).
476 """
477 func = "_run_format_pass"
478 from oct.formatter.oct_format import format_file
479
480 ctx = _build_formatter_context(project_root, fix_mode=fix_mode)
481 violations = 0
482
483 for path in py_files:
484 if not path.is_file():
485 continue
486 try:
487 result = format_file(path, ctx)
488 if result.get("changed", False):
489 violations += 1
490 _dbg("GIT", func, f"format change {result.get('path', '?')}", 4)
491 except Exception as exc: # pragma: no cover - defensive
492 _dbg("GIT", func, f"format error {path}: {exc}", 1)
493
494 _dbg("GIT", func, f"violations={violations} files={len(py_files)}", 3)
495 return violations
496
497
498def _run_test_pass(project_root: Path, sandbox: bool = False) -> int:
499 """Run ``python -m pytest tests/`` and return the failure count.
500
501 Returns 0 if all tests pass (exit code 0), or 1 if any test fails
502 (non-zero exit code). This is a subprocess boundary — tests are a
503 distinct tool and their execution is not inline.
504
505 When ``sandbox=True`` (OI-517), the pytest subprocess runs under a
506 sanitised environment via :class:`oct.mcp.sandbox.SandboxExecutor`:
507 secret-named env vars are stripped, ``PYTHONPATH`` is dropped, and a
508 600s wall-clock ceiling is enforced. Output is capped at 1 MiB so a
509 runaway test cannot flood stderr.
510 """
511 func = "_run_test_pass"
512 _dbg("GIT", func, f"starting test pass sandbox={sandbox}", 2)
513
514 cmd = [sys.executable, "-m", "pytest", "tests/", "-x", "--tb=no", "-q"]
515 try:
516 if sandbox:
517 from oct.mcp.sandbox import NativeBackend, SandboxExecutor
518 executor = SandboxExecutor(backend=NativeBackend())
519 sandbox_result = executor.run(cmd, cwd=project_root, timeout=600)
520 returncode = sandbox_result.exit_code
521 if sandbox_result.timed_out:
522 _dbg("GIT", func, "sandbox pytest timed out", 1)
523 else:
524 result = subprocess.run(
525 cmd,
526 cwd=project_root,
527 capture_output=True,
528 text=True,
529 timeout=300,
530 )
531 returncode = result.returncode
532 failures = 0 if returncode == 0 else 1
533 _dbg("GIT", func, f"test exit_code={returncode}", 3)
534 return failures
535 except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
536 _dbg("GIT", func, f"test pass error: {exc}", 1)
537 return 1
538
539
540# ---------------------------------------------------------------------
541# Main orchestrator
542# ---------------------------------------------------------------------
543
544
546 project_root: Path,
547 scope: Literal["staged", "changed", "all"],
548 profile: Literal["proto", "compact", "strict"],
549 include_tests: bool = False,
550 fix: bool = False,
551 sandbox: bool = False,
552) -> QualityGateResult:
553 """Orchestrate lint + format-dry-run + secrets on the scoped files.
554
555 When ``fix=True``, runs in two passes:
556
557 1. **Detect pass:** run the gate with ``fix=False`` to record the
558 initial state.
559 2. **Fix pass:** run lint and format with ``fix_mode=True`` to apply
560 auto-fixes.
561 3. **Re-verify pass:** run the gate again with ``fix=False``. The
562 returned result reflects the post-fix state.
563
564 Secrets are **never** auto-fixed (no safe auto-fix for a hardcoded
565 password). The ``--fix`` flag does not bypass the always-blocking
566 secrets rule.
567
568 Parameters
569 ----------
570 project_root
571 Resolved project root containing ``.octrc.json``, ``tests/``, etc.
572 scope
573 ``"staged"`` checks only the git index; ``"changed"`` checks
574 staged + unstaged + untracked; ``"all"`` checks every Python file.
575 profile
576 Lint profile: ``"proto"`` / ``"compact"`` / ``"strict"``.
577 include_tests
578 If True, also run ``pytest`` as a subprocess (exit code 4 on fail).
579 fix
580 If True, attempt auto-fix of lint/format violations, then re-check.
581 sandbox
582 OI-517: when True and ``include_tests`` is True, the pytest
583 subprocess runs under the MCP sandbox environment — sanitised
584 env, no network side-channels via PYTHONPATH, output capped.
585 """
586 func = "run_quality_gate"
587 _dbg("GIT", func, f"start scope={scope} profile={profile} fix={fix}", 2)
588
589 # F1.3: scope-resolution is the only step that differs between this
590 # entry point and run_quality_gate_on_files. After resolving the
591 # scope we delegate to the file-list variant so per-subproject
592 # fan-out can call the same machinery without re-discovering files.
593 all_files = _resolve_scope(project_root, scope)
595 project_root=project_root,
596 files=all_files,
597 profile=profile,
598 include_tests=include_tests,
599 fix=fix,
600 sandbox=sandbox,
601 )
602
603
605 project_root: Path,
606 files: list[Path],
607 profile: Literal["proto", "compact", "strict"],
608 include_tests: bool = False,
609 fix: bool = False,
610 sandbox: bool = False,
611) -> QualityGateResult:
612 """Run the quality gate on an explicit file list.
613
614 Identical contract to :func:`run_quality_gate` but accepts a
615 pre-resolved list of files instead of computing one from a scope
616 string. This is the entry point used by F1.3 per-subproject
617 fan-out at workspace-level commit, where the workspace-wide
618 staged set is grouped into subproject buckets and each bucket
619 runs under its own profile.
620
621 Parameters
622 ----------
623 project_root
624 Resolved project root for octrc / linter-context / secrets
625 config loading. For a per-subproject call, pass the
626 subproject's path; for a workspace-level call, pass the
627 workspace root.
628 files
629 Pre-filtered list of files to lint / format / scan. May
630 include non-Python files (passed through to the secrets
631 scanner; lint/format ignore them via the suffix filter).
632 profile
633 Lint profile: ``"proto"`` / ``"compact"`` / ``"strict"``.
634 include_tests, fix, sandbox
635 Same semantics as :func:`run_quality_gate`.
636 """
637 func = "run_quality_gate_on_files"
638 start = time.monotonic()
639 _dbg(
640 "GIT", func,
641 f"start files={len(files)} profile={profile} fix={fix}",
642 2,
643 )
644
645 from oct.git.policy import PROFILE_MATRIX
646
647 all_files = list(files)
648 py_files = [f for f in all_files if f.suffix == ".py"]
649
650 # -- Secrets pass (always runs, on ALL file types) -------------------
651 from oct.core.octrc import load_octrc_safe
652 octrc = load_octrc_safe(project_root)
653 scanner_cfg = octrc.get("secret_scanner", {}) if isinstance(octrc.get("secret_scanner"), dict) else {}
654 exclusions = scanner_cfg.get("entropy_exclusions", []) or []
655 if not isinstance(exclusions, list):
656 exclusions = []
657
658 # F1 fan-out: when invoked from a workspace root, the workspace-root
659 # `.octrc.json` typically does not exist; the per-subproject configs
660 # at `<sp>/.option_c/octrc.json` carry the exclusions. Merge each
661 # subproject's `entropy_exclusions` into the active list so the
662 # operator's explicit allow-listing applies. `field_name_pattern`
663 # entries are global; `path_pattern` entries are prefixed with the
664 # subproject's workspace-relative path so they match against
665 # workspace-relative file paths used by `_run_secrets_pass`.
666 from oct.core.workspace import (
667 WORKSPACE_MANIFEST,
668 WorkspaceError,
669 load_workspace,
670 )
671 ws_manifest_path = project_root / WORKSPACE_MANIFEST
672 if ws_manifest_path.is_file():
673 try:
674 ws = load_workspace(project_root)
675 except WorkspaceError:
676 ws = None
677 if ws is not None:
678 for sp in ws.subprojects:
679 try:
680 sp_rel = sp.path.relative_to(project_root).as_posix()
681 except ValueError:
682 continue
683 sub_octrc = load_octrc_safe(sp.path)
684 sub_scanner = sub_octrc.get("secret_scanner", {})
685 if not isinstance(sub_scanner, dict):
686 continue
687 sub_excl = sub_scanner.get("entropy_exclusions", []) or []
688 if not isinstance(sub_excl, list):
689 continue
690 for excl in sub_excl:
691 if not isinstance(excl, dict):
692 continue
693 merged = dict(excl)
694 pp = merged.get("path_pattern")
695 if isinstance(pp, str) and pp:
696 merged["path_pattern"] = f"{sp_rel}/{pp.lstrip('/')}"
697 exclusions.append(merged)
698
699 field_patterns = [
700 e["field_name_pattern"] for e in exclusions
701 if isinstance(e, dict)
702 and e.get("context") == "namedtuple_field"
703 and isinstance(e.get("field_name_pattern"), str)
704 ]
705 secrets_findings = _run_secrets_pass(
706 all_files, project_root,
707 code_entropy_threshold=scanner_cfg.get("code_entropy_threshold", 4.5),
708 docstring_entropy_threshold=scanner_cfg.get("docstring_entropy_threshold", 5.0),
709 comment_entropy_threshold=scanner_cfg.get("comment_entropy_threshold", 5.0),
710 secret_match_mode=scanner_cfg.get("match_mode", "substring"),
711 entropy_exclusions=exclusions if isinstance(exclusions, list) else [],
712 excluded_field_patterns=field_patterns,
713 )
714
715 # -- Lint pass -------------------------------------------------------
716 matrix = PROFILE_MATRIX.get(profile, PROFILE_MATRIX["compact"])
717 lint_violations = 0
718 per_file: list[dict] = []
719 if matrix["lint"] != "skip":
720 lint_violations, per_file = _run_lint_pass(
721 py_files, project_root, profile, fix_mode=False,
722 )
723
724 # -- Format pass -----------------------------------------------------
725 format_violations = 0
726 if matrix["format"] != "skip":
727 format_violations = _run_format_pass(
728 py_files, project_root, fix_mode=False,
729 )
730
731 # -- Test pass (optional) --------------------------------------------
732 test_failures = 0
733 if include_tests and matrix["tests"] != "skip":
734 test_failures = _run_test_pass(project_root, sandbox=sandbox)
735
736 # -- Fix mode: two-pass approach ------------------------------------
737 if fix and (lint_violations > 0 or format_violations > 0):
738 _dbg("GIT", func, "fix mode: applying auto-fixes", 2)
739
740 # Fix pass — apply lint and format auto-fixes.
741 if lint_violations > 0 and matrix["lint"] != "skip":
742 _run_lint_pass(py_files, project_root, profile, fix_mode=True)
743 if format_violations > 0 and matrix["format"] != "skip":
744 _run_format_pass(py_files, project_root, fix_mode=True)
745
746 # Re-verify pass — check again after fixes.
747 _dbg("GIT", func, "fix mode: re-verifying", 2)
748 lint_violations = 0
749 per_file = []
750 if matrix["lint"] != "skip":
751 lint_violations, per_file = _run_lint_pass(
752 py_files, project_root, profile, fix_mode=False,
753 )
754 format_violations = 0
755 if matrix["format"] != "skip":
756 format_violations = _run_format_pass(
757 py_files, project_root, fix_mode=False,
758 )
759 # Note: secrets are NOT re-run — they cannot be auto-fixed.
760
761 duration_ms = int((time.monotonic() - start) * 1000)
762
763 result = QualityGateResult(
764 files_checked=len(all_files),
765 lint_violations=lint_violations,
766 format_violations=format_violations,
767 secrets_findings=secrets_findings,
768 test_failures=test_failures,
769 duration_ms=duration_ms,
770 per_file=per_file,
771 )
772 _dbg(
773 "GIT", func,
774 f"end lint={lint_violations} fmt={format_violations} "
775 f"secrets={len(secrets_findings)} tests={test_failures} "
776 f"duration_ms={duration_ms}",
777 2,
778 )
779 return result
_build_linter_context(Path project_root)
QualityGateResult run_quality_gate(Path project_root, Literal["staged", "changed", "all"] scope, Literal["proto", "compact", "strict"] profile, bool include_tests=False, bool fix=False, bool sandbox=False)
list[Path] _resolve_scope(Path project_root, Literal["staged", "changed", "all"] scope)
QualityGateResult run_quality_gate_on_files(Path project_root, list[Path] files, Literal["proto", "compact", "strict"] profile, bool include_tests=False, bool fix=False, bool sandbox=False)
list[tuple[str, int, str]] _run_secrets_pass(list[Path] all_files, Path project_root, *, float code_entropy_threshold=4.5, float docstring_entropy_threshold=5.0, float comment_entropy_threshold=5.0, str secret_match_mode="substring", list[dict]|None entropy_exclusions=None, list[str]|None excluded_field_patterns=None)
tuple[int, list[dict]] _run_lint_pass(list[Path] py_files, Path project_root, str profile, bool fix_mode=False)
_build_formatter_context(Path project_root, *, bool fix_mode=False)
int _run_format_pass(list[Path] py_files, Path project_root, bool fix_mode=False)
PreCommitResult pre_commit_checks(Path project_root, str branch, str message, bool force_commit, str profile, dict octrc)
int _run_test_pass(Path project_root, bool sandbox=False)
list[Path] _walk_secret_filenames(Path project_root, *, exclude_dirs, wildcard_exclude_dirs, patterns)