oct
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- Expose the secrets pass alone, configured exactly as the gate runs it,
22 as :func:`run_secrets_scan_on_files` (``oct git commit``'s plan-208
23 full-index check uses it instead of the whole gate).
24- Optionally run pytest via subprocess (``--include-tests``).
25- Support ``--fix`` two-pass mode: detect → fix → re-verify.
26
27Diagnostics
28-----------
29Domain: GIT
30Levels:
31 L2 - lifecycle: gate start/end, scope resolution
32 L3 - details: per-pass summary counts
33 L4 - deep trace: per-file check outcomes
34
35Contracts
36---------
37- This module does not import ``click``. It is a pure orchestrator.
38- It calls existing library functions (linter, formatter, exclusions)
39 directly; it never shells out to ``oct lint`` or ``oct format``.
40- The only subprocess call is for pytest (``--include-tests``), which is
41 a distinct tool boundary.
42- :func:`run_quality_gate` never raises on check failures; it returns
43 a :class:`QualityGateResult` with the findings. Only infrastructure
44 errors (missing git repo, broken imports) may propagate.
45
46Dependencies
47------------
48- oct.core.diagnostics (_dbg structured logger)
49- oct.core.paths (to_project_relative - project-relative paths in secrets findings and for the format pass's path-rule lookup)
50- oct.core.exclusions (lazy; excluded directories and never-export file patterns for the all scope and the secrets pass)
51- oct.core.git (lazy; git_staged_files, git_changed_files and git_staged_deletions - the scoped file sets)
52- oct.core.octrc (lazy; load_octrc_safe - each subproject's linter rules and secret-scanner settings)
53- oct.core.option_c_dir (lazy; resolve_debug_config - the diagnostics directory for the linter and formatter contexts)
54- oct.core.waivers (lazy; inline waivers applied to secrets findings and to the format pass)
55- oct.core.workspace (lazy; the workspace manifest, for merging each subproject's entropy exclusions)
56- oct.formatter.oct_format (lazy; FormatterContext and format_file - the format pass)
57- oct.git.conventional (lazy; commit message and AI-Model trailer validation in the pre-commit checks)
58- oct.git.policy (lazy; the protected-branch check, profile resolution and PROFILE_MATRIX)
59- oct.linter.oct_lint (lazy; _lint_single_file, the lint profiles and path rules - the lint pass)
60- oct.mcp.sandbox (lazy; SandboxExecutor - the sanitised pytest run when sandbox is set)
61- oct.tools.secret_scanner (lazy; check_no_hardcoded_secrets - the always-on secrets pass)
62- pytest (not imported; run as python -m pytest for the --include-tests test pass)
63"""
64
65from __future__ import annotations
66
67import dataclasses
68import fnmatch
69import subprocess
70import sys
71import time
72from dataclasses import dataclass, field
73from pathlib import Path
74from typing import Literal
75
76from oct.core.diagnostics import _dbg
77from oct.core.paths import to_project_relative
78
79
80# ---------------------------------------------------------------------
81# Data model
82# ---------------------------------------------------------------------
83
84
85@dataclass
87 """Structured output from a quality-gate run.
88
89 Every field is JSON-serialisable (primitives, lists of tuples/dicts).
90 Field names must match the attribute access in
91 :func:`oct.git.policy.apply_profile_policy`.
92 """
93
94 files_checked: int = 0
95 lint_violations: int = 0
96 format_violations: int = 0
97 secrets_findings: list = field(default_factory=list)
98 """List of ``(path_str, line, reason)`` tuples."""
99 test_failures: int = 0
100 duration_ms: int = 0
101 exit_code: int = 0
102 per_file: list = field(default_factory=list)
103 """Per-file dicts suitable for ``--json`` rendering."""
104
105
106@dataclass(frozen=True)
108 """OI-527: structured outcome of the three pre-gate checks that run
109 before ``oct git commit`` executes the quality gate.
110
111 When :attr:`ok` is True the caller proceeds to the quality gate using
112 :attr:`staged_files`; the other fields carry context the command
113 needs regardless (branch, effective_profile). When :attr:`ok` is
114 False the caller emits :attr:`errors` on stderr (or as JSON) and
115 exits with :attr:`exit_code`.
116
117 :attr:`pb_advisory` is a protected-branch advisory string that must
118 be echoed *whether or not* the check blocked the commit; callers
119 treat it as a side-channel warning.
120 """
121
122 ok: bool
123 exit_code: int
124 errors: list
125 branch: str
126 effective_profile: str
127 staged_files: list
128 pb_advisory: str = ""
129
130
132 project_root: Path,
133 branch: str,
134 message: str,
135 force_commit: bool,
136 profile: str,
137 octrc: dict,
138) -> PreCommitResult:
139 """OI-527: run the three pre-gate checks and collapse their outcomes
140 into a single :class:`PreCommitResult`.
141
142 The helper replaces the four sequential early-return blocks that
143 previously lived in ``git_commit_cmd`` (commit-message validation,
144 staged-files presence, protected-branch guard). Exit codes and side
145 effects match the original CLI behaviour byte-for-byte:
146 * ``exit_code == 3`` - invalid commit message (skipped when
147 ``force_commit`` is True).
148 * ``exit_code == 4`` - nothing staged at all. A staged set of pure
149 deletions is NOT nothing: it returns ``ok`` with an empty
150 :attr:`~PreCommitResult.staged_files`, because the gate has no content
151 to inspect in files that are being removed.
152 * ``exit_code == 1`` - protected-branch block.
153 """
154 from oct.core.git import git_staged_deletions, git_staged_files
155 from oct.git.conventional import (
156 validate_ai_model_trailer,
157 validate_commit_message,
158 )
159 from oct.git.policy import (
160 check_protected_branch_commit,
161 resolve_profile_for_project,
162 )
163
164 # Plan 197: manifest-aware resolution. This is the single-project
165 # commit path - how every worker commits from inside a subproject -
166 # so before 197 it gated `oc_chat` at `compact` despite the
167 # workspace manifest declaring `strict`.
168 effective_profile = resolve_profile_for_project(
169 project_root, branch, octrc, profile,
170 )
171
172 if not force_commit:
173 msg_errors = list(validate_commit_message(message))
174 # Option C commit-forensics convention (Plan 151 T6, inventory
175 # item 4): when ``git.require_ai_model_trailer`` is enabled in
176 # ``.octrc.json``, the commit body must carry an AI-Model trailer.
177 # Default off - general oct users and the oct suite are unaffected.
178 if (octrc or {}).get("git", {}).get("require_ai_model_trailer", False):
179 msg_errors.extend(validate_ai_model_trailer(message))
180 if msg_errors:
181 return PreCommitResult(
182 ok=False,
183 exit_code=3,
184 errors=list(msg_errors),
185 branch=branch,
186 effective_profile=effective_profile,
187 staged_files=[],
188 )
189
190 staged = git_staged_files(project_root)
191 # A staged set of pure deletions has nothing for the gate to inspect, but it
192 # is emphatically something to commit. ``git_staged_files`` filters to ACMR
193 # because its result is what gets linted, formatted and secret-scanned, so it
194 # cannot answer "is anything staged" on its own - and using it for both
195 # questions made every deletion-only commit impossible through ``oct git
196 # commit`` (untracking a generated artefact, removing a dead module). Asking
197 # for deletions separately leaves the gate's input byte-identical: on this
198 # path ``staged`` stays empty, so the gate inspects nothing, which is the
199 # correct amount of content to inspect in a commit that removes files.
200 if not staged and not git_staged_deletions(project_root):
201 return PreCommitResult(
202 ok=False,
203 exit_code=4,
204 errors=["Nothing to commit."],
205 branch=branch,
206 effective_profile=effective_profile,
207 staged_files=[],
208 )
209
210 blocked, pb_message = check_protected_branch_commit(
211 branch, octrc, effective_profile,
212 )
213 if blocked:
214 return PreCommitResult(
215 ok=False,
216 exit_code=1,
217 errors=[pb_message] if pb_message else [],
218 branch=branch,
219 effective_profile=effective_profile,
220 staged_files=list(staged),
221 pb_advisory=pb_message or "",
222 )
223
224 return PreCommitResult(
225 ok=True,
226 exit_code=0,
227 errors=[],
228 branch=branch,
229 effective_profile=effective_profile,
230 staged_files=list(staged),
231 pb_advisory=pb_message or "",
232 )
233
234
235# ---------------------------------------------------------------------
236# Scope resolution
237# ---------------------------------------------------------------------
238
239
241 project_root: Path,
242 scope: Literal["staged", "changed", "all"],
243) -> list[Path]:
244 """Return the list of files to check based on *scope*.
245
246 For ``"staged"`` and ``"changed"`` we return **all** file types so
247 that filename-pattern secrets detection can flag non-Python files
248 like ``.env`` or ``*.pem``. The caller filters to ``.py`` for the
249 lint and format passes.
250
251 For ``"all"`` we use the linter's own ``find_python_files`` for the
252 Python set, but also walk the tree for non-Python secret-filename
253 checks.
254 """
255 func = "_resolve_scope"
256 _dbg("GIT", func, f"scope={scope} root={project_root}", 2)
257
258 if scope == "staged":
259 from oct.core.git import git_staged_files
260 files = git_staged_files(project_root)
261 _dbg("GIT", func, f"staged files={len(files)}", 3)
262 return files
263
264 if scope == "changed":
265 from oct.core.git import git_changed_files
266 files = git_changed_files(project_root)
267 _dbg("GIT", func, f"changed files={len(files)}", 3)
268 return files
269
270 # scope == "all"
271 from oct.core.exclusions import (
272 LINTER_EXCLUDE_DIRS,
273 NEVER_EXPORT_FILE_PATTERNS,
274 WILDCARD_EXCLUDE_DIRS,
275 )
276 from oct.linter.oct_lint import find_python_files
277 py_files = list(find_python_files(
278 project_root,
279 exclude_dirs=LINTER_EXCLUDE_DIRS,
280 wildcard_exclude_dirs=WILDCARD_EXCLUDE_DIRS,
281 ))
282 # OI-505: the secrets pass uses filename-pattern matching to catch
283 # ``.env``, ``*.pem``, etc. At ``scope="all"`` we previously only
284 # returned ``.py`` files, so those files were invisible to the gate.
285 # Walk the tree once more for any filename that matches the secret
286 # patterns, honouring the same exclusion lists as the linter.
287 secret_files = list(_walk_secret_filenames(
288 project_root,
289 exclude_dirs=LINTER_EXCLUDE_DIRS,
290 wildcard_exclude_dirs=WILDCARD_EXCLUDE_DIRS,
291 patterns=NEVER_EXPORT_FILE_PATTERNS,
292 ))
293 _dbg(
294 "GIT", func,
295 f"all python files={len(py_files)} secret filename candidates="
296 f"{len(secret_files)}",
297 3,
298 )
299 return py_files + secret_files
300
301
303 project_root: Path,
304 *,
305 exclude_dirs,
306 wildcard_exclude_dirs,
307 patterns,
308) -> list[Path]:
309 """Yield non-Python files whose name matches any ``patterns`` glob.
310
311 Uses ``os.walk`` with in-place pruning of ``dirnames`` so excluded
312 trees are never descended into. Mirrors the exclusion semantics of
313 :func:`oct.linter.oct_lint.find_python_files` - if a directory name
314 is in ``exclude_dirs`` (exact) or matches one of the wildcard entries
315 (prefix/suffix/glob), it is pruned from the walk.
316 """
317 import os
318
319 exact = set(exclude_dirs or ())
320 wildcards = tuple(wildcard_exclude_dirs or ())
321 results: list[Path] = []
322
323 def _should_exclude_dir(name: str) -> bool:
324 if name in exact:
325 return True
326 for pat in wildcards:
327 if fnmatch.fnmatch(name, pat):
328 return True
329 return False
330
331 for dirpath, dirnames, filenames in os.walk(project_root):
332 dirnames[:] = [d for d in dirnames if not _should_exclude_dir(d)]
333 for name in filenames:
334 if name.endswith(".py"):
335 continue # Already captured by find_python_files.
336 for pat in patterns:
337 if fnmatch.fnmatch(name, pat):
338 results.append(Path(dirpath) / name)
339 break
340 return results
341
342
343# ---------------------------------------------------------------------
344# Individual check passes
345# ---------------------------------------------------------------------
346
347
348def _gate_diagnostics_dir(project_root: Path) -> Path:
349 """Return the diagnostics directory the gate's contexts should carry.
350
351 Plan 213 / T0: derived from the ONE resolver,
352 :func:`oct.core.option_c_dir.resolve_debug_config`, instead of the
353 hard-coded ``<root>/oc_diagnostics``. On a project with no config
354 anywhere the resolver returns the ``oc_diagnostics/`` path, so this
355 is byte-identical to the old expression; on a migrated project it
356 correctly yields ``.option_c``, and on a pre-v0.17 project the legacy
357 ``diagnostics/`` directory - which is what the linter's deprecation
358 warning keys off.
359 """
360 from oct.core.option_c_dir import resolve_debug_config
361
362 return resolve_debug_config(project_root).parent
363
364
365def _build_linter_context(project_root: Path):
366 """Build a minimal ``LinterContext`` for the quality gate.
367
368 We import lazily to avoid pulling Click and the full linter on
369 import of this module.
370 """
371 from oct.linter.oct_lint import LinterContext
372
373 project_name = project_root.name
374 return LinterContext(
375 project_root=project_root,
376 project_name=project_name,
377 diagnostics_dir=_gate_diagnostics_dir(project_root),
378 tests_dir=project_root / "tests",
379 docs_dir=project_root / "docs",
380 )
381
382
383def _build_formatter_context(project_root: Path, *, fix_mode: bool = False):
384 """Build a ``FormatterContext`` for dry-run (or fix) mode."""
385 from oct.formatter.oct_format import FormatterContext
386
387 return FormatterContext(
388 project_root=project_root,
389 project_name=project_root.name,
390 diagnostics_dir=_gate_diagnostics_dir(project_root),
391 tests_dir=project_root / "tests",
392 docs_dir=project_root / "docs",
393 fix_mode=fix_mode,
394 dry_run_mode=not fix_mode,
395 json_mode=False,
396 verbose=False,
397 )
398
399
400#: Fixed names for what a secret-scanner finding is, matched against its
401#: message. Only these names are ever logged, never the message itself.
402_FINDING_KINDS: tuple[tuple[str, str], ...] = (
403 ("high-entropy string", "high-entropy string"),
404 (" assigned ", "secret-named assignment"),
405 ("dict key", "secret-named dict key"),
406 ("parameter", "secret-named parameter default"),
407 ("unparseable", "unparseable file"),
408)
409
410
411def _finding_kinds(message: str) -> list[str]:
412 """The kinds of finding in a scanner message, as fixed names only.
413
414 The message itself can quote part of the flagged value, so a log record
415 carries this instead. A finding matching no known kind is ``other``.
416 """
417 kinds: set[str] = set()
418 for part in message.split("; "):
419 kinds.add(next((name for marker, name in _FINDING_KINDS if marker in part), "other"))
420 return sorted(kinds)
421
422
423# OCT-LINT: disable=no_hardcoded_secrets reason="Param documents the matcher mode, not a credential"
425 all_files: list[Path],
426 project_root: Path,
427 *,
428 code_entropy_threshold: float = 4.5,
429 docstring_entropy_threshold: float = 5.0,
430 comment_entropy_threshold: float = 5.0,
431 secret_match_mode: str = "substring",
432 entropy_exclusions: list[dict] | None = None,
433 excluded_field_patterns: list[str] | None = None,
434) -> list[tuple[str, int, str]]:
435 """Check files for hardcoded secrets (content) and secret filenames.
436
437 Returns a list of ``(relative_path, line, reason)`` tuples.
438 """
439 func = "_run_secrets_pass"
440 from oct.core.exclusions import NEVER_EXPORT_FILE_PATTERNS
441 # OI-514: call the standalone scanner directly - no detour through the
442 # linter module just to reach these helpers.
443 from oct.tools.secret_scanner import check_no_hardcoded_secrets
444
445 findings: list[tuple[str, int, str]] = []
446 scanned: list[str] = []
447
448 for path in all_files:
449 rel = to_project_relative(path, project_root)
450 scanned.append(rel)
451
452 # Filename-pattern check (catches .env, *.pem, *.key, etc.).
453 name = path.name
454 for pattern in NEVER_EXPORT_FILE_PATTERNS:
455 if fnmatch.fnmatch(name, pattern):
456 findings.append((rel, 0, f"secret file pattern: {pattern}"))
457 _dbg("GIT", func, f"secret filename {rel} ~ {pattern}", 4)
458 break
459
460 # Content check (Python files only - AST-based detection).
461 if path.suffix == ".py" and path.is_file():
462 try:
463 text = path.read_text(encoding="utf-8")
464
465 # OI-540: path-based entropy exclusions. Both
466 # ``test_directory`` and ``module_self_doc`` contexts
467 # honour the same ``exempt_entropy_only`` /
468 # ``require_waiver_for_secret_patterns`` restriction
469 # semantics. ``module_self_doc`` is for source files
470 # whose code is dominated by help-text / template /
471 # scaffold string constants.
472 file_entropy = code_entropy_threshold
473 require_waiver = False
474 for excl in (entropy_exclusions or []):
475 if excl.get("context") in ("test_directory", "module_self_doc"):
476 pattern_str = excl.get("path_pattern", "")
477 restrictions = excl.get("restrictions", {})
478 if pattern_str and rel.replace("\\", "/").startswith(pattern_str):
479 if restrictions.get("exempt_entropy_only"):
480 file_entropy = 0
481 if restrictions.get("require_waiver_for_secret_patterns"):
482 require_waiver = True
483 break
484
485 passed, message = check_no_hardcoded_secrets(
486 text,
487 mode=secret_match_mode,
488 code_entropy_threshold=file_entropy,
489 docstring_entropy_threshold=docstring_entropy_threshold,
490 comment_entropy_threshold=comment_entropy_threshold,
491 excluded_field_patterns=excluded_field_patterns,
492 )
493 if not passed:
494 # OI-540: honour inline waivers universally in the
495 # quality gate, matching the linter's existing
496 # behaviour. Previously waivers were only checked
497 # when ``require_waiver`` was set by a test_directory
498 # exclusion - a gap exposed when the waiver regex
499 # was extracted to oct.core.waivers (its own pattern
500 # string legitimately exceeds the entropy threshold).
501 #
502 # Plan 193 (sec): that universal honouring was
503 # WHOLE-FILE - one waiver anywhere blinded this pass
504 # to every other finding in the file, so a real
505 # credential in any waived file committed clean.
506 # ``apply_waivers`` scopes each waiver to its own
507 # line and the next; only findings it does not reach
508 # survive, and they still block (G-D4 unchanged -
509 # this pass detects exactly what it detected before,
510 # and now reports strictly more of it).
511 from oct.core.waivers import apply_waivers, parse_inline_waivers
512 waivers = parse_inline_waivers(text)
513 outcome = apply_waivers(message, waivers, "no_hardcoded_secrets")
514 if not outcome.blocked:
515 _dbg("GIT", func, f"waiver accepted {rel}", 4)
516 continue
517 findings.append((rel, outcome.first_line, outcome.message))
518 # The record names the KINDS of finding, never the
519 # message: an entropy finding's message quotes the
520 # first 24 characters of the flagged value, and
521 # redaction runs only in production mode, so it reached
522 # dev log files whenever GIT was at level 4 or higher
523 # (operator ruling 2026-09-28).
524 _dbg(
525 "GIT", func,
526 f"secret content {rel}:{outcome.first_line} "
527 f"kinds={_finding_kinds(outcome.message)}", 4,
528 )
529 except (OSError, UnicodeDecodeError) as exc:
530 _dbg("GIT", func, f"read error {rel}: {exc}", 1)
531
532 # One L3 record per scanned file, with its finding COUNT only: a
533 # finding's message can quote the matched text. The scan logged
534 # nothing per file (found by the chart C runtime trace, 2026-09-27).
535 per_file: dict[str, int] = {}
536 for found_rel, _line, _reason in findings:
537 per_file[found_rel] = per_file.get(found_rel, 0) + 1
538 for scanned_rel in scanned:
539 _dbg("GIT", func, f"scanned {scanned_rel}: {per_file.get(scanned_rel, 0)} finding(s)", 3)
540
541 _dbg("GIT", func, f"total findings={len(findings)}", 3)
542 return findings
543
544
546 project_root: Path,
547 ctx,
548 active_rules: dict,
549 *,
550 profile: str | None = None,
551) -> dict:
552 """Honor the owning subproject's ``.octrc.json`` in the gate lint pass.
553
554 Plan 177 - ``_run_lint_pass`` previously built its rule set and linter
555 context from the PROFILE only, so an octrc-waived rule (e.g. ``oct``'s
556 ``dbg_import: false``) re-fired and false-blocked the workspace-root
557 fan-out. This mirrors the mechanism the standalone linter already uses
558 (:func:`oct.linter.oct_lint.run_linter`), reusing its exact semantics:
559
560 - ``linter.profile`` names the profile per-rule SEVERITY is resolved at
561 (plan 269). Without this the context kept its ``"strict"`` default, so
562 :func:`~oct.linter.rule_registry.get_severity` answered at strict
563 severities in the gate and at the project's own profile in
564 ``oct lint`` - the same divergence class plans 177 and 262 closed for
565 the rule SET. *profile* is the profile the gate was called with and
566 is used when the octrc names no valid one; a name outside
567 ``_PROFILES`` is refused on both sides rather than passed through,
568 since ``get_severity`` would silently answer at rule defaults for it;
569 - ``linter.rules`` overrides merge on top of the profile defaults
570 (explicit ``false`` disables, ``true`` enables; bool values only);
571 - ``linter.path_rules`` (plan 268) populate ``ctx.path_rules``, which
572 ``_lint_single_file`` applies per file after both of the above, so
573 the gate and ``oct lint`` reach the same verdict on a file a path
574 entry names. Line-scoped rules are refused by the parser;
575 - ``secret_scanner`` entropy thresholds + ``entropy_exclusions``
576 populate the linter context so the lint-pass ``no_hardcoded_secrets``
577 check honors the same ``exempt_entropy_only`` path exclusions.
578
579 The SECRETS pass (:func:`_run_secrets_pass`) is a separate, ALWAYS-ON
580 pass and is NOT touched here - octrc can never disable secret scanning
581 (G-D4). ``exempt_entropy_only`` exempts entropy MAGNITUDE only; a
582 name/pattern-matched secret still fires in both passes.
583
584 *ctx* is mutated in place; the (possibly) updated *active_rules* dict is
585 returned. The fan-out passes each slice's own subproject as
586 ``project_root``, so the octrc loaded here is that subproject's.
587 """
588 func = "_apply_octrc_to_lint"
589 from oct.core.octrc import load_octrc_safe
590 from oct.linter.oct_lint import _PROFILES
591
592 # Plan 269 / P2: the fallback is applied BEFORE the octrc is read, so a
593 # project with no octrc at all is still judged at the profile the gate
594 # is running rather than at the context's ``strict`` default.
595 if isinstance(profile, str) and profile in _PROFILES:
596 ctx.profile_name = profile
597
598 octrc = load_octrc_safe(project_root)
599 if not isinstance(octrc, dict) or not octrc:
600 return active_rules
601
602 linter_cfg = octrc.get("linter", {})
603 if isinstance(linter_cfg, dict):
604 octrc_profile = linter_cfg.get("profile")
605 if isinstance(octrc_profile, str) and octrc_profile in _PROFILES:
606 ctx.profile_name = octrc_profile
607 rule_overrides = linter_cfg.get("rules", {}) or {}
608 if isinstance(rule_overrides, dict):
609 active_rules.update(
610 {k: v for k, v in rule_overrides.items() if isinstance(v, bool)}
611 )
612 # Plan 268: the per-path overrides ride the context rather than
613 # the rule dict, because ``_lint_single_file`` resolves them per
614 # file. ``parse_path_rules`` refuses every line-scoped rule, so
615 # nothing reachable from here can touch the always-on secrets
616 # pass below (G-D4).
617 from oct.linter.oct_lint import parse_path_rules
618
619 ctx.path_rules = parse_path_rules(linter_cfg)
620
621 scanner_cfg = octrc.get("secret_scanner", {})
622 if isinstance(scanner_cfg, dict):
623 octrc_threshold = scanner_cfg.get("code_entropy_threshold")
624 if isinstance(octrc_threshold, (int, float)) and not isinstance(
625 octrc_threshold, bool
626 ):
627 ctx.code_entropy_threshold = float(octrc_threshold)
628 for key in ("docstring_entropy_threshold", "comment_entropy_threshold"):
629 val = scanner_cfg.get(key)
630 if isinstance(val, (int, float)) and not isinstance(val, bool):
631 setattr(ctx, key, float(val))
632 exclusions = scanner_cfg.get("entropy_exclusions", [])
633 if isinstance(exclusions, list):
634 ctx.entropy_exclusions = exclusions
635 ctx.excluded_field_patterns = [
636 e["field_name_pattern"] for e in exclusions
637 if isinstance(e, dict)
638 and e.get("context") == "namedtuple_field"
639 and isinstance(e.get("field_name_pattern"), str)
640 ]
641
642 _dbg(
643 "GIT", func,
644 f"applied octrc lint overrides from {project_root} "
645 f"at profile={ctx.profile_name}",
646 4,
647 )
648 return active_rules
649
650
652 py_files: list[Path],
653 project_root: Path,
654 profile: str,
655 fix_mode: bool = False,
656) -> tuple[int, list[dict]]:
657 """Run the linter on *py_files* and return (violation_count, per_file).
658
659 Uses ``_lint_single_file`` directly, bypassing the CLI. The profile
660 selects which rules are active via ``_PROFILES``; the owning
661 subproject's ``.octrc.json`` then merges its ``linter.rules`` disables
662 and ``secret_scanner`` entropy exclusions on top (Plan 177), mirroring
663 the standalone linter. The SECRETS pass is unaffected.
664 """
665 func = "_run_lint_pass"
666 from oct.linter.oct_lint import _PROFILES, _lint_single_file
667
668 ctx = _build_linter_context(project_root)
669 active_rules = dict(_PROFILES.get(profile, _PROFILES.get("compact", {})))
670 active_rules = _apply_octrc_to_lint(
671 project_root, ctx, active_rules, profile=profile,
672 )
673
674 violations = 0
675 per_file: list[dict] = []
676
677 for path in py_files:
678 if not path.is_file():
679 continue
680 try:
681 result = _lint_single_file(path, ctx, active_rules, fix_mode)
682 if not result.get("compliant", True):
683 violations += 1
684 per_file.append(result)
685 _dbg(
686 "GIT", func,
687 f"lint {result.get('rel', '?')} compliant={result.get('compliant')}",
688 4,
689 )
690 except Exception as exc: # pragma: no cover - defensive
691 _dbg("GIT", func, f"lint error {path}: {exc}", 1)
692
693 _dbg("GIT", func, f"violations={violations} files={len(py_files)}", 3)
694 return violations, per_file
695
696
697#: Formatter fixers whose auto-fix mirrors a lint rule an octrc
698#: ``linter.rules`` disable can suppress (Plan 177 - the format-pass twin
699#: of the lint-pass merge). A format "change" produced solely by one of
700#: these fixers is NOT a violation when the owning subproject disables the
701#: corresponding rule - otherwise the gate re-flags oct's own
702#: octrc-waived ``oct.core.diagnostics`` shim imports (``dbg_import``).
703_FORMAT_FIXER_RULES = {
704 "header": "header",
705 "docstring": "docstring",
706 "dbg_import": "dbg_import",
707 "func_pattern": "func_pattern",
708}
709
710
711def _octrc_disabled_lint_rules(project_root: Path) -> set[str]:
712 """Return the lint rules the subproject's ``.octrc.json`` disables.
713
714 Plan 177 - a rule with an explicit ``false`` under ``linter.rules`` is
715 honored by the gate's lint pass (via :func:`_apply_octrc_to_lint`) and,
716 for the fixers listed in :data:`_FORMAT_FIXER_RULES`, by the format
717 pass. The SECRETS pass is never consulted here - octrc can't disable it.
718 """
719 from oct.core.octrc import load_octrc_safe
720
721 disabled: set[str] = set()
722 octrc = load_octrc_safe(project_root)
723 if isinstance(octrc, dict):
724 linter_cfg = octrc.get("linter", {})
725 if isinstance(linter_cfg, dict):
726 rules = linter_cfg.get("rules", {}) or {}
727 if isinstance(rules, dict):
728 disabled = {k for k, v in rules.items() if v is False}
729 return disabled
730
731
733 rel_posix: str,
734 run_disabled: set[str],
735 path_rules: list,
736) -> set[str]:
737 """Return the lint rules disabled FOR ONE FILE, the per-path answer.
738
739 Plan 269 - the format-pass twin of what plan 268 gave the lint pass.
740 :func:`_octrc_disabled_lint_rules` answers for the whole run, which is
741 the wrong grain for a config whose whole purpose is that a test tree
742 and its package answer differently: a module a ``tests/`` path entry
743 excused from ``dbg_import`` passed the gate's lint pass and was still
744 refused by its format pass, on the same rule, in the same commit.
745
746 The run-wide set is the starting point and every matching path entry
747 then moves what it names: a rule the entry sets ``False`` joins the
748 set, one it sets ``True`` leaves it - including a rule ``linter.rules``
749 disabled run-wide, which is the only way a project can keep a fixer
750 where it wants it and drop it everywhere else. Entry order and the
751 refusals (line-scoped rules, ``_``-prefixed mode markers) are
752 :func:`~oct.linter.oct_lint.effective_rules`'s, not restated here, so
753 the gate's format pass and ``oct lint`` cannot drift apart on what a
754 path entry means.
755
756 G-D4 is untouched by construction and by test: the secrets pass never
757 consults this, and no key of :data:`_FORMAT_FIXER_RULES` names a
758 secret rule - ``effective_rules`` would refuse it in any case.
759
760 Parameters
761 ----------
762 rel_posix
763 The file's project-relative POSIX path, as
764 :func:`~oct.core.paths.to_project_relative` renders it.
765 run_disabled
766 The run-wide disables from :func:`_octrc_disabled_lint_rules`.
767 Never mutated - one set is shared across the whole pass.
768 path_rules
769 Validated ``(prefix, {rule: bool})`` pairs from
770 :func:`~oct.linter.oct_lint.parse_path_rules`.
771 """
772 func = "_disabled_fixer_rules_for"
773 if not path_rules:
774 return run_disabled
775
776 from oct.linter.oct_lint import effective_rules
777
778 # An empty base means the returned dict names ONLY what a matching
779 # entry set, which is exactly the delta to apply to the run-wide set.
780 overrides, _changed = effective_rules({}, rel_posix, path_rules)
781 if not overrides:
782 return run_disabled
783
784 disabled = set(run_disabled)
785 for rule, active in overrides.items():
786 if active:
787 disabled.discard(rule)
788 else:
789 disabled.add(rule)
790 _dbg("GIT", func, f"{rel_posix} disables {sorted(disabled)}", 4)
791 return disabled
792
793
794def _disabled_fixers_for(disabled_rules: set) -> frozenset:
795 """Translate disabled LINT RULES into the FIXER names to skip.
796
797 :data:`_FORMAT_FIXER_RULES` maps fixer name to lint rule; this reads
798 it in the other direction, which is the direction the formatter needs
799 - :func:`~oct.formatter.oct_format.format_file` knows fixers, and
800 everything upstream of it speaks rules.
801
802 The map is an identity today, so the translation is a filter; it is
803 written as a translation anyway so that a fixer whose name ever
804 differs from its rule cannot quietly disable the wrong one. A rule
805 with no fixer (``dbg_usage``, every line-scoped rule) simply names no
806 fixer and disappears here, which is the second reason G-D4 cannot be
807 reached from this path.
808 """
809 return frozenset(
810 name for name, rule in _FORMAT_FIXER_RULES.items()
811 if rule in disabled_rules
812 )
813
814
816 py_files: list[Path],
817 project_root: Path,
818 fix_mode: bool = False,
819) -> int:
820 """Run the formatter in dry-run (or fix) mode, return violation count.
821
822 A "violation" is a file where ``format_file`` reports a change from a
823 fixer whose lint rule is NOT disabled for THAT FILE by the owning
824 subproject's octrc. Plan 177 (format-pass twin): a change produced
825 solely by an octrc-disabled fixer (e.g. ``dbg_import`` on oct's
826 ``oct.core`` diagnostics shim) is suppressed, mirroring the lint pass.
827 Plan 269: ``linter.path_rules`` are resolved per file by
828 :func:`_disabled_fixer_rules_for`, so this pass and the lint pass
829 reach the same verdict on a file a path entry names - before, a path
830 entry reached the lint pass only and the format pass refused the file
831 the lint pass had just cleared. The SECRETS pass is unaffected.
832
833 Plan 277 closes the last of the three: the file's OWN inline
834 ``# OCT-LINT: disable[-file]=<rule>`` waivers join the per-file
835 disabled set, on exactly the linter's terms
836 (:func:`~oct.core.waivers.inline_disabled_rules`). The file text is
837 read here to parse them, as the secrets pass already reads it, and a
838 file that cannot be read yields no waivers - ``format_file`` reads it
839 again and reports the failure through its own error channel.
840 :data:`~oct.core.waivers.LINE_SCOPED_RULES` cannot enter the set, so
841 a ``no_hardcoded_secrets`` waiver disables no fixer here and G-D4 is
842 untouched by construction as well as by test.
843
844 The octrc is read once per pass, not once per file: the run-wide
845 disables and the parsed path rules are both resolved before the loop.
846 """
847 func = "_run_format_pass"
848 from oct.core.octrc import load_octrc_safe
849 from oct.core.waivers import inline_disabled_rules
850 from oct.formatter.oct_format import format_file
851 from oct.linter.oct_lint import parse_path_rules
852
853 ctx = _build_formatter_context(project_root, fix_mode=fix_mode)
854 run_disabled = _octrc_disabled_lint_rules(project_root)
855 octrc = load_octrc_safe(project_root)
856 path_rules = parse_path_rules(
857 octrc.get("linter", {}) if isinstance(octrc, dict) else {},
858 )
859 violations = 0
860
861 for path in py_files:
862 if not path.is_file():
863 continue
864 try:
865 disabled = _disabled_fixer_rules_for(
866 to_project_relative(path, project_root),
867 run_disabled,
868 path_rules,
869 )
870 try:
871 text = path.read_text(encoding="utf-8")
872 except (OSError, UnicodeDecodeError) as read_exc:
873 _dbg("GIT", func, f"read error {path}: {read_exc}", 1)
874 text = ""
875 waived = inline_disabled_rules(text)
876 if waived:
877 # A NEW set every time: with no path rules
878 # ``_disabled_fixer_rules_for`` hands back the run-wide
879 # set itself, which is shared across the whole pass.
880 disabled = set(disabled) | waived
881 # Plan 277 P2 - the formatter is told what NOT to run, in
882 # check and fix mode alike, so a disabled fixer can no longer
883 # rewrite the file and be filtered out afterwards. A fresh
884 # context per file rather than a mutated one: the class calls
885 # itself an immutable per-run context, and one shared object
886 # carrying the previous file's answer is precisely the bug
887 # class this plan is closing.
888 file_ctx = dataclasses.replace(
889 ctx, disabled_fixers=_disabled_fixers_for(disabled),
890 )
891 result = format_file(path, file_ctx)
892 fixes = result.get("fixes") or {}
893 changed_fixers = {
894 name for name, info in fixes.items()
895 if isinstance(info, dict) and info.get("changed")
896 }
897 # A change counts only when it comes from a fixer whose lint
898 # rule the subproject did NOT disable (fixers with no rule
899 # analogue map to themselves and are always counted).
900 effective = {
901 name for name in changed_fixers
902 if _FORMAT_FIXER_RULES.get(name, name) not in disabled
903 }
904 if effective:
905 violations += 1
906 _dbg(
907 "GIT", func,
908 f"format change {result.get('path', '?')}: {sorted(effective)}",
909 4,
910 )
911 elif result.get("changed", False) and not changed_fixers:
912 # Defensive: reported changed without per-fixer detail.
913 violations += 1
914 _dbg("GIT", func, f"format change {result.get('path', '?')}", 4)
915 except Exception as exc: # pragma: no cover - defensive
916 _dbg("GIT", func, f"format error {path}: {exc}", 1)
917
918 _dbg("GIT", func, f"violations={violations} files={len(py_files)}", 3)
919 return violations
920
921
922def _run_test_pass(project_root: Path, sandbox: bool = False) -> int:
923 """Run ``python -m pytest tests/`` and return the failure count.
924
925 Returns 0 if all tests pass (exit code 0), or 1 if any test fails
926 (non-zero exit code). This is a subprocess boundary - tests are a
927 distinct tool and their execution is not inline.
928
929 When ``sandbox=True`` (OI-517), the pytest subprocess runs under a
930 sanitised environment via :class:`oct.mcp.sandbox.SandboxExecutor`:
931 secret-named env vars are stripped, ``PYTHONPATH`` is dropped, and a
932 600s wall-clock ceiling is enforced. Output is capped at 1 MiB so a
933 runaway test cannot flood stderr.
934 """
935 func = "_run_test_pass"
936 _dbg("GIT", func, f"starting test pass sandbox={sandbox}", 2)
937
938 cmd = [sys.executable, "-m", "pytest", "tests/", "-x", "--tb=no", "-q"]
939 try:
940 if sandbox:
941 from oct.mcp.sandbox import NativeBackend, SandboxExecutor
942 executor = SandboxExecutor(backend=NativeBackend())
943 sandbox_result = executor.run(cmd, cwd=project_root, timeout=600)
944 returncode = sandbox_result.exit_code
945 if sandbox_result.timed_out:
946 _dbg("GIT", func, "sandbox pytest timed out", 1)
947 else:
948 result = subprocess.run(
949 cmd,
950 cwd=project_root,
951 capture_output=True,
952 text=True,
953 timeout=300,
954 )
955 returncode = result.returncode
956 failures = 0 if returncode == 0 else 1
957 _dbg("GIT", func, f"test exit_code={returncode}", 3)
958 return failures
959 except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
960 _dbg("GIT", func, f"test pass error: {exc}", 1)
961 return 1
962
963
964# ---------------------------------------------------------------------
965# Main orchestrator
966# ---------------------------------------------------------------------
967
968
970 project_root: Path,
971 scope: Literal["staged", "changed", "all"],
972 profile: Literal["proto", "compact", "strict"],
973 include_tests: bool = False,
974 fix: bool = False,
975 sandbox: bool = False,
976) -> QualityGateResult:
977 """Orchestrate lint + format-dry-run + secrets on the scoped files.
978
979 When ``fix=True``, runs in two passes:
980
981 1. **Detect pass:** run the gate with ``fix=False`` to record the
982 initial state.
983 2. **Fix pass:** run lint and format with ``fix_mode=True`` to apply
984 auto-fixes.
985 3. **Re-verify pass:** run the gate again with ``fix=False``. The
986 returned result reflects the post-fix state.
987
988 Secrets are **never** auto-fixed (no safe auto-fix for a hardcoded
989 password). The ``--fix`` flag does not bypass the always-blocking
990 secrets rule.
991
992 Parameters
993 ----------
994 project_root
995 Resolved project root containing ``.octrc.json``, ``tests/``, etc.
996 scope
997 ``"staged"`` checks only the git index; ``"changed"`` checks
998 staged + unstaged + untracked; ``"all"`` checks every Python file.
999 profile
1000 Lint profile: ``"proto"`` / ``"compact"`` / ``"strict"``.
1001 include_tests
1002 If True, also run ``pytest`` as a subprocess (exit code 4 on fail).
1003 fix
1004 If True, attempt auto-fix of lint/format violations, then re-check.
1005 sandbox
1006 OI-517: when True and ``include_tests`` is True, the pytest
1007 subprocess runs under the MCP sandbox environment - sanitised
1008 env, no network side-channels via PYTHONPATH, output capped.
1009 """
1010 func = "run_quality_gate"
1011 _dbg("GIT", func, f"start scope={scope} profile={profile} fix={fix}", 2)
1012
1013 # F1.3: scope-resolution is the only step that differs between this
1014 # entry point and run_quality_gate_on_files. After resolving the
1015 # scope we delegate to the file-list variant so per-subproject
1016 # fan-out can call the same machinery without re-discovering files.
1017 all_files = _resolve_scope(project_root, scope)
1019 project_root=project_root,
1020 files=all_files,
1021 profile=profile,
1022 include_tests=include_tests,
1023 fix=fix,
1024 sandbox=sandbox,
1025 )
1026
1027
1029 project_root: Path,
1030 files: list[Path],
1031) -> list[tuple[str, int, str]]:
1032 """The gate's secrets pass alone, configured exactly as the gate runs it.
1033
1034 Reads the scanner settings from *project_root*'s octrc, merges every
1035 workspace subproject's ``entropy_exclusions`` when *project_root* is a
1036 workspace root, and scans *files* (all file types). This is the one
1037 place that configuration is assembled: :func:`run_quality_gate_on_files`
1038 calls it for its secrets pass, and ``oct git commit``'s plan-208
1039 full-index check calls it directly, so the two scans cannot drift apart
1040 and the full-index check no longer re-runs lint and format only to
1041 discard them (found by the chart C runtime trace, 2026-09-27).
1042
1043 Returns
1044 -------
1045 list of ``(relative_path, line, reason)`` tuples, as
1046 :func:`_run_secrets_pass` does.
1047 """
1048 func = "run_secrets_scan_on_files"
1049 all_files = list(files)
1050 from oct.core.octrc import load_octrc_safe
1051 octrc = load_octrc_safe(project_root)
1052 scanner_cfg = octrc.get("secret_scanner", {}) if isinstance(octrc.get("secret_scanner"), dict) else {}
1053 exclusions = scanner_cfg.get("entropy_exclusions", []) or []
1054 if not isinstance(exclusions, list):
1055 exclusions = []
1056
1057 # F1 fan-out: when invoked from a workspace root, the workspace-root
1058 # `.octrc.json` typically does not exist; the per-subproject configs
1059 # at `<sp>/.option_c/octrc.json` carry the exclusions. Merge each
1060 # subproject's `entropy_exclusions` into the active list so the
1061 # operator's explicit allow-listing applies. `field_name_pattern`
1062 # entries are global; `path_pattern` entries are prefixed with the
1063 # subproject's workspace-relative path so they match against
1064 # workspace-relative file paths used by `_run_secrets_pass`.
1065 from oct.core.workspace import (
1066 WORKSPACE_MANIFEST,
1067 WorkspaceError,
1068 load_workspace,
1069 )
1070 ws_manifest_path = project_root / WORKSPACE_MANIFEST
1071 if ws_manifest_path.is_file():
1072 try:
1073 ws = load_workspace(project_root)
1074 except WorkspaceError:
1075 ws = None
1076 if ws is not None:
1077 for sp in ws.subprojects:
1078 try:
1079 sp_rel = sp.path.relative_to(project_root).as_posix()
1080 except ValueError:
1081 continue
1082 sub_octrc = load_octrc_safe(sp.path)
1083 sub_scanner = sub_octrc.get("secret_scanner", {})
1084 if not isinstance(sub_scanner, dict):
1085 continue
1086 sub_excl = sub_scanner.get("entropy_exclusions", []) or []
1087 if not isinstance(sub_excl, list):
1088 continue
1089 for excl in sub_excl:
1090 if not isinstance(excl, dict):
1091 continue
1092 merged = dict(excl)
1093 pp = merged.get("path_pattern")
1094 if isinstance(pp, str) and pp:
1095 merged["path_pattern"] = f"{sp_rel}/{pp.lstrip('/')}"
1096 exclusions.append(merged)
1097
1098 field_patterns = [
1099 e["field_name_pattern"] for e in exclusions
1100 if isinstance(e, dict)
1101 and e.get("context") == "namedtuple_field"
1102 and isinstance(e.get("field_name_pattern"), str)
1103 ]
1104 secrets_findings = _run_secrets_pass(
1105 all_files, project_root,
1106 code_entropy_threshold=scanner_cfg.get("code_entropy_threshold", 4.5),
1107 docstring_entropy_threshold=scanner_cfg.get("docstring_entropy_threshold", 5.0),
1108 comment_entropy_threshold=scanner_cfg.get("comment_entropy_threshold", 5.0),
1109 secret_match_mode=scanner_cfg.get("match_mode", "substring"),
1110 entropy_exclusions=exclusions if isinstance(exclusions, list) else [],
1111 excluded_field_patterns=field_patterns,
1112 )
1113 _dbg("GIT", func, f"files={len(all_files)} findings={len(secrets_findings)}", 3)
1114 return secrets_findings
1115
1116
1118 project_root: Path,
1119 files: list[Path],
1120 profile: Literal["proto", "compact", "strict"],
1121 include_tests: bool = False,
1122 fix: bool = False,
1123 sandbox: bool = False,
1124) -> QualityGateResult:
1125 """Run the quality gate on an explicit file list.
1126
1127 Identical contract to :func:`run_quality_gate` but accepts a
1128 pre-resolved list of files instead of computing one from a scope
1129 string. This is the entry point used by F1.3 per-subproject
1130 fan-out at workspace-level commit, where the workspace-wide
1131 staged set is grouped into subproject buckets and each bucket
1132 runs under its own profile.
1133
1134 Parameters
1135 ----------
1136 project_root
1137 Resolved project root for octrc / linter-context / secrets
1138 config loading. For a per-subproject call, pass the
1139 subproject's path; for a workspace-level call, pass the
1140 workspace root.
1141 files
1142 Pre-filtered list of files to lint / format / scan. May
1143 include non-Python files (passed through to the secrets
1144 scanner; lint/format ignore them via the suffix filter).
1145 profile
1146 Lint profile: ``"proto"`` / ``"compact"`` / ``"strict"``.
1147 include_tests, fix, sandbox
1148 Same semantics as :func:`run_quality_gate`.
1149 """
1150 func = "run_quality_gate_on_files"
1151 start = time.monotonic()
1152 _dbg(
1153 "GIT", func,
1154 f"start files={len(files)} profile={profile} fix={fix}",
1155 2,
1156 )
1157
1158 from oct.git.policy import PROFILE_MATRIX
1159
1160 all_files = list(files)
1161 py_files = [f for f in all_files if f.suffix == ".py"]
1162
1163 # -- Secrets pass (always runs, on ALL file types) -------------------
1164 secrets_findings = run_secrets_scan_on_files(project_root, all_files)
1165
1166 # -- Lint pass -------------------------------------------------------
1167 matrix = PROFILE_MATRIX.get(profile, PROFILE_MATRIX["compact"])
1168 lint_violations = 0
1169 per_file: list[dict] = []
1170 if matrix["lint"] != "skip":
1171 lint_violations, per_file = _run_lint_pass(
1172 py_files, project_root, profile, fix_mode=False,
1173 )
1174
1175 # -- Format pass -----------------------------------------------------
1176 format_violations = 0
1177 if matrix["format"] != "skip":
1178 format_violations = _run_format_pass(
1179 py_files, project_root, fix_mode=False,
1180 )
1181
1182 # -- Test pass (optional) --------------------------------------------
1183 test_failures = 0
1184 if include_tests and matrix["tests"] != "skip":
1185 test_failures = _run_test_pass(project_root, sandbox=sandbox)
1186
1187 # -- Fix mode: two-pass approach ------------------------------------
1188 if fix and (lint_violations > 0 or format_violations > 0):
1189 _dbg("GIT", func, "fix mode: applying auto-fixes", 2)
1190
1191 # Fix pass - apply lint and format auto-fixes.
1192 if lint_violations > 0 and matrix["lint"] != "skip":
1193 _run_lint_pass(py_files, project_root, profile, fix_mode=True)
1194 if format_violations > 0 and matrix["format"] != "skip":
1195 _run_format_pass(py_files, project_root, fix_mode=True)
1196
1197 # Re-verify pass - check again after fixes.
1198 _dbg("GIT", func, "fix mode: re-verifying", 2)
1199 lint_violations = 0
1200 per_file = []
1201 if matrix["lint"] != "skip":
1202 lint_violations, per_file = _run_lint_pass(
1203 py_files, project_root, profile, fix_mode=False,
1204 )
1205 format_violations = 0
1206 if matrix["format"] != "skip":
1207 format_violations = _run_format_pass(
1208 py_files, project_root, fix_mode=False,
1209 )
1210 # Note: secrets are NOT re-run - they cannot be auto-fixed.
1211
1212 duration_ms = int((time.monotonic() - start) * 1000)
1213
1214 result = QualityGateResult(
1215 files_checked=len(all_files),
1216 lint_violations=lint_violations,
1217 format_violations=format_violations,
1218 secrets_findings=secrets_findings,
1219 test_failures=test_failures,
1220 duration_ms=duration_ms,
1221 per_file=per_file,
1222 )
1223 _dbg(
1224 "GIT", func,
1225 f"end lint={lint_violations} fmt={format_violations} "
1226 f"secrets={len(secrets_findings)} tests={test_failures} "
1227 f"duration_ms={duration_ms}",
1228 2,
1229 )
1230 return result
Path _gate_diagnostics_dir(Path project_root)
set[str] _disabled_fixer_rules_for(str rel_posix, set[str] run_disabled, list path_rules)
frozenset _disabled_fixers_for(set disabled_rules)
_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)
set[str] _octrc_disabled_lint_rules(Path project_root)
dict _apply_octrc_to_lint(Path project_root, ctx, dict active_rules, *, str|None profile=None)
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)
list[tuple[str, int, str]] run_secrets_scan_on_files(Path project_root, list[Path] files)
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)
list[str] _finding_kinds(str message)
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)