117 """OI-527: run the three pre-gate checks and collapse their outcomes
118 into a single :class:`PreCommitResult`.
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.
129 from oct.core.git
import git_staged_files
132 check_protected_branch_commit,
133 resolve_effective_profile,
136 effective_profile = resolve_effective_profile(branch, octrc, profile)
139 msg_errors = validate_commit_message(message)
144 errors=list(msg_errors),
146 effective_profile=effective_profile,
150 staged = git_staged_files(project_root)
155 errors=[
"Nothing to commit."],
157 effective_profile=effective_profile,
161 blocked, pb_message = check_protected_branch_commit(
162 branch, octrc, effective_profile,
168 errors=[pb_message]
if pb_message
else [],
170 effective_profile=effective_profile,
171 staged_files=list(staged),
172 pb_advisory=pb_message
or "",
180 effective_profile=effective_profile,
181 staged_files=list(staged),
182 pb_advisory=pb_message
or "",
193 scope: Literal[
"staged",
"changed",
"all"],
195 """Return the list of files to check based on *scope*.
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.
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
206 func =
"_resolve_scope"
207 _dbg(
"GIT", func, f
"scope={scope} root={project_root}", 2)
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)
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)
222 from oct.core.exclusions
import (
224 NEVER_EXPORT_FILE_PATTERNS,
225 WILDCARD_EXCLUDE_DIRS,
228 py_files = list(find_python_files(
230 exclude_dirs=LINTER_EXCLUDE_DIRS,
231 wildcard_exclude_dirs=WILDCARD_EXCLUDE_DIRS,
240 exclude_dirs=LINTER_EXCLUDE_DIRS,
241 wildcard_exclude_dirs=WILDCARD_EXCLUDE_DIRS,
242 patterns=NEVER_EXPORT_FILE_PATTERNS,
246 f
"all python files={len(py_files)} secret filename candidates="
247 f
"{len(secret_files)}",
250 return py_files + secret_files
257 wildcard_exclude_dirs,
260 """Yield non-Python files whose name matches any ``patterns`` glob.
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.
270 exact = set(exclude_dirs
or ())
271 wildcards = tuple(wildcard_exclude_dirs
or ())
272 results: list[Path] = []
274 def _should_exclude_dir(name: str) -> bool:
277 for pat
in wildcards:
278 if fnmatch.fnmatch(name, pat):
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"):
288 if fnmatch.fnmatch(name, pat):
289 results.append(Path(dirpath) / name)
335 all_files: list[Path],
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",
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.
347 Returns a list of ``(relative_path, line, reason)`` tuples.
349 func =
"_run_secrets_pass"
350 from oct.core.exclusions
import NEVER_EXPORT_FILE_PATTERNS
353 from oct.tools.secret_scanner
import check_no_hardcoded_secrets
355 findings: list[tuple[str, int, str]] = []
357 for path
in all_files:
358 rel = to_project_relative(path, project_root)
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)
369 if path.suffix ==
".py" and path.is_file():
371 text = path.read_text(encoding=
"utf-8")
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"):
389 if restrictions.get(
"require_waiver_for_secret_patterns"):
390 require_waiver =
True
393 passed, message = check_no_hardcoded_secrets(
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,
409 from oct.core.waivers
import parse_inline_waivers
410 waivers = parse_inline_waivers(text)
412 w[
"rule"] ==
"no_hardcoded_secrets" and w[
"reason"]
413 for w
in waivers.values()
416 _dbg(
"GIT", func, f
"waiver accepted {rel}", 4)
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)
423 _dbg(
"GIT", func, f
"total findings={len(findings)}", 3)
428 py_files: list[Path],
431 fix_mode: bool =
False,
432) -> tuple[int, list[dict]]:
433 """Run the linter on *py_files* and return (violation_count, per_file).
435 Uses ``_lint_single_file`` directly, bypassing the CLI. The profile
436 selects which rules are active via ``_PROFILES``.
438 func =
"_run_lint_pass"
442 active_rules = dict(_PROFILES.get(profile, _PROFILES.get(
"compact", {})))
445 per_file: list[dict] = []
447 for path
in py_files:
448 if not path.is_file():
451 result = _lint_single_file(path, ctx, active_rules, fix_mode)
452 if not result.get(
"compliant",
True):
454 per_file.append(result)
457 f
"lint {result.get('rel', '?')} compliant={result.get('compliant')}",
460 except Exception
as exc:
461 _dbg(
"GIT", func, f
"lint error {path}: {exc}", 1)
463 _dbg(
"GIT", func, f
"violations={violations} files={len(py_files)}", 3)
464 return violations, per_file
499 """Run ``python -m pytest tests/`` and return the failure count.
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.
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.
511 func =
"_run_test_pass"
512 _dbg(
"GIT", func, f
"starting test pass sandbox={sandbox}", 2)
514 cmd = [sys.executable,
"-m",
"pytest",
"tests/",
"-x",
"--tb=no",
"-q"]
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)
524 result = subprocess.run(
531 returncode = result.returncode
532 failures = 0
if returncode == 0
else 1
533 _dbg(
"GIT", func, f
"test exit_code={returncode}", 3)
535 except (subprocess.TimeoutExpired, FileNotFoundError, OSError)
as exc:
536 _dbg(
"GIT", func, f
"test pass error: {exc}", 1)
607 profile: Literal[
"proto",
"compact",
"strict"],
608 include_tests: bool =
False,
610 sandbox: bool =
False,
611) -> QualityGateResult:
612 """Run the quality gate on an explicit file list.
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.
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
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).
633 Lint profile: ``"proto"`` / ``"compact"`` / ``"strict"``.
634 include_tests, fix, sandbox
635 Same semantics as :func:`run_quality_gate`.
637 func =
"run_quality_gate_on_files"
638 start = time.monotonic()
641 f
"start files={len(files)} profile={profile} fix={fix}",
647 all_files = list(files)
648 py_files = [f
for f
in all_files
if f.suffix ==
".py"]
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):
666 from oct.core.workspace
import (
671 ws_manifest_path = project_root / WORKSPACE_MANIFEST
672 if ws_manifest_path.is_file():
674 ws = load_workspace(project_root)
675 except WorkspaceError:
678 for sp
in ws.subprojects:
680 sp_rel = sp.path.relative_to(project_root).as_posix()
683 sub_octrc = load_octrc_safe(sp.path)
684 sub_scanner = sub_octrc.get(
"secret_scanner", {})
685 if not isinstance(sub_scanner, dict):
687 sub_excl = sub_scanner.get(
"entropy_exclusions", [])
or []
688 if not isinstance(sub_excl, list):
690 for excl
in sub_excl:
691 if not isinstance(excl, dict):
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)
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)
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,
716 matrix = PROFILE_MATRIX.get(profile, PROFILE_MATRIX[
"compact"])
718 per_file: list[dict] = []
719 if matrix[
"lint"] !=
"skip":
721 py_files, project_root, profile, fix_mode=
False,
725 format_violations = 0
726 if matrix[
"format"] !=
"skip":
728 py_files, project_root, fix_mode=
False,
733 if include_tests
and matrix[
"tests"] !=
"skip":
737 if fix
and (lint_violations > 0
or format_violations > 0):
738 _dbg(
"GIT", func,
"fix mode: applying auto-fixes", 2)
741 if lint_violations > 0
and matrix[
"lint"] !=
"skip":
743 if format_violations > 0
and matrix[
"format"] !=
"skip":
747 _dbg(
"GIT", func,
"fix mode: re-verifying", 2)
750 if matrix[
"lint"] !=
"skip":
752 py_files, project_root, profile, fix_mode=
False,
754 format_violations = 0
755 if matrix[
"format"] !=
"skip":
757 py_files, project_root, fix_mode=
False,
761 duration_ms = int((time.monotonic() - start) * 1000)
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,
774 f
"end lint={lint_violations} fmt={format_violations} "
775 f
"secrets={len(secrets_findings)} tests={test_failures} "
776 f
"duration_ms={duration_ms}",
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[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)