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.
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.
31 L2 - lifecycle: gate start/end, scope resolution
32 L3 - details: per-pass summary counts
33 L4 - deep trace: per-file check outcomes
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.
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)
65from __future__
import annotations
72from dataclasses
import dataclass, field
73from pathlib
import Path
74from typing
import Literal
76from oct.core.diagnostics
import _dbg
77from oct.core.paths
import to_project_relative
87 """Structured output from a quality-gate run.
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`.
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
102 per_file: list = field(default_factory=list)
103 """Per-file dicts suitable for ``--json`` rendering."""
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.
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`.
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.
126 effective_profile: str
128 pb_advisory: str =
""
139 """OI-527: run the three pre-gate checks and collapse their outcomes
140 into a single :class:`PreCommitResult`.
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.
154 from oct.core.git
import git_staged_deletions, git_staged_files
156 validate_ai_model_trailer,
157 validate_commit_message,
160 check_protected_branch_commit,
161 resolve_profile_for_project,
168 effective_profile = resolve_profile_for_project(
169 project_root, branch, octrc, profile,
173 msg_errors = list(validate_commit_message(message))
178 if (octrc
or {}).get(
"git", {}).get(
"require_ai_model_trailer",
False):
179 msg_errors.extend(validate_ai_model_trailer(message))
184 errors=list(msg_errors),
186 effective_profile=effective_profile,
190 staged = git_staged_files(project_root)
200 if not staged
and not git_staged_deletions(project_root):
204 errors=[
"Nothing to commit."],
206 effective_profile=effective_profile,
210 blocked, pb_message = check_protected_branch_commit(
211 branch, octrc, effective_profile,
217 errors=[pb_message]
if pb_message
else [],
219 effective_profile=effective_profile,
220 staged_files=list(staged),
221 pb_advisory=pb_message
or "",
229 effective_profile=effective_profile,
230 staged_files=list(staged),
231 pb_advisory=pb_message
or "",
242 scope: Literal[
"staged",
"changed",
"all"],
244 """Return the list of files to check based on *scope*.
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.
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
255 func =
"_resolve_scope"
256 _dbg(
"GIT", func, f
"scope={scope} root={project_root}", 2)
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)
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)
271 from oct.core.exclusions
import (
273 NEVER_EXPORT_FILE_PATTERNS,
274 WILDCARD_EXCLUDE_DIRS,
277 py_files = list(find_python_files(
279 exclude_dirs=LINTER_EXCLUDE_DIRS,
280 wildcard_exclude_dirs=WILDCARD_EXCLUDE_DIRS,
289 exclude_dirs=LINTER_EXCLUDE_DIRS,
290 wildcard_exclude_dirs=WILDCARD_EXCLUDE_DIRS,
291 patterns=NEVER_EXPORT_FILE_PATTERNS,
295 f
"all python files={len(py_files)} secret filename candidates="
296 f
"{len(secret_files)}",
299 return py_files + secret_files
306 wildcard_exclude_dirs,
309 """Yield non-Python files whose name matches any ``patterns`` glob.
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.
319 exact = set(exclude_dirs
or ())
320 wildcards = tuple(wildcard_exclude_dirs
or ())
321 results: list[Path] = []
323 def _should_exclude_dir(name: str) -> bool:
326 for pat
in wildcards:
327 if fnmatch.fnmatch(name, pat):
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"):
337 if fnmatch.fnmatch(name, pat):
338 results.append(Path(dirpath) / name)
349 """Return the diagnostics directory the gate's contexts should carry.
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
360 from oct.core.option_c_dir
import resolve_debug_config
362 return resolve_debug_config(project_root).parent
366 """Build a minimal ``LinterContext`` for the quality gate.
368 We import lazily to avoid pulling Click and the full linter on
369 import of this module.
373 project_name = project_root.name
374 return LinterContext(
375 project_root=project_root,
376 project_name=project_name,
378 tests_dir=project_root /
"tests",
379 docs_dir=project_root /
"docs",
384 """Build a ``FormatterContext`` for dry-run (or fix) mode."""
387 return FormatterContext(
388 project_root=project_root,
389 project_name=project_root.name,
391 tests_dir=project_root /
"tests",
392 docs_dir=project_root /
"docs",
394 dry_run_mode=
not fix_mode,
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"),
412 """The kinds of finding in a scanner message, as fixed names only.
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``.
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"))
425 all_files: list[Path],
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.
437 Returns a list of ``(relative_path, line, reason)`` tuples.
439 func =
"_run_secrets_pass"
440 from oct.core.exclusions
import NEVER_EXPORT_FILE_PATTERNS
443 from oct.tools.secret_scanner
import check_no_hardcoded_secrets
445 findings: list[tuple[str, int, str]] = []
446 scanned: list[str] = []
448 for path
in all_files:
449 rel = to_project_relative(path, project_root)
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)
461 if path.suffix ==
".py" and path.is_file():
463 text = path.read_text(encoding=
"utf-8")
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"):
481 if restrictions.get(
"require_waiver_for_secret_patterns"):
482 require_waiver =
True
485 passed, message = check_no_hardcoded_secrets(
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,
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)
517 findings.append((rel, outcome.first_line, outcome.message))
526 f
"secret content {rel}:{outcome.first_line} "
527 f
"kinds={_finding_kinds(outcome.message)}", 4,
529 except (OSError, UnicodeDecodeError)
as exc:
530 _dbg(
"GIT", func, f
"read error {rel}: {exc}", 1)
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)
541 _dbg(
"GIT", func, f
"total findings={len(findings)}", 3)
550 profile: str |
None =
None,
552 """Honor the owning subproject's ``.octrc.json`` in the gate lint pass.
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:
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.
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.
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.
588 func =
"_apply_octrc_to_lint"
589 from oct.core.octrc
import load_octrc_safe
595 if isinstance(profile, str)
and profile
in _PROFILES:
596 ctx.profile_name = profile
598 octrc = load_octrc_safe(project_root)
599 if not isinstance(octrc, dict)
or not octrc:
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):
610 {k: v
for k, v
in rule_overrides.items()
if isinstance(v, bool)}
619 ctx.path_rules = parse_path_rules(linter_cfg)
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
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)
644 f
"applied octrc lint overrides from {project_root} "
645 f
"at profile={ctx.profile_name}",
652 py_files: list[Path],
655 fix_mode: bool =
False,
656) -> tuple[int, list[dict]]:
657 """Run the linter on *py_files* and return (violation_count, per_file).
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.
665 func =
"_run_lint_pass"
669 active_rules = dict(_PROFILES.get(profile, _PROFILES.get(
"compact", {})))
671 project_root, ctx, active_rules, profile=profile,
675 per_file: list[dict] = []
677 for path
in py_files:
678 if not path.is_file():
681 result = _lint_single_file(path, ctx, active_rules, fix_mode)
682 if not result.get(
"compliant",
True):
684 per_file.append(result)
687 f
"lint {result.get('rel', '?')} compliant={result.get('compliant')}",
690 except Exception
as exc:
691 _dbg(
"GIT", func, f
"lint error {path}: {exc}", 1)
693 _dbg(
"GIT", func, f
"violations={violations} files={len(py_files)}", 3)
694 return violations, per_file
703_FORMAT_FIXER_RULES = {
705 "docstring":
"docstring",
706 "dbg_import":
"dbg_import",
707 "func_pattern":
"func_pattern",
712 """Return the lint rules the subproject's ``.octrc.json`` disables.
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.
719 from oct.core.octrc
import load_octrc_safe
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}
734 run_disabled: set[str],
737 """Return the lint rules disabled FOR ONE FILE, the per-path answer.
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.
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
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.
763 The file's project-relative POSIX path, as
764 :func:`~oct.core.paths.to_project_relative` renders it.
766 The run-wide disables from :func:`_octrc_disabled_lint_rules`.
767 Never mutated - one set is shared across the whole pass.
769 Validated ``(prefix, {rule: bool})`` pairs from
770 :func:`~oct.linter.oct_lint.parse_path_rules`.
772 func =
"_disabled_fixer_rules_for"
780 overrides, _changed = effective_rules({}, rel_posix, path_rules)
784 disabled = set(run_disabled)
785 for rule, active
in overrides.items():
787 disabled.discard(rule)
790 _dbg(
"GIT", func, f
"{rel_posix} disables {sorted(disabled)}", 4)
795 """Translate disabled LINT RULES into the FIXER names to skip.
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.
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.
810 name
for name, rule
in _FORMAT_FIXER_RULES.items()
811 if rule
in disabled_rules
816 py_files: list[Path],
818 fix_mode: bool =
False,
820 """Run the formatter in dry-run (or fix) mode, return violation count.
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.
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.
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.
847 func =
"_run_format_pass"
848 from oct.core.octrc
import load_octrc_safe
849 from oct.core.waivers
import inline_disabled_rules
855 octrc = load_octrc_safe(project_root)
856 path_rules = parse_path_rules(
857 octrc.get(
"linter", {})
if isinstance(octrc, dict)
else {},
861 for path
in py_files:
862 if not path.is_file():
866 to_project_relative(path, project_root),
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)
875 waived = inline_disabled_rules(text)
880 disabled = set(disabled) | waived
888 file_ctx = dataclasses.replace(
891 result = format_file(path, file_ctx)
892 fixes = result.get(
"fixes")
or {}
894 name
for name, info
in fixes.items()
895 if isinstance(info, dict)
and info.get(
"changed")
901 name
for name
in changed_fixers
902 if _FORMAT_FIXER_RULES.get(name, name)
not in disabled
908 f
"format change {result.get('path', '?')}: {sorted(effective)}",
911 elif result.get(
"changed",
False)
and not changed_fixers:
914 _dbg(
"GIT", func, f
"format change {result.get('path', '?')}", 4)
915 except Exception
as exc:
916 _dbg(
"GIT", func, f
"format error {path}: {exc}", 1)
918 _dbg(
"GIT", func, f
"violations={violations} files={len(py_files)}", 3)
923 """Run ``python -m pytest tests/`` and return the failure count.
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.
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.
935 func =
"_run_test_pass"
936 _dbg(
"GIT", func, f
"starting test pass sandbox={sandbox}", 2)
938 cmd = [sys.executable,
"-m",
"pytest",
"tests/",
"-x",
"--tb=no",
"-q"]
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)
948 result = subprocess.run(
955 returncode = result.returncode
956 failures = 0
if returncode == 0
else 1
957 _dbg(
"GIT", func, f
"test exit_code={returncode}", 3)
959 except (subprocess.TimeoutExpired, FileNotFoundError, OSError)
as exc:
960 _dbg(
"GIT", func, f
"test pass error: {exc}", 1)
971 scope: Literal[
"staged",
"changed",
"all"],
972 profile: Literal[
"proto",
"compact",
"strict"],
973 include_tests: bool =
False,
975 sandbox: bool =
False,
976) -> QualityGateResult:
977 """Orchestrate lint + format-dry-run + secrets on the scoped files.
979 When ``fix=True``, runs in two passes:
981 1. **Detect pass:** run the gate with ``fix=False`` to record the
983 2. **Fix pass:** run lint and format with ``fix_mode=True`` to apply
985 3. **Re-verify pass:** run the gate again with ``fix=False``. The
986 returned result reflects the post-fix state.
988 Secrets are **never** auto-fixed (no safe auto-fix for a hardcoded
989 password). The ``--fix`` flag does not bypass the always-blocking
995 Resolved project root containing ``.octrc.json``, ``tests/``, etc.
997 ``"staged"`` checks only the git index; ``"changed"`` checks
998 staged + unstaged + untracked; ``"all"`` checks every Python file.
1000 Lint profile: ``"proto"`` / ``"compact"`` / ``"strict"``.
1002 If True, also run ``pytest`` as a subprocess (exit code 4 on fail).
1004 If True, attempt auto-fix of lint/format violations, then re-check.
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.
1010 func =
"run_quality_gate"
1011 _dbg(
"GIT", func, f
"start scope={scope} profile={profile} fix={fix}", 2)
1019 project_root=project_root,
1022 include_tests=include_tests,
1031) -> list[tuple[str, int, str]]:
1032 """The gate's secrets pass alone, configured exactly as the gate runs it.
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).
1045 list of ``(relative_path, line, reason)`` tuples, as
1046 :func:`_run_secrets_pass` does.
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):
1065 from oct.core.workspace
import (
1070 ws_manifest_path = project_root / WORKSPACE_MANIFEST
1071 if ws_manifest_path.is_file():
1073 ws = load_workspace(project_root)
1074 except WorkspaceError:
1077 for sp
in ws.subprojects:
1079 sp_rel = sp.path.relative_to(project_root).as_posix()
1082 sub_octrc = load_octrc_safe(sp.path)
1083 sub_scanner = sub_octrc.get(
"secret_scanner", {})
1084 if not isinstance(sub_scanner, dict):
1086 sub_excl = sub_scanner.get(
"entropy_exclusions", [])
or []
1087 if not isinstance(sub_excl, list):
1089 for excl
in sub_excl:
1090 if not isinstance(excl, dict):
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)
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)
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,
1113 _dbg(
"GIT", func, f
"files={len(all_files)} findings={len(secrets_findings)}", 3)
1114 return secrets_findings
1120 profile: Literal[
"proto",
"compact",
"strict"],
1121 include_tests: bool =
False,
1123 sandbox: bool =
False,
1124) -> QualityGateResult:
1125 """Run the quality gate on an explicit file list.
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.
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
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).
1146 Lint profile: ``"proto"`` / ``"compact"`` / ``"strict"``.
1147 include_tests, fix, sandbox
1148 Same semantics as :func:`run_quality_gate`.
1150 func =
"run_quality_gate_on_files"
1151 start = time.monotonic()
1154 f
"start files={len(files)} profile={profile} fix={fix}",
1160 all_files = list(files)
1161 py_files = [f
for f
in all_files
if f.suffix ==
".py"]
1167 matrix = PROFILE_MATRIX.get(profile, PROFILE_MATRIX[
"compact"])
1169 per_file: list[dict] = []
1170 if matrix[
"lint"] !=
"skip":
1172 py_files, project_root, profile, fix_mode=
False,
1176 format_violations = 0
1177 if matrix[
"format"] !=
"skip":
1179 py_files, project_root, fix_mode=
False,
1184 if include_tests
and matrix[
"tests"] !=
"skip":
1188 if fix
and (lint_violations > 0
or format_violations > 0):
1189 _dbg(
"GIT", func,
"fix mode: applying auto-fixes", 2)
1192 if lint_violations > 0
and matrix[
"lint"] !=
"skip":
1194 if format_violations > 0
and matrix[
"format"] !=
"skip":
1198 _dbg(
"GIT", func,
"fix mode: re-verifying", 2)
1201 if matrix[
"lint"] !=
"skip":
1203 py_files, project_root, profile, fix_mode=
False,
1205 format_violations = 0
1206 if matrix[
"format"] !=
"skip":
1208 py_files, project_root, fix_mode=
False,
1212 duration_ms = int((time.monotonic() - start) * 1000)
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,
1225 f
"end lint={lint_violations} fmt={format_violations} "
1226 f
"secrets={len(secrets_findings)} tests={test_failures} "
1227 f
"duration_ms={duration_ms}",
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)