8Provide a strict, deterministic linter for Option C projects that validates
9mandatory header blocks, module docstrings, diagnostics profiles, and basic
10testing presence, and can optionally auto-fix header blocks.
14- Walk the project tree from a given Option C project root or directory or single file given as an argument.
15- Skip excluded directories and the linter's own files.
16- Validate the 4-line mandatory header block for every Python module.
17- Validate the presence and structure of the module docstring.
18- Validate the presence of a Diagnostics profile block (Domain + L2/L3/L4).
19- Validate basic ``_dbg`` usage in non-trivial modules; ``_dbg`` is the
20 structured debug logger provided by the ``oc_diagnostics`` package
21 (``from oc_diagnostics import _dbg``).
22- Validate the presence of regression tests for each module.
23- Optionally auto-fix header blocks while archiving originals.
24- Produce a detailed log file and a colorized terminal summary.
25- Optionally emit machine-readable JSON to stdout (``--json``).
37- Must not modify files unless ``--fix-headers`` is explicitly requested.
38- Must treat the provided project root as authoritative for all relative paths.
39- Must write a deterministic log file into the project's ``logs/`` directory
40 when writable; warns and continues if the log directory is unwritable.
41- Must not import project modules; analysis is purely static.
42- Must be callable both from the OCT CLI and as a standalone module.
46- oct.core.exclusions (directory and wildcard exclusion lists)
47- oct.core.parsing (safe AST parsing helper)
48- oct.core.paths (project-relative path helper)
49- oct.core.waivers (inline waiver parsing)
50- oct.core.diagnostics (_dbg structured logger)
51- oct.core.option_c_dir (resolve_debug_config, resolve_octrc, option_c_dir - config and cache locations)
52- oct.core.terminal (CYAN, GREEN, RED, RESET - terminal summary colours)
53- oct.tools.secret_scanner (check_no_hardcoded_secrets and its helpers - the secrets rule, re-exported)
54- oct (lazy; __version__ - stamps the lint cache)
55- oct.core.compat (lazy; check_oc_diagnostics_compat - the version handshake with oc_diagnostics)
56- oct.core.git (lazy; git_changed_files, GitError - the file list for --changed)
57- oct.core.octrc (lazy; load_octrc - per-project overrides)
58- oct.core.workspace (lazy; find_workspace_root, load_workspace, assign_to_subproject - the lens for each file)
59- oct.deps.dependency_check (lazy; check_dependencies_match - the dependencies_match rule)
60- oct.linter.lint_baseline (lazy; save_baseline, load_baseline, diff_against_baseline - the baseline flags)
61- oct.linter.lint_cache (lazy; LintCache, current_linter_identity, content_hash, rules_hash - the incremental lint cache)
62- oct.linter.prompt_invariant_rules (lazy; check_prompt_invariants - the prompt_invariants rule)
63- oct.linter.rule_registry (lazy; get_severity, get_rule_info, list_rules - rule severities and --explain)
68import concurrent.futures
75from collections.abc
import Iterator
76from dataclasses
import dataclass, field
77from datetime
import datetime
78from pathlib
import Path
79from typing
import Any, NamedTuple, TYPE_CHECKING
82 from oct.core.workspace
import Workspace
93_OCT_PACKAGE_DIR = Path(__file__).resolve().parent.parent
99from oct.core.exclusions
import LINTER_EXCLUDE_DIRS
as EXCLUDE_DIRS
100from oct.core.exclusions
import WILDCARD_EXCLUDE_DIRS
101from oct.core.parsing
import safe_parse
102from oct.core.option_c_dir
import resolve_debug_config, resolve_octrc
103from oct.core.paths
import to_project_relative
104from oct.core.waivers
import LINE_SCOPED_RULES, apply_waivers, parse_inline_waivers
116 "syntax_error":
True,
119 "diagnostics_profile":
True,
122 "func_pattern":
True,
125 "no_hardcoded_secrets":
True,
126 "dbg_assert_safety":
True,
127 "dependencies_section":
True,
132 "dependencies_match":
True,
136 "prompt_invariants":
True,
140 "legacy_file_scoped_waiver":
True,
145 "strict": dict(_DEFAULT_RULES),
149 "diagnostics_profile":
True,
152 "func_pattern":
True,
155 "no_hardcoded_secrets":
True,
156 "dbg_assert_safety":
True,
158 "dependencies_section":
False,
159 "dependencies_match":
True,
163 "prompt_invariants":
False,
165 "legacy_file_scoped_waiver":
True,
167 "_compact_mode":
True,
172 "diagnostics_profile":
False,
175 "func_pattern":
False,
176 "tests_exist":
False,
178 "no_hardcoded_secrets":
False,
179 "dbg_assert_safety":
False,
180 "dependencies_section":
False,
181 "dependencies_match":
True,
182 "prompt_invariants":
False,
185 "legacy_file_scoped_waiver":
False,
188 "default": dict(_DEFAULT_RULES),
192 "diagnostics_profile":
False,
195 "func_pattern":
False,
196 "tests_exist":
False,
198 "no_hardcoded_secrets":
False,
199 "dbg_assert_safety":
False,
200 "dependencies_section":
False,
201 "dependencies_match":
True,
202 "prompt_invariants":
False,
203 "legacy_file_scoped_waiver":
False,
229SUMMARY_ROWS: tuple[tuple[str, str, int, str], ...] = (
230 (
"syntax_error",
"Syntax error", 2,
"pass_syntax_error"),
231 (
"header",
"Header block", 2,
"pass_header"),
232 (
"docstring",
"Docstring structure", 2,
"pass_docstring"),
233 (
"diagnostics_profile",
"Diagnostics profile", 2,
"pass_diagnostics_profile"),
234 (
"dbg_usage",
"_dbg usage", 3,
"pass_dbg"),
235 (
"dbg_import",
"_dbg import", 3,
"pass_dbg_import"),
236 (
"func_pattern",
"func pattern", 3,
"pass_func"),
237 (
"syntax_warnings",
"Syntax warnings", 2,
"pass_syntax"),
238 (
"type_hints",
"Type hints", 3,
"pass_type_hints"),
239 (
"tests_exist",
"Regression test exists", 1,
"pass_tests"),
241 (
"dependencies_section",
"Dependencies section", 2,
"pass_dependencies_section"),
242 (
"no_hardcoded_secrets",
"Hardcoded secrets", 2,
"pass_no_hardcoded_secrets"),
243 (
"dbg_assert_safety",
"dbg_assert safety", 2,
"pass_dbg_assert_safety"),
244 (
"prompt_invariants",
"Prompt invariants", 2,
"pass_prompt_invariants"),
246 "legacy_file_scoped_waiver",
247 "Legacy file-scoped waiver",
249 "pass_legacy_file_scoped_waiver",
252 (
"dependencies_match",
"Dependencies match imports", 1,
"pass_dependencies_match"),
258_TERMINAL_LABEL_WIDTH = 27
266def resolve_active_rules(
268 octrc_rules: dict |
None,
269 cli_profile: str |
None,
270) -> tuple[dict[str, bool], list[str]]:
271 """Resolve the active rule set for one linter run.
273 Plan 262 - the project's ``linter.rules`` overrides are applied on
274 top of the profile table in BOTH directions: whether the profile came
275 from ``.octrc.json`` or from an explicit ``--profile`` flag. Before
276 this plan the flag replaced the merged set wholesale, so ``oct lint
277 --profile compact`` re-fired rules the project had deliberately
278 switched off and reported files non-compliant that the commit gate
279 passed, with nothing printed to explain the difference.
284 The profile named by the project's ``linter.profile`` key, or
285 ``"default"`` when the project names none.
287 The project's ``linter.rules`` block. Non-boolean values are
288 ignored, exactly as they were before this plan.
290 A validated ``--profile`` name, or ``None``. When given it
291 chooses the BASE rule set; the overrides still apply on top.
295 (active_rules, disabled_by_octrc) : tuple[dict[str, bool], list[str]]
296 ``active_rules`` is the profile table with the overrides merged
297 in. ``disabled_by_octrc`` names, sorted, the rules the overrides
298 switched OFF that the chosen profile would otherwise have run -
299 that is, exactly what a caller reading the profile name alone
300 would wrongly have believed active. Keys starting with ``_`` are
301 mode markers (``_compact_mode``), not rules, and never appear.
303 base_profile = cli_profile
if cli_profile
else profile_name
304 active_rules = dict(_PROFILES.get(base_profile, _DEFAULT_RULES))
307 for key, value
in (octrc_rules
or {}).items()
308 if isinstance(value, bool)
310 disabled_by_octrc = sorted(
312 for key, value
in overrides.items()
314 and not key.startswith(
"_")
317 and active_rules.get(key,
True)
319 active_rules.update(overrides)
320 return active_rules, disabled_by_octrc
328def _path_rule_matches(prefix: str, rel_posix: str) -> bool:
329 """Return True when *rel_posix* is covered by *prefix*.
331 Plan 268 - the prefix form is deliberately the one the scanner's
332 ``entropy_exclusions[].path_pattern`` already uses, with one added
333 distinction that makes a single file addressable: a prefix ending in
334 ``/`` matches every file under that directory at any depth, and a
335 prefix without one matches that exact file and nothing else. There is
336 no globbing; a rule set that decides a verdict must be readable
339 if prefix.endswith(
"/"):
340 return rel_posix.startswith(prefix)
341 return rel_posix == prefix
345 active_rules: dict[str, bool],
348) -> tuple[dict[str, bool], list[str]]:
349 """Resolve the rule set for ONE file under the project's path rules.
351 Plan 268 - :func:`resolve_active_rules` returns one dict for the whole
352 run; this applies the project's ``linter.path_rules`` on top of it for
353 a single file, so a test tree can be exempt from a rule its own
354 package must keep. Every matching entry applies in list order and a
355 later entry overrides an earlier one, exactly as ``linter.rules``
356 overrides the profile.
358 Two kinds of key are REFUSED here as well as at the schema, so a
359 hand-edited octrc that slipped past a warning still cannot reach them:
360 a rule in :data:`~oct.core.waivers.LINE_SCOPED_RULES` (a waiver never
361 switches such a rule off for a whole file either - plan 193; and the
362 commit gate's secrets pass is a separate always-on pass, G-D4), and
363 any key starting with ``_``, which names a mode marker such as
364 ``_compact_mode`` rather than a rule.
369 The run's rule set from :func:`resolve_active_rules`. Never
370 mutated - the parallel linter shares one dict across threads.
372 The file's project-relative POSIX path, as
373 :func:`~oct.core.paths.to_project_relative` renders it.
375 Validated ``(prefix, {rule: bool})`` pairs from
376 :func:`parse_path_rules`, usually ``ctx.path_rules``.
380 (file_rules, changed) : tuple[dict[str, bool], list[str]]
381 ``file_rules`` is a fresh dict for this file. ``changed`` names,
382 sorted, the rules whose value ends up DIFFERENT from what
383 *active_rules* said - the net effect, so an entry that restates
384 the run's own value, or one that a later entry puts back, names
385 nothing. A missing key counts as ``True``, the same default
386 ``_rule_active`` applies.
388 file_rules = dict(active_rules)
390 return file_rules, []
392 touched: set[str] = set()
393 for prefix, rules
in path_rules:
394 if not _path_rule_matches(prefix, rel_posix):
396 for rule, value
in rules.items():
397 if not isinstance(value, bool):
399 if rule.startswith(
"_")
or rule
in LINE_SCOPED_RULES:
401 file_rules[rule] = value
405 rule
for rule
in touched
406 if file_rules.get(rule,
True) != active_rules.get(rule,
True)
408 return file_rules, changed
411def parse_path_rules(linter_cfg: dict |
None) -> list:
412 """Return the validated ``linter.path_rules`` entries, worst case ``[]``.
414 Plan 268 - the runtime twin of the ``linter.path_rules`` schema check
415 in :func:`_validate_octrc_schema`. Every shape that validator reports
416 is dropped here rather than half-applied: a malformed entry is
417 ignored, and the entries around it still apply. An entry left with no
418 usable rule after the refusals is dropped too, since it can no longer
421 Returns a list of ``(prefix, {rule: bool})`` pairs in file order, the
422 form :func:`effective_rules` and :class:`LinterContext.path_rules`
425 func =
"parse_path_rules"
426 if not isinstance(linter_cfg, dict):
428 raw = linter_cfg.get(
"path_rules")
429 if not isinstance(raw, list):
434 if not isinstance(entry, dict):
436 prefix = entry.get(
"path_prefix")
437 if not isinstance(prefix, str)
or not prefix:
439 if ".." in prefix
or prefix.startswith(
"/"):
441 rules = entry.get(
"rules")
442 if not isinstance(rules, dict):
446 for name, value
in rules.items()
447 if isinstance(value, bool)
448 and not name.startswith(
"_")
449 and name
not in LINE_SCOPED_RULES
453 parsed.append((prefix, usable))
455 _dbg(
"OCT-LINTER", func, f
"parsed {len(parsed)} path rule(s)", 3)
463def _strict_config_requested() -> bool:
464 """Return True when strict-config enforcement is active.
466 Strict mode is opt-in via the ``OCT_STRICT_CONFIG`` environment variable
467 (any truthy value: ``1``, ``true``, ``yes``, ``on``). The ``--strict-config``
468 CLI flag wired up in ``oct/cli.py`` sets this env var before dispatch.
471 val = _os.environ.get(
"OCT_STRICT_CONFIG",
"").strip().lower()
472 return val
in (
"1",
"true",
"yes",
"on")
484_MISPLACED_TOP_LEVEL_KEYS: dict[str, str] = {
485 "linter_profile":
"linter.profile",
486 "profile":
"linter.profile",
487 "pre_commit_profile":
"git.pre_commit_profile",
488 "protected_branches":
"git.protected_branches",
492def _validate_path_rule_entry(index: int, entry: object) -> list[str]:
493 """Validate one ``linter.path_rules`` entry (plan 268).
495 Returns the human-readable errors for this entry; an empty list means
496 :func:`parse_path_rules` will apply it as written. Unknown keys inside
497 an entry stay tolerated for forward compatibility, exactly as unknown
498 keys are elsewhere in the octrc.
500 where = f
"linter.path_rules[{index}]"
501 if not isinstance(entry, dict):
502 return [f
"'{where}' must be an object, got {type(entry).__name__}"]
504 errors: list[str] = []
506 prefix = entry.get(
"path_prefix")
507 if not isinstance(prefix, str)
or not prefix:
508 errors.append(f
"'{where}.path_prefix' must be a non-empty string")
511 errors.append(f
"'{where}.path_prefix' must not contain '..'")
512 if prefix.startswith(
"/"):
514 f
"'{where}.path_prefix' must be project-relative "
518 rules = entry.get(
"rules")
519 if not isinstance(rules, dict):
520 errors.append(f
"'{where}.rules' must be an object, got {type(rules).__name__}")
522 for name, value
in rules.items():
523 if name
in LINE_SCOPED_RULES:
529 f
"'{where}.rules.{name}' cannot be scoped by path "
530 f
"(line-scoped rules are never switched off)"
532 elif name.startswith(
"_"):
534 f
"'{where}.rules.{name}' is not a rule "
535 f
"(keys starting with '_' are mode markers)"
537 elif not isinstance(value, bool):
539 f
"'{where}.rules.{name}' must be a bool, "
540 f
"got {type(value).__name__}"
543 reason = entry.get(
"reason")
544 if reason
is not None and not isinstance(reason, str):
545 errors.append(f
"'{where}.reason' must be a string, got {type(reason).__name__}")
550def _validate_octrc_schema(cfg: dict) -> list[str]:
551 """Validate the structure of a loaded ``.octrc.json`` dict.
553 Returns a list of human-readable error strings. Empty list means the
554 config is structurally acceptable. Unknown keys are tolerated (forward
555 compat); only known keys are type-checked.
557 Exception (Plan 197 T3): keys in :data:`_MISPLACED_TOP_LEVEL_KEYS`
558 are reported as misplaced. They are inert where they sit, and the
559 setting they appear to configure is security-relevant, so a silent
560 no-op is the one outcome that is not acceptable.
562 errors: list[str] = []
563 if not isinstance(cfg, dict):
564 return [f
"top-level must be an object, got {type(cfg).__name__}"]
567 for bad_key, correct_key
in _MISPLACED_TOP_LEVEL_KEYS.items():
570 f
"'{bad_key}' is not a top-level key and is IGNORED; "
571 f
"move it to '{correct_key}'"
574 linter_cfg = cfg.get(
"linter")
575 if linter_cfg
is not None and not isinstance(linter_cfg, dict):
576 errors.append(f
"'linter' must be an object, got {type(linter_cfg).__name__}")
579 profile = linter_cfg.get(
"profile")
if isinstance(linter_cfg, dict)
else None
580 if profile
is not None:
581 if not isinstance(profile, str):
582 errors.append(f
"'linter.profile' must be a string, got {type(profile).__name__}")
583 elif profile
not in _PROFILES:
584 valid =
", ".join(p
for p
in _PROFILES
if not p.startswith(
"_"))
585 errors.append(f
"'linter.profile' unknown value '{profile}' (valid: {valid})")
587 if isinstance(linter_cfg, dict):
588 for key
in (
"exclude_dirs_add",
"exclude_dirs_remove",
"wildcard_exclude_add"):
589 val = linter_cfg.get(key)
592 if not isinstance(val, list):
593 errors.append(f
"'linter.{key}' must be a list, got {type(val).__name__}")
595 for i, item
in enumerate(val):
596 if not isinstance(item, str):
597 errors.append(f
"'linter.{key}[{i}]' must be a string, got {type(item).__name__}")
599 rules = linter_cfg.get(
"rules")
600 if rules
is not None:
601 if not isinstance(rules, dict):
602 errors.append(f
"'linter.rules' must be an object, got {type(rules).__name__}")
604 for rule_name, rule_val
in rules.items():
605 if not isinstance(rule_val, bool):
607 f
"'linter.rules.{rule_name}' must be a bool, got {type(rule_val).__name__}"
613 path_rules = linter_cfg.get(
"path_rules")
614 if path_rules
is not None:
615 if not isinstance(path_rules, list):
617 f
"'linter.path_rules' must be a list, "
618 f
"got {type(path_rules).__name__}"
621 for i, entry
in enumerate(path_rules):
622 errors.extend(_validate_path_rule_entry(i, entry))
627 formatter_cfg = cfg.get(
"formatter")
628 if formatter_cfg
is not None:
629 if not isinstance(formatter_cfg, dict):
631 f
"'formatter' must be an object, got {type(formatter_cfg).__name__}"
634 max_backups = formatter_cfg.get(
"max_backups_per_file")
635 if max_backups
is not None:
636 if not isinstance(max_backups, int)
or isinstance(max_backups, bool):
638 "'formatter.max_backups_per_file' must be an int, "
639 f
"got {type(max_backups).__name__}"
641 elif max_backups < 0:
643 "'formatter.max_backups_per_file' must be >= 0, "
648 git_cfg = cfg.get(
"git")
649 if git_cfg
is not None:
650 if not isinstance(git_cfg, dict):
651 errors.append(f
"'git' must be an object, got {type(git_cfg).__name__}")
653 _GIT_VALID_PROFILES = (
"proto",
"compact",
"strict",
"auto")
655 pb = git_cfg.get(
"protected_branches")
657 if not isinstance(pb, list):
658 errors.append(f
"'git.protected_branches' must be a list, got {type(pb).__name__}")
660 for i, item
in enumerate(pb):
661 if not isinstance(item, str):
662 errors.append(f
"'git.protected_branches[{i}]' must be a string, got {type(item).__name__}")
664 bn = git_cfg.get(
"branch_naming")
666 if not isinstance(bn, dict):
667 errors.append(f
"'git.branch_naming' must be an object, got {type(bn).__name__}")
669 bn_enabled = bn.get(
"enabled")
670 if bn_enabled
is not None and not isinstance(bn_enabled, bool):
671 errors.append(f
"'git.branch_naming.enabled' must be a bool, got {type(bn_enabled).__name__}")
672 bn_patterns = bn.get(
"patterns")
673 if bn_patterns
is not None:
674 if not isinstance(bn_patterns, list):
675 errors.append(f
"'git.branch_naming.patterns' must be a list, got {type(bn_patterns).__name__}")
677 for i, pat
in enumerate(bn_patterns):
678 if not isinstance(pat, str):
679 errors.append(f
"'git.branch_naming.patterns[{i}]' must be a string, got {type(pat).__name__}")
681 pcp = git_cfg.get(
"pre_commit_profile")
683 if not isinstance(pcp, str):
684 errors.append(f
"'git.pre_commit_profile' must be a string, got {type(pcp).__name__}")
685 elif pcp
not in _GIT_VALID_PROFILES:
686 errors.append(f
"'git.pre_commit_profile' unknown value '{pcp}' (valid: {', '.join(_GIT_VALID_PROFILES)})")
689 "conventional_commits",
"require_changelog_update",
690 "auto_branch_on_protected",
"add_post_check",
692 val = git_cfg.get(bool_key)
693 if val
is not None and not isinstance(val, bool):
694 errors.append(f
"'git.{bool_key}' must be a bool, got {type(val).__name__}")
696 sds = git_cfg.get(
"status_default_scope")
698 if not isinstance(sds, str):
700 f
"'git.status_default_scope' must be a string, "
701 f
"got {type(sds).__name__}"
703 elif sds
not in (
"project",
"repo",
"auto"):
705 f
"'git.status_default_scope' unknown value "
706 f
"'{sds}' (valid: project, repo, auto)"
709 oum = git_cfg.get(
"observe_unstaged_mutation")
711 if isinstance(oum, bool):
713 elif isinstance(oum, str):
714 if oum
not in (
"off",
"warn",
"block"):
716 f
"'git.observe_unstaged_mutation' unknown "
717 f
"value '{oum}' (valid: off, warn, block)"
721 f
"'git.observe_unstaged_mutation' must be a "
722 f
"bool or string, got {type(oum).__name__}"
725 ids = git_cfg.get(
"ignore_default_scope")
727 if not isinstance(ids, str):
729 f
"'git.ignore_default_scope' must be a string, "
730 f
"got {type(ids).__name__}"
732 elif ids
not in (
"workspace",
"project",
"nearest"):
734 f
"'git.ignore_default_scope' unknown value "
735 f
"'{ids}' (valid: workspace, project, nearest)"
738 for num_key
in (
"large_file_warn_mb",
"timeout",
"long_timeout"):
739 val = git_cfg.get(num_key)
741 if not isinstance(val, (int, float))
or isinstance(val, bool):
742 errors.append(f
"'git.{num_key}' must be a number, got {type(val).__name__}")
744 errors.append(f
"'git.{num_key}' must be > 0, got {val}")
747 scanner_cfg = cfg.get(
"secret_scanner")
748 if scanner_cfg
is not None:
749 if not isinstance(scanner_cfg, dict):
750 errors.append(f
"'secret_scanner' must be an object, got {type(scanner_cfg).__name__}")
752 et = scanner_cfg.get(
"code_entropy_threshold")
754 if not isinstance(et, (int, float))
or isinstance(et, bool):
756 f
"'secret_scanner.code_entropy_threshold' must be a number, got {type(et).__name__}"
760 f
"'secret_scanner.code_entropy_threshold' must be >= 0, got {et}"
762 for key
in (
"docstring_entropy_threshold",
"comment_entropy_threshold"):
763 val = scanner_cfg.get(key)
765 if not isinstance(val, (int, float))
or isinstance(val, bool):
767 f
"'secret_scanner.{key}' must be a number, got {type(val).__name__}"
771 f
"'secret_scanner.{key}' must be >= 0, got {val}"
775 excl = scanner_cfg.get(
"entropy_exclusions")
777 if not isinstance(excl, list):
778 errors.append(
"'secret_scanner.entropy_exclusions' must be an array")
780 for i, entry
in enumerate(excl):
781 if not isinstance(entry, dict):
783 f
"'secret_scanner.entropy_exclusions[{i}]' must be an object"
786 ctx_val = entry.get(
"context")
787 if ctx_val
not in (
"namedtuple_field",
"test_directory",
"module_self_doc"):
789 f
"'secret_scanner.entropy_exclusions[{i}].context' "
790 f
"must be 'namedtuple_field', 'test_directory', or 'module_self_doc'"
792 if ctx_val ==
"namedtuple_field":
793 if not isinstance(entry.get(
"field_name_pattern"), str):
795 f
"'secret_scanner.entropy_exclusions[{i}].field_name_pattern' "
798 elif ctx_val
in (
"test_directory",
"module_self_doc"):
799 if not isinstance(entry.get(
"path_pattern"), str):
801 f
"'secret_scanner.entropy_exclusions[{i}].path_pattern' "
804 restrictions = entry.get(
"restrictions", {})
805 if not isinstance(restrictions, dict):
807 f
"'secret_scanner.entropy_exclusions[{i}].restrictions' "
818SHEBANG =
"#!/usr/bin/env python3"
819ENCODING =
"# -*- coding: utf-8 -*-"
830 Immutable per-run context for the linter. Replaces module-level globals
831 so that concurrent linter invocations do not share mutable state.
836 diagnostics_dir: Path
839 log_handle: Any =
None
840 log_path: Path |
None =
None
841 log_lock: threading.Lock = field(default_factory=threading.Lock)
846 test_index: dict[str, set[str]] |
None =
None
852 secret_match_mode: str =
"substring"
855 code_entropy_threshold: float = 4.5
857 docstring_entropy_threshold: float = 5.0
858 comment_entropy_threshold: float = 5.0
860 entropy_exclusions: list = field(default_factory=list)
861 excluded_field_patterns: list = field(default_factory=list)
865 profile_name: str =
"strict"
871 path_rules: list = field(default_factory=list)
873 lint_cache: Any =
None
874 no_cache_read: bool =
False
878 dependency_memo: dict = field(default_factory=dict)
886def log(message: str, ctx: LinterContext |
None =
None) ->
None:
888 Write a single line to the linter log file.
890 OI-513: ``ctx`` is required in practice - every internal callsite
891 threads it through. The ``None`` branch is kept as a silent no-op so
892 external callers that import ``log()`` standalone do not crash.
894 if ctx
is None or ctx.log_handle
is None:
897 ctx.log_handle.write(message +
"\n")
900def print_header(title: str, ctx: LinterContext |
None =
None) ->
None:
902 Write a section header to the log.
910def report(result: bool, message: str, fix_mode: bool =
False,
911 ctx: LinterContext |
None =
None) -> bool:
913 Log a PASS/FAIL (or FIXED) line and return the result.
915 if fix_mode
and not result:
918 status =
"PASS" if result
else "FAIL"
919 log(f
"[{status}] {message}", ctx)
928def is_excluded_dir(path: Path, exclude_dirs: set, wildcard_exclude_dirs: list) -> bool:
930 Return True if the directory name should be excluded from scanning.
933 if name
in exclude_dirs:
935 for pattern
in wildcard_exclude_dirs:
941def read_file(path: Path, ctx: LinterContext |
None =
None) -> str:
943 Read a file as UTF-8, returning an empty string on failure.
946 return path.read_text(encoding=
"utf-8")
947 except (OSError, UnicodeDecodeError)
as e:
949 log(f
"[WARN] Could not read {path}: {e}", ctx)
953def _git_changed_files(root: Path) -> list[Path]:
954 """Return .py files modified in the git working tree (staged + unstaged).
956 Phase 4A (G-A7): thin shim over :func:`oct.core.git.git_changed_files`.
957 The core module enforces subprocess discipline, credential redaction,
958 and path-escape filtering; the linter's sole contract here is to
959 preserve the historical "never crash on any git failure" behaviour
960 by collapsing every ``GitError`` back to an empty list.
962 from oct.core
import git
as core_git
964 return core_git.git_changed_files(root, extensions={
".py"})
965 except core_git.GitError:
969def find_python_files(
971 exclude_dirs: set |
None =
None,
972 wildcard_exclude_dirs: list |
None =
None,
975 Yield all Python files under root, excluding the linter itself and
976 excluded directories.
981 The project root to scan.
983 Set of directory names to exclude. Defaults to the module-level
984 ``EXCLUDE_DIRS`` constant.
985 wildcard_exclude_dirs:
986 List of substrings; directories whose name contains any of these
987 are excluded. Defaults to ``WILDCARD_EXCLUDE_DIRS``.
989 if exclude_dirs
is None:
990 exclude_dirs = EXCLUDE_DIRS
991 if wildcard_exclude_dirs
is None:
992 wildcard_exclude_dirs = WILDCARD_EXCLUDE_DIRS
993 for path
in root.rglob(
"*.py"):
996 if path.resolve().is_relative_to(_OCT_PACKAGE_DIR):
998 except (OSError, ValueError):
1007 parts = path.relative_to(root).parts[:-1]
1008 if any(is_excluded_dir(Path(p), exclude_dirs, wildcard_exclude_dirs)
for p
in parts):
1018def _load_workspace_for(root: Path):
1019 """Return ``(workspace, is_workspace_root)`` for *root*, or ``(None, False)``.
1021 Mirrors ``oct.git.oct_git._maybe_load_workspace`` but is kept local so
1022 the linter never imports the git layer. Any load failure degrades to
1023 single-project mode - a malformed manifest must not stop ``oct lint``
1027 from oct.core.workspace
import find_workspace_root, load_workspace
1030 ws_path = find_workspace_root(root)
1034 workspace = load_workspace(ws_path)
1037 return workspace, ws_path.resolve() == root.resolve()
1040def group_paths_by_lens(
1041 files: list[Path], workspace:
"Workspace",
1042) -> dict[Path, list[Path]]:
1043 """Group *files* by the project root whose lens governs each one.
1045 Plan 213 / D-D. The "lens" is the ``project_root`` handed to
1046 :class:`LinterContext` / ``FormatterContext``: it decides the expected
1047 ``# path/to/file.py`` identity header, ``tests_dir``,
1048 ``diagnostics_dir``, and which ``octrc.json`` applies. Before 213 that
1049 root was whatever the caller passed - the invocation cwd - so the same
1050 file linted clean from its subproject and dirty from the workspace
1051 root, and ``oct format --fix`` would rewrite its header to the wrong
1054 A file inside a subproject is governed by that subproject; longest
1055 prefix wins for nested subprojects. Every other file keeps the
1056 workspace-root lens, matching how the commit gate treats its
1057 ``workspace`` and ``__unscoped__`` buckets.
1059 The returned mapping partitions *files*: every input path appears
1060 under exactly one root.
1062 from oct.core.workspace
import assign_to_subproject
1064 groups: dict[Path, list[Path]] = {}
1066 subproject = assign_to_subproject(path, workspace)
1067 root = subproject.path
if subproject
is not None else workspace.root
1068 groups.setdefault(root, []).append(path)
1072def _linter_flag_argv(args) -> list[str]:
1073 """Rebuild the non-positional argv from parsed *args*.
1075 Used to re-invoke the single-root engine once per lens group with the
1076 caller's flags preserved. Rebuilt from the parsed namespace rather
1077 than filtered out of the raw argv so option-with-value forms
1078 (``--jobs 4``, ``-j4``, ``--profile strict``) cannot be mistaken for
1081 flags: list[str] = []
1082 if args.fix_headers:
1083 flags.append(
"--fix-headers")
1085 flags.append(
"--dry-run")
1087 flags.append(
"--json")
1089 flags.extend([
"--jobs", str(args.jobs)])
1091 flags.extend([
"--profile", args.profile])
1092 if args.secret_match !=
"substring":
1093 flags.extend([
"--secret-match", args.secret_match])
1094 if args.entropy_threshold
is not None:
1096 [
"--code-entropy-threshold", str(args.entropy_threshold)]
1099 flags.append(
"--no-entropy")
1101 flags.append(
"--no-cache")
1103 flags.append(
"--baseline")
1104 if args.against_baseline:
1105 flags.append(
"--against-baseline")
1109def _expand_lint_targets(
1110 targets: list[str], exclude_dirs, wildcard_exclude,
1112 """Resolve CLI target strings to a de-duplicated list of Python files."""
1113 files: list[Path] = []
1114 for target
in targets:
1115 path = Path(target).resolve()
1116 if path.is_file()
and path.suffix ==
".py":
1120 find_python_files(path, exclude_dirs, wildcard_exclude)
1122 return list(dict.fromkeys(files))
1130def project_required_docs(ctx: LinterContext) -> list[Path]:
1131 """Return the project-level documents ``oct lint`` requires for *ctx*.
1133 Plan 213 / T0: the ``debug_config.json`` entry resolves through
1134 :func:`oct.core.option_c_dir.resolve_debug_config` rather than
1135 ``ctx.diagnostics_dir / "debug_config.json"``. The old expression
1136 hard-coded the pre-FS-539 layout, so a project migrated to
1137 ``.option_c/`` was reported as missing a config file it actually had
1138 - and, worse, the workaround was to keep an ``oc_diagnostics/``
1139 directory in the project root purely to satisfy the check, which is
1140 exactly the directory that shadowed the real ``oc_diagnostics``
1141 package on every ``python -m pytest`` run.
1143 ``ctx.diagnostics_dir`` is untouched: it still drives the legacy
1144 ``diagnostics/`` deprecation warning in :func:`run_linter`.
1147 ctx.docs_dir /
"ARCHITECTURE.md",
1148 ctx.tests_dir /
"run_tests.py",
1149 ctx.tests_dir /
"Tests_README.md",
1150 resolve_debug_config(ctx.project_root),
1159def expected_path_header(path: Path, ctx: LinterContext) -> str:
1161 Compute the expected file identity header for a given module path.
1163 rel = to_project_relative(path, ctx.project_root)
1167def validate_header_block(
1168 lines: list[str], path: Path, ctx: LinterContext,
1169) -> tuple[bool, list[str]]:
1171 Validate the mandatory 4-line header block.
1173 Returns (ok, errors) where:
1174 - ok = True if header block is correct
1175 - errors = list of error messages
1177 errors: list[str] = []
1180 errors.append(
"File too short to contain mandatory header block")
1181 return False, errors
1183 if lines[0].strip() != SHEBANG:
1184 errors.append(
"Shebang missing or incorrect")
1186 if lines[1].strip() != ENCODING:
1187 errors.append(
"Encoding line missing or incorrect")
1189 if lines[2].strip() != expected_path_header(path, ctx):
1190 errors.append(
"File identity header missing or incorrect")
1192 if lines[3].strip() !=
"":
1193 errors.append(
"Missing mandatory blank line after header block")
1195 return len(errors) == 0, errors
1198def _header_boundary(lines: list[str], path: Path, ctx: LinterContext) -> int:
1200 Return the line index where file content begins, after any existing
1201 header elements. OI-509: thin wrapper that delegates to
1202 :func:`oct.core.parsing.header_boundary` so linter and formatter share
1205 from oct.core.parsing
import header_boundary
1206 return header_boundary(
1207 lines, path, expected_identity=expected_path_header(path, ctx),
1211def fix_header_block(path: Path, text: str, ctx: LinterContext) -> bool:
1213 Rewrite the top of the file to enforce the Option C 4-line header.
1214 The original file is archived in a `.linter_archive` directory.
1216 lines = text.splitlines()
1219 archive_dir = path.parent /
".linter_archive"
1220 archive_dir.mkdir(exist_ok=
True)
1221 ts = datetime.now().strftime(
"%Y%m%d-%H%M%S")
1222 backup_path = archive_dir / f
"{path.name}.{ts}.bak"
1223 shutil.copy2(path, backup_path)
1225 boundary = _header_boundary(lines, path, ctx)
1226 content = lines[boundary:]
1231 expected_path_header(path, ctx),
1235 new_text =
"\n".join(new_header + content)
1236 path.write_text(new_text, encoding=
"utf-8")
1240def preview_header_fix(path: Path, ctx: LinterContext) -> str:
1242 Compute what fix_header_block would produce, without writing.
1243 Returns the corrected 4-line header block as a string.
1248 expected_path_header(path, ctx),
1251 return "\n".join(new_header)
1259def _docstring_for_rule(
1260 text: str, tree: ast.Module |
None =
None,
1261) -> tuple[str |
None, str]:
1262 """The module docstring a docstring-reading rule judges, or why there is none.
1264 Located structurally, by :func:`_extract_module_docstring` (the plan-204
1265 authority): the module's first statement must be the string literal.
1266 Before 2026-09-28 the docstring and dependencies rules matched the first
1267 triple-quoted string anywhere, so a "docstring" below an import - a bare
1268 string statement Python does not treat as the docstring - passed
1269 (``oct new`` generated exactly that).
1271 A file that does not parse keeps the old text match: ``syntax_error``
1272 already fails it, and "the first statement must be the docstring" would
1273 misreport a file whose only problem is a syntax error further down.
1275 Returns ``(docstring, "")`` or ``(None, message)``. *tree*, when given,
1276 must be the parse of *text*; it spares both parses below.
1278 doc = _extract_module_docstring(text, tree)
1282 tree, _ = safe_parse(text)
1283 if tree
is not None:
1284 return None,
"Missing module docstring (the first statement must be the docstring)"
1285 m = re.search(
r'"""(.*?)"""', text, re.DOTALL)
1286 return (m.group(1),
"")
if m
else (
None,
"Missing module docstring")
1289def check_docstring(text: str, tree: ast.Module |
None =
None) -> tuple[bool, str]:
1291 Validate that a module docstring exists and contains the required
1292 Option C sections. The docstring is found by :func:`_docstring_for_rule`;
1293 pass *tree* (the parse of *text*) to spare a re-parse.
1295 doc, why = _docstring_for_rule(text, tree)
1298 required = [
"Purpose",
"Responsibilities",
"Diagnostics",
"Contracts"]
1299 missing = [s
for s
in required
if s
not in doc]
1301 return False, f
"Missing sections: {', '.join(missing)}"
1305_DEP_SECTION_RE = re.compile(
1306 r'Dependencies\s*\n\s*[-]+\s*\n(.*?)(?:\n\s*\n|\Z)',
1309_DEP_LINE_RE = re.compile(
r'^\s*-\s+\S+\s*\([^)]+\)\s*$', re.MULTILINE)
1310_DEP_BULLET_RE = re.compile(
r'^\s*[-*]\s+\S', re.MULTILINE)
1313def check_dependencies_section(
1314 text: str, tree: ast.Module |
None =
None,
1315) -> tuple[bool, str]:
1317 Validate the Dependencies section of a module docstring at strict profile.
1319 Spec reference: Appendix C - Dependencies docstring format. Each entry
1320 must follow ``- module_name (purpose description)`` so ``oct deps`` can
1321 parse the dependency graph.
1323 Returns ``(ok, msg)``. Empty Dependencies sections are accepted (a module
1324 may have no project-internal dependencies); malformed entries are rejected.
1325 The docstring is found by :func:`_docstring_for_rule` (structural since
1326 2026-09-28, operator ruling; it matched the first triple-quoted string
1327 anywhere before). Pass *tree* (the parse of *text*) to spare a re-parse.
1329 doc, why = _docstring_for_rule(text, tree)
1332 if "Dependencies" not in doc:
1333 return False,
"Missing Dependencies section in module docstring"
1334 section_match = _DEP_SECTION_RE.search(doc)
1335 if not section_match:
1336 return False,
"Malformed Dependencies section (missing underline)"
1337 section = section_match.group(1)
1338 bullets = _DEP_BULLET_RE.findall(section)
1341 valid_lines = _DEP_LINE_RE.findall(section)
1342 if len(valid_lines) != len(bullets):
1344 "Malformed Dependencies entry; expected '- module_name (purpose)'"
1352_SOURCE_LINE_BREAK_RE = re.compile(
r'\r\n|\r|\n')
1355_STRING_PREFIX_RE = re.compile(
r'[A-Za-z]*')
1358class _DocstringSlice(NamedTuple):
1359 """Where the module docstring is, in SOURCE-TEXT terms.
1361 ``text`` is the raw source between the quotes - NOT the decoded value:
1362 ``source[open_index:close_index] == text``. ``quote`` is the delimiter
1363 that surrounds it and ``prefix`` is the literal's string prefix, so
1364 ``prefix + quote + text + quote`` reconstructs the literal exactly and
1365 decodes to the value ``ast.get_docstring`` reports. The prefix matters
1366 for that reconstruction alone: an ``r``-prefixed docstring decodes
1367 differently with it than without.
1378 text: str, line_starts: list[int], lineno: int, col_offset: int,
1381 Convert an AST ``(lineno, col_offset)`` pair into an offset into *text*.
1383 ``ast`` reports ``col_offset`` as a UTF-8 BYTE offset within the line, so
1384 a line containing any non-ASCII character needs the prefix re-decoded
1385 rather than added. Returns ``None`` if the position is not addressable,
1386 so callers fail closed rather than slicing at a guessed offset.
1388 if lineno < 1
or lineno > len(line_starts):
1390 line_start = line_starts[lineno - 1]
1391 line_end = line_starts[lineno]
if lineno < len(line_starts)
else len(text)
1392 line = text[line_start:line_end]
1394 return line_start + col_offset
if col_offset <= len(line)
else None
1396 prefix = line.encode(
"utf-8")[:col_offset].decode(
"utf-8")
1397 except UnicodeDecodeError:
1399 return line_start + len(prefix)
1402def _module_docstring_slice(
1403 text: str, tree: ast.Module |
None =
None,
1404) -> _DocstringSlice |
None:
1406 Locate the module docstring structurally, and report it as SOURCE TEXT.
1408 204: this is the one authority for "where is the module docstring". It
1409 asks ``ast`` - the same question Python itself answers - instead of
1410 matching quotes with a regex, which could neither see a docstring
1411 containing a column-0 ``def`` (``^\\s*(def|class)\\s`` matches inside
1412 string literals) nor rank a ``'''`` docstring above a ``\"\"\"`` banner.
1414 The RAW SLICE, not ``ast.get_docstring``'s decoded value, is the return
1415 contract, and it is what lets one helper serve both consumers: the
1416 formatter's ``fix_docstring`` must find the docstring *in the source*
1417 to know where to append Option C sections, and a decoded value is not
1418 there to be found on any docstring carrying a backslash or an escaped
1419 quote (plan 200 measured 16 such files).
1421 Returns ``None`` - "no docstring" - when the source does not parse, when
1422 the module does not open with a string literal, or when the literal
1423 cannot be expressed as one delimited slice (implicit concatenation, an
1424 f-string). Every one of those is a fail-closed answer: callers must not
1425 widen their scope on ``None``.
1427 *tree*, when given, must be the parse of *text* itself. The linter
1428 passes the tree it already built so a file is parsed once, not once per
1429 docstring-reading rule (measured 2026-09-28: those re-parses were two
1430 of every three parses in a cold ``oct lint``). Without it the source is
1431 parsed here, as before.
1435 tree = ast.parse(text)
1436 except (SyntaxError, ValueError):
1441 if not isinstance(node, ast.Expr):
1443 literal = node.value
1444 if not isinstance(literal, ast.Constant)
or not isinstance(literal.value, str):
1446 if node.end_lineno
is None or node.end_col_offset
is None:
1450 line_starts.extend(m.end()
for m
in _SOURCE_LINE_BREAK_RE.finditer(text))
1451 begin = _source_offset(text, line_starts, node.lineno, node.col_offset)
1452 end = _source_offset(text, line_starts, node.end_lineno, node.end_col_offset)
1453 if begin
is None or end
is None or not 0 <= begin < end <= len(text):
1456 segment = text[begin:end]
1457 prefix_match = _STRING_PREFIX_RE.match(segment)
1458 prefix = prefix_match.group(0)
if prefix_match
else ""
1459 if set(prefix.lower()) - {
"r",
"u"}:
1461 body = segment[len(prefix):]
1463 (q
for q
in (
'"""',
"'''",
'"',
"'")
if body.startswith(q)),
None,
1465 if quote
is None or len(body) < 2 * len(quote)
or not body.endswith(quote):
1467 inner = body[len(quote):-len(quote)]
1474 if ast.literal_eval(segment) != literal.value:
1476 except (SyntaxError, ValueError):
1479 open_index = begin + len(prefix) + len(quote)
1480 return _DocstringSlice(
1481 inner, open_index, open_index + len(inner), quote, prefix,
1485def _extract_module_docstring(
1486 text: str, tree: ast.Module |
None =
None,
1489 Return the module docstring as it is written in the source, or ``None``.
1491 The returned value is the raw text between the quotes - escapes
1492 undecoded - so it can be located in the source with ``str.find``. See
1493 :func:`_module_docstring_slice`, which is the authority; this is the
1494 value-only view of it that most callers want. *tree* is passed through
1497 located = _module_docstring_slice(text, tree)
1498 return None if located
is None else located.text
1501def check_diagnostics_profile(
1502 text: str, tree: ast.Module |
None =
None,
1503) -> tuple[bool, str]:
1505 Validate that a Diagnostics profile is present in the module docstring.
1507 Scopes the search to the module docstring so in-code string literals that
1508 happen to contain 'L2', 'L3', 'L4', 'Diagnostics' or 'Domain:' cannot
1511 204: no docstring means the rule FAILS. It used to mean "scan the whole
1512 file instead", which is strictly more permissive - a module whose
1513 docstring carried no profile at all could pass on tokens found in its
1514 code, and under the AST authority every unparseable file would take that
1515 path. A rule may not become more lenient the less it can see. An
1516 unparseable file is separately blocked by the ``syntax_error`` rule
1517 (170/F21), so this costs no file a correct verdict. Pass *tree* (the
1518 parse of *text*) to spare a re-parse.
1520 docstring = _extract_module_docstring(text, tree)
1521 if docstring
is None:
1522 return False,
"Missing Diagnostics section in module docstring"
1524 if "Diagnostics" not in scope:
1525 return False,
"Missing Diagnostics section in module docstring"
1526 if "Domain:" not in scope:
1527 return False,
"Missing Diagnostics domain in module docstring"
1528 if "L2" not in scope
or "L3" not in scope
or "L4" not in scope:
1529 return False,
"Missing Diagnostics level definitions in module docstring"
1533def check_dbg_usage(text: str, tree: ast.Module |
None =
None) -> tuple[bool, str]:
1535 Validate that non-trivial modules call ``_dbg()`` for diagnostics.
1537 ``_dbg`` is the structured debug logger from ``oc_diagnostics``
1538 (``from oc_diagnostics import _dbg``). Files shorter than 20 lines
1539 are considered trivial scaffolding and are exempt from this check.
1541 Uses AST parsing to detect actual ``_dbg()`` call nodes, avoiding
1542 false positives from comments or string literals.
1544 if len(text.splitlines()) < 20:
1547 tree, _ = safe_parse(text)
1550 for node
in ast.walk(tree):
1551 if (isinstance(node, ast.Call)
1552 and isinstance(node.func, ast.Name)
1553 and node.func.id ==
"_dbg"):
1555 if (isinstance(node, ast.Call)
1556 and isinstance(node.func, ast.Attribute)
1557 and node.func.attr ==
"_dbg"):
1559 return False,
"Missing _dbg usage"
1575_DBG_IMPORT_SOURCES = (
"oc_diagnostics",
"oct.core.diagnostics")
1578def check_dbg_import(text: str, tree: ast.Module |
None =
None) -> tuple[bool, str]:
1580 Verify that ``_dbg`` is imported at module level from an accepted source.
1582 The canonical import is ``from oc_diagnostics import _dbg``. oct's own
1583 fail-soft re-export, ``from oct.core.diagnostics import _dbg``, is the
1584 one other accepted form (263/P1; see :data:`_DBG_IMPORT_SOURCES` for
1585 why). Files shorter than 20 lines are exempt (same threshold as
1586 ``check_dbg_usage``). Aliased imports (``import _dbg as ...``) are not
1587 accepted - the Option C spec requires the canonical form - and neither
1588 is a plain ``import <module>``, which binds no ``_dbg`` name at all.
1590 if len(text.splitlines()) < 20:
1593 tree, _ = safe_parse(text)
1596 for node
in ast.iter_child_nodes(tree):
1597 if (isinstance(node, ast.ImportFrom)
1598 and node.module
in _DBG_IMPORT_SOURCES
1599 and any(alias.name ==
"_dbg" and alias.asname
is None
1600 for alias
in node.names)):
1603 "Missing `from oc_diagnostics import _dbg` "
1604 "(or `from oct.core.diagnostics import _dbg` within oct)"
1608def _walk_function_body(
1609 func_node: ast.FunctionDef | ast.AsyncFunctionDef,
1610) -> Iterator[ast.AST]:
1612 Yield AST nodes within a function body, stopping at nested function
1613 boundaries so that each function gets an independent check.
1615 for node
in ast.walk(func_node):
1616 if node
is func_node:
1618 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1624def _direct_body_nodes(func_node: ast.AST) -> Iterator[ast.AST]:
1626 Yield AST nodes that are directly in this function's body (not in
1627 nested function bodies). Uses an iterative approach to avoid
1628 descending into nested FunctionDef/AsyncFunctionDef.
1630 A nested definition that is itself a statement of the body is skipped
1631 too (2026-09-28). Before, only nested definitions met as CHILDREN were
1632 skipped, so a decorator's inner ``wrapper`` - a body statement - had
1633 its ``_dbg`` calls credited to the decorator as well, and func_pattern
1634 flagged the decorator for a call it never makes.
1637 stmt
for stmt
in func_node.body
1638 if not isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef))
1643 for child
in ast.iter_child_nodes(node):
1644 if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
1649def find_func_pattern_violations(
1651 tree: ast.Module |
None =
None,
1652) -> list[tuple[str, int]]:
1654 Return ``[(func_name, lineno), ...]`` for every function that calls
1655 ``_dbg()`` without a prior ``func = "..."`` assignment.
1657 ``func`` also counts as present (2026-09-28, operator ruling) when it
1658 is one of the function's parameters - a logging helper such as
1659 oc_diagnostics' ``dbg_timed`` takes the caller's context as ``func`` -
1660 or when it is assigned from any expression, not only a literal - a
1661 decorator's wrapper labels records ``func = fn.__name__``. Before, both
1662 were flagged, and the formatter's fix would have overwritten the
1663 parameter with a literal, breaking every caller's label.
1665 Returns an empty list for trivial files (< 20 lines) or files with
1666 syntax errors. This is the single source of truth for func-pattern
1667 detection - used by :func:`check_func_pattern` (linter pass/fail)
1668 and by :func:`oct.formatter.oct_format.fix_func_pattern` (auto-fix).
1670 lines = text.splitlines()
1675 tree, _ = safe_parse(text)
1679 violations: list[tuple[str, int]] = []
1682 node
for node
in ast.walk(tree)
1683 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
1686 for func_node
in func_nodes:
1687 params = func_node.args
1690 for a
in (*params.posonlyargs, *params.args, *params.kwonlyargs)
1692 has_dbg_call =
False
1696 for stmt
in func_node.body:
1699 if (isinstance(stmt, ast.Expr)
1700 and isinstance(stmt.value, ast.Constant)
1701 and isinstance(stmt.value.value, str)):
1703 if (isinstance(stmt, ast.Assign)
1704 and len(stmt.targets) == 1
1705 and isinstance(stmt.targets[0], ast.Name)
1706 and stmt.targets[0].id ==
"func"):
1709 if (isinstance(stmt, ast.AnnAssign)
1710 and stmt.value
is not None
1711 and isinstance(stmt.target, ast.Name)
1712 and stmt.target.id ==
"func"):
1716 if non_doc_seen >= 3:
1727 for node
in _direct_body_nodes(func_node):
1728 if isinstance(node, ast.Call)
and (
1729 (isinstance(node.func, ast.Name)
and node.func.id ==
"_dbg")
1730 or (isinstance(node.func, ast.Attribute)
1731 and node.func.attr ==
"_dbg")
1737 violations.append((func_node.name, func_node.lineno))
1742def check_func_pattern(text: str, tree: ast.Module |
None =
None) -> tuple[bool, str]:
1744 Validate that every function body containing ``_dbg()`` first defines
1745 ``func = "function_name"``.
1747 Uses ``ast.parse()`` to build a syntax tree, then walks each function's
1748 body (stopping at nested function boundaries) to check for the pattern.
1749 Exempt trivial files (< 20 lines) - same threshold as check_dbg_usage.
1751 Delegates to :func:`find_func_pattern_violations` for the actual
1752 detection; this wrapper converts the result into a ``(bool, message)``
1753 pair for the linter pipeline.
1755 lines = text.splitlines()
1757 return True,
"trivial"
1760 tree, _ = safe_parse(text)
1762 return True,
"syntax error; skipped"
1764 violations = find_func_pattern_violations(text, tree=tree)
1766 names =
", ".join(f
"{name}()" for name, _
in violations)
1767 return False, f
'Missing `func = "..."` before `_dbg()` in: {names}'
1774from oct.tools.secret_scanner
import (
1775 _SECRET_NAME_PATTERNS,
1778 _name_matches_secret,
1779 check_no_hardcoded_secrets,
1783def check_syntax_warnings(
1784 text: str, warnings_list: list[str] |
None =
None,
1785) -> tuple[bool, str]:
1787 Check for Python syntax warnings (invalid escape sequences etc.).
1789 Python 3.12+ emits ``SyntaxWarning`` for invalid escape sequences in
1790 string literals. These will become ``SyntaxError`` in a future Python
1791 version, so they should be surfaced as lint findings.
1793 if warnings_list
is None:
1794 _, warnings_list = safe_parse(text)
1796 return False, f
"{len(warnings_list)} syntax warning(s): {'; '.join(warnings_list)}"
1804_AST_SKIP_MSG =
"NOT-RUN (syntax error prevents AST parsing)"
1807def _describe_syntax_error(text: str) -> str:
1808 """Re-parse *text* (known to fail ``safe_parse()``) to recover a
1809 human-readable SyntaxError description for the ``syntax_error`` rule.
1811 ``safe_parse()`` collapses every parse failure to ``(None, [])`` -
1812 this is the one place that re-raises to read the actual message.
1816 except SyntaxError
as e:
1820 if e.lineno
is not None and "line" not in e.msg.lower():
1821 return f
"{e.msg} (line {e.lineno})"
1823 except (ValueError, RecursionError)
as e:
1829 return "unknown parse failure"
1832def check_type_hints(
1833 text: str, tree: ast.Module |
None =
None, compact_mode: bool =
False,
1834) -> tuple[bool, str]:
1836 Validate that public functions have type annotations on all parameters
1837 and a return annotation.
1839 Private functions (leading underscore) and dunder methods are always
1840 exempt - they are internal implementation details (OI-417). The
1841 ``compact_mode`` parameter is retained for API compatibility but no
1842 longer changes behaviour.
1844 Trivial files (< 20 lines) are exempt.
1846 if len(text.splitlines()) < 20:
1849 tree, _ = safe_parse(text)
1851 return True,
"syntax error; skipped"
1853 violations: list[str] = []
1855 for node
in ast.walk(tree):
1856 if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1859 if node.name.startswith(
"__")
and node.name.endswith(
"__"):
1863 if node.name.startswith(
"_"):
1868 for arg
in node.args.args:
1869 if arg.arg
in (
"self",
"cls"):
1871 if arg.annotation
is None:
1872 missing_params.append(arg.arg)
1875 missing_return = node.returns
is None
1877 if missing_params
or missing_return:
1880 parts.append(f
"params: {', '.join(missing_params)}")
1882 parts.append(
"return type")
1883 violations.append(f
"{node.name}() missing {'; '.join(parts)}")
1886 return False, f
"Type hints: {'; '.join(violations[:5])}"
1894_SECURITY_SENSITIVE_KEYWORDS = frozenset({
1895 "auth",
"authenticate",
"authorize",
"permission",
"security",
1896 "validate",
"verify",
"credential",
"login",
"session",
"sanitize",
1897 "encrypt",
"decrypt",
"password",
"token",
1901def _is_security_sensitive_name(name: str) -> bool:
1902 lowered = name.lower()
1903 return any(kw
in lowered
for kw
in _SECURITY_SENSITIVE_KEYWORDS)
1906def check_dbg_assert_safety(
1907 text: str, tree: ast.Module |
None =
None, filename: str =
""
1908) -> tuple[bool, str]:
1910 Flag ``_dbg_assert`` calls made inside security-sensitive contexts.
1912 ``_dbg_assert`` is a diagnostic aid - in production mode it is a no-op.
1913 Per AP-16 it must never be used for safety invariants (authentication,
1914 authorization, input validation, crypto, etc.). This check walks the AST
1915 looking for calls named ``_dbg_assert`` (bare or attribute access) and
1916 verifies that none of the enclosing functions, classes, or the module's
1917 filename stem contain a security-sensitive keyword.
1920 tree, _ = safe_parse(text)
1922 return True,
"syntax error; skipped"
1925 module_sensitive =
False
1927 stem = Path(filename).stem
1928 if _is_security_sensitive_name(stem):
1929 module_sensitive =
True
1931 violations: list[str] = []
1933 def _walk(node: ast.AST, scopes: list[str]) ->
None:
1934 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
1935 new_scopes = scopes + [node.name]
1936 for child
in ast.iter_child_nodes(node):
1937 _walk(child, new_scopes)
1940 if isinstance(node, ast.Call):
1943 if isinstance(func, ast.Name):
1945 elif isinstance(func, ast.Attribute):
1946 call_name = func.attr
1947 if call_name ==
"_dbg_assert":
1948 sensitive_scope =
None
1949 for scope
in scopes:
1950 if _is_security_sensitive_name(scope):
1951 sensitive_scope = scope
1953 if sensitive_scope
is None and module_sensitive:
1954 sensitive_scope = f
"module '{Path(filename).stem}'"
1955 if sensitive_scope
is not None:
1957 f
"L{node.lineno}: _dbg_assert used in security-sensitive "
1958 f
"context '{sensitive_scope}' - use a hard assertion (AP-16)"
1961 for child
in ast.iter_child_nodes(node):
1962 _walk(child, scopes)
1967 return False,
"; ".join(violations[:5])
1975_IMPORT_STMT_RE = re.compile(
1976 r'(?:^|\s)(?:import|from)\s+([a-zA-Z_][a-zA-Z0-9_]*)',
1981def build_test_index(ctx: LinterContext) -> dict[str, set[str]]:
1983 OI-510 / FS-516: one-shot scan of ``ctx.tests_dir`` producing two
1984 lookup sets so :func:`check_tests_exist` becomes O(1) per module
1985 instead of re-walking the tests tree for every file linted.
1987 The index is cached on :attr:`LinterContext.test_index` - subsequent
1988 calls return the cached value directly.
1990 Returns a dict with:
1992 * ``name_stems`` - stems ``<s>`` for which ``test_<s>.py`` exists.
1993 * ``imported_stems`` - module stems referenced in any test file by
1994 a whole-word ``import`` / ``from`` statement.
1996 if ctx.test_index
is not None:
1997 return ctx.test_index
1999 name_stems: set[str] = set()
2000 imported_stems: set[str] = set()
2002 if ctx.tests_dir.is_dir():
2003 for test_file
in ctx.tests_dir.rglob(
"*.py"):
2004 stem = test_file.stem
2005 if stem.startswith(
"test_"):
2006 name_stems.add(stem[len(
"test_"):])
2008 text = test_file.read_text(encoding=
"utf-8")
2009 except (OSError, UnicodeDecodeError):
2011 for m
in _IMPORT_STMT_RE.finditer(text):
2012 imported_stems.add(m.group(1))
2015 "name_stems": name_stems,
2016 "imported_stems": imported_stems,
2018 return ctx.test_index
2021def check_tests_exist(module_path: Path, ctx: LinterContext) -> bool:
2023 Validate that at least one test file references the module either by
2024 naming convention (``test_<module>.py``) or by an explicit import
2025 statement (``import <module>`` or ``from <module>``).
2027 Two-stage check, now O(1) per call: consults the index built once
2028 per :func:`run_linter` invocation by :func:`build_test_index`.
2030 index = build_test_index(ctx)
2031 stem = module_path.stem
2032 return stem
in index[
"name_stems"]
or stem
in index[
"imported_stems"]
2039from oct.core.terminal
import CYAN, GREEN, RED, RESET
2040from oct.core.diagnostics
import _dbg
2043_MAX_LINT_LOG_FILES = 10
2046def _rotate_lint_logs(log_dir: Path, max_files: int = _MAX_LINT_LOG_FILES) ->
None:
2047 """Remove old lint log files, keeping only the most recent max_files."""
2048 logs = sorted(log_dir.glob(
"oct_lint_output-*.txt"), key=
lambda p: p.name)
2049 excess = len(logs) - max_files
2050 for old
in logs[:excess]:
2057def _dbg_rule_outcomes(rel: str, checks: dict, cached: bool) ->
None:
2058 """One L3 record per rule: which rules ran on *rel* and what each found.
2060 The individual check functions log nothing, so without this a debug
2061 log showed that a file was linted but not which rules ran or what they
2062 said (found by the chart C runtime trace of ``oct git commit``,
2063 2026-09-27). A failing rule's message is included, truncated, except
2064 ``no_hardcoded_secrets``: its message can quote the matched text, so
2065 only its verdict is logged.
2067 func =
"_dbg_rule_outcomes"
2068 source =
" (cached)" if cached
else ""
2069 for rule, check
in checks.items():
2070 passed = bool(check.get(
"pass",
True))
2072 if not passed
and rule !=
"no_hardcoded_secrets":
2073 detail = f
": {str(check.get('message', ''))[:120]}"
2074 _dbg(
"OCT-LINTER", func,
2075 f
"rule {rule} {'pass' if passed else 'FAIL'} "
2076 f
"[{check.get('severity', '?')}] {rel}{source}{detail}", 3)
2079def _lint_single_file(
2084 dry_run: bool =
False,
2086 """Lint one Python file and return a result dict with check outcomes.
2088 Thread-safe: logging uses ctx.log_lock internally.
2090 func =
"_lint_single_file"
2091 text = read_file(path, ctx)
2092 rel = to_project_relative(path, ctx.project_root)
2101 active_rules, path_rule_changes = effective_rules(
2102 active_rules, rel, ctx.path_rules,
2106 log(f
"--- {rel} ---", ctx)
2111 waivers = parse_inline_waivers(text)
2121 waived_rules: set[str] = {
2122 w[
"rule"]
for w
in waivers.values()
2123 if w[
"reason"]
is not None and w[
"rule"]
not in LINE_SCOPED_RULES
2127 def _rule_active(rule: str) -> bool:
2128 return active_rules.get(rule,
True)
and rule
not in waived_rules
2131 _file_hash: str |
None =
None
2132 _rules_hash: str |
None =
None
2133 if ctx.lint_cache
is not None and not fix_mode:
2135 _file_hash = content_hash(text)
2136 _rules_hash = rules_hash(active_rules)
2137 if not ctx.no_cache_read:
2138 cached = ctx.lint_cache.get(_file_hash, _rules_hash)
2139 if cached
is not None:
2140 log(
" (cached)", ctx)
2141 result = dict(cached.rule_results)
2149 _detail = dict(cached.json_detail)
if cached.json_detail
else {}
2150 _detail[
"path"] = rel
2156 if path_rule_changes:
2157 _detail[
"path_rules"] = path_rule_changes
2159 _detail.pop(
"path_rules",
None)
2160 result[
"json_detail"] = _detail
2161 result[
"fixed"] =
False
2162 result[
"rel"] = str(rel)
2174 _detail_checks = dict(_detail.get(
"checks", {}))
2184 _cache_tree, _ = safe_parse(text)
2185 ok_syntax_error_fresh = _cache_tree
is not None
2186 syntax_error_msg_fresh = (
2187 None if ok_syntax_error_fresh
else _describe_syntax_error(text)
2189 result[
"syntax_error"] = ok_syntax_error_fresh
2190 _detail_checks[
"syntax_error"] = {
2191 **_detail_checks.get(
"syntax_error", {}),
2192 "pass": ok_syntax_error_fresh,
2193 "message": syntax_error_msg_fresh
if not ok_syntax_error_fresh
else "OK",
2194 "severity": _SB_CACHE,
2197 if _rule_active(
"header"):
2198 _lines = text.splitlines()
2199 ok_header_fresh, header_errors_fresh = validate_header_block(
2202 result[
"header"] = ok_header_fresh
2203 if "header" in _detail_checks:
2204 _detail_checks[
"header"] = {
2205 **_detail_checks[
"header"],
2206 "pass": ok_header_fresh,
2208 ", ".join(header_errors_fresh)
2209 if header_errors_fresh
else "OK"
2212 if _rule_active(
"tests_exist"):
2213 ok_test_fresh = check_tests_exist(path, ctx)
2214 result[
"tests_exist"] = ok_test_fresh
2215 if "tests_exist" in _detail_checks:
2216 _detail_checks[
"tests_exist"] = {
2217 **_detail_checks[
"tests_exist"],
2218 "pass": ok_test_fresh,
2219 "message":
"OK" if ok_test_fresh
else "No test found",
2221 if _rule_active(
"prompt_invariants"):
2223 ok_prompt_fresh, prompt_msg_fresh = check_prompt_invariants(rel, text)
2224 result[
"prompt_invariants"] = ok_prompt_fresh
2225 if "prompt_invariants" in _detail_checks:
2226 _detail_checks[
"prompt_invariants"] = {
2227 **_detail_checks[
"prompt_invariants"],
2228 "pass": ok_prompt_fresh,
2229 "message": prompt_msg_fresh
or "OK",
2235 if _rule_active(
"dependencies_match"):
2237 ok_match_fresh, match_msg_fresh = check_dependencies_match(
2238 path, ctx.project_root, _cache_tree, memo=ctx.dependency_memo,
2240 result[
"dependencies_match"] = ok_match_fresh
2241 if "dependencies_match" in _detail_checks:
2242 _detail_checks[
"dependencies_match"] = {
2243 **_detail_checks[
"dependencies_match"],
2244 "pass": ok_match_fresh,
2245 "message": match_msg_fresh,
2247 _detail[
"checks"] = _detail_checks
2254 _checks = _detail_checks
2256 result[
"compliant"] = all(
2258 for c
in _checks.values()
2259 if c.get(
"severity", _SB) == _SB
2266 result[
"compliant"] = all(
2267 v
for k, v
in result.items()
2268 if isinstance(v, bool)
and k !=
"fixed"
2270 _dbg_rule_outcomes(str(rel), _detail_checks, cached=
True)
2274 tree, syntax_warnings_list = safe_parse(text)
2280 syntax_error_msg =
None if tree
is not None else _describe_syntax_error(text)
2282 lines = text.splitlines()
2283 header_errors: list[str] = []
2285 doc_msg = diag_msg = dbg_msg = dbg_imp_msg = func_msg = secrets_msg =
""
2286 hints_msg = dbg_assert_msg =
""
2288 compact_mode = active_rules.get(
"_compact_mode",
False)
2290 waiver_warnings: list[str] = []
2291 for lineno, w
in waivers.items():
2292 if w[
"reason"]
is None:
2293 waiver_warnings.append(
2294 f
"Line {lineno}: waiver for '{w['rule']}' has no reason"
2303 legacy_waiver_lines: tuple[int, ...] = ()
2306 "syntax_error":
False,
2307 "header":
False,
"docstring":
False,
"diagnostics_profile":
False,
2308 "dbg_usage":
False,
"dbg_import":
False,
"func_pattern":
False,
2309 "syntax_warnings":
False,
"tests_exist":
False,
"type_hints":
False,
2310 "no_hardcoded_secrets":
False,
"prompt_invariants":
False,
2311 "legacy_file_scoped_waiver":
True,
2312 "fixed":
False,
"rel": str(rel),
2313 "waivers": [{
"line": ln, **w}
for ln, w
in waivers.items()],
2319 ok_syntax_error = tree
is not None
2320 result[
"syntax_error"] = ok_syntax_error
2323 f
"Syntax error: {syntax_error_msg if not ok_syntax_error else 'OK'}",
2327 if _rule_active(
"header"):
2328 ok_header, header_errors = validate_header_block(lines, path, ctx)
2329 if fix_mode
and not ok_header:
2331 result[
"fixed"] =
True
2332 result[
"header"] =
False
2333 report(
False, f
"Would fix header: {', '.join(header_errors)}", fix_mode=
True, ctx=ctx)
2334 preview = preview_header_fix(path, ctx)
2335 boundary = _header_boundary(lines, path, ctx)
2336 log(
" Current header lines:", ctx)
2340 for i, hl
in enumerate(lines[:boundary], 1):
2341 log(f
" {i}: {hl}", ctx)
2342 log(
" Corrected header:", ctx)
2343 for i, hl
in enumerate(preview.splitlines() + [
""], 1):
2344 log(f
" {i}: {hl}", ctx)
2346 fix_header_block(path, text, ctx)
2347 result[
"fixed"] =
True
2348 result[
"header"] =
True
2349 report(
False, f
"Header block corrected: {', '.join(header_errors)}", fix_mode=
True, ctx=ctx)
2351 result[
"header"] = ok_header
2352 report(ok_header, f
"Header block: {', '.join(header_errors) if header_errors else 'OK'}", ctx=ctx)
2355 result[
"header"] =
True
2357 if _rule_active(
"docstring"):
2358 ok_doc, doc_msg = check_docstring(text, tree=tree)
2359 result[
"docstring"] = ok_doc
2360 report(ok_doc, f
"Docstring structure: {doc_msg or 'OK'}", ctx=ctx)
2363 result[
"docstring"] =
True
2365 if _rule_active(
"dependencies_section"):
2366 ok_deps, deps_msg = check_dependencies_section(text, tree=tree)
2367 result[
"dependencies_section"] = ok_deps
2368 report(ok_deps, f
"Dependencies section: {deps_msg or 'OK'}", ctx=ctx)
2371 result[
"dependencies_section"] =
True
2376 if _rule_active(
"dependencies_match"):
2378 ok_match, match_msg = check_dependencies_match(
2379 path, ctx.project_root, tree, memo=ctx.dependency_memo,
2381 result[
"dependencies_match"] = ok_match
2382 report(ok_match, f
"Dependencies match imports: {match_msg}", ctx=ctx)
2384 ok_match, match_msg =
True,
"OK"
2385 result[
"dependencies_match"] =
True
2387 if _rule_active(
"diagnostics_profile"):
2388 ok_diag, diag_msg = check_diagnostics_profile(text, tree=tree)
2389 result[
"diagnostics_profile"] = ok_diag
2390 report(ok_diag, f
"Diagnostics profile: {diag_msg or 'OK'}", ctx=ctx)
2393 result[
"diagnostics_profile"] =
True
2395 if _rule_active(
"dbg_usage"):
2397 ok_dbg, dbg_msg =
True, _AST_SKIP_MSG
2399 ok_dbg, dbg_msg = check_dbg_usage(text, tree=tree)
2400 result[
"dbg_usage"] = ok_dbg
2401 report(ok_dbg, f
"_dbg usage: {dbg_msg or 'OK'}", ctx=ctx)
2404 result[
"dbg_usage"] =
True
2406 if _rule_active(
"dbg_import"):
2408 ok_dbg_imp, dbg_imp_msg =
True, _AST_SKIP_MSG
2410 ok_dbg_imp, dbg_imp_msg = check_dbg_import(text, tree=tree)
2411 result[
"dbg_import"] = ok_dbg_imp
2412 report(ok_dbg_imp, f
"_dbg import: {dbg_imp_msg or 'OK'}", ctx=ctx)
2415 result[
"dbg_import"] =
True
2417 if _rule_active(
"func_pattern"):
2419 ok_func, func_msg =
True, _AST_SKIP_MSG
2421 ok_func, func_msg = check_func_pattern(text, tree=tree)
2422 result[
"func_pattern"] = ok_func
2423 report(ok_func, f
"func pattern: {func_msg or 'OK'}", ctx=ctx)
2426 result[
"func_pattern"] =
True
2429 ok_syntax, syntax_msg =
True, _AST_SKIP_MSG
2431 ok_syntax, syntax_msg = check_syntax_warnings(text, warnings_list=syntax_warnings_list)
2432 result[
"syntax_warnings"] = ok_syntax
2433 report(ok_syntax, f
"Syntax warnings: {syntax_msg or 'OK'}", ctx=ctx)
2435 if _rule_active(
"type_hints"):
2437 ok_hints, hints_msg =
True, _AST_SKIP_MSG
2439 ok_hints, hints_msg = check_type_hints(text, tree=tree, compact_mode=compact_mode)
2440 result[
"type_hints"] = ok_hints
2441 report(ok_hints, f
"Type hints: {hints_msg or 'OK'}", ctx=ctx)
2444 result[
"type_hints"] =
True
2446 if _rule_active(
"no_hardcoded_secrets"):
2451 file_entropy = ctx.code_entropy_threshold
2452 for excl
in ctx.entropy_exclusions:
2453 if excl.get(
"context")
in (
"test_directory",
"module_self_doc"):
2454 pattern = excl.get(
"path_pattern",
"")
2455 restrictions = excl.get(
"restrictions", {})
2456 if pattern
and rel.replace(
"\\",
"/").startswith(pattern):
2457 if restrictions.get(
"exempt_entropy_only"):
2460 ok_secrets, secrets_msg = check_no_hardcoded_secrets(
2461 text, tree=tree, mode=ctx.secret_match_mode,
2462 code_entropy_threshold=file_entropy,
2463 docstring_entropy_threshold=ctx.docstring_entropy_threshold,
2464 comment_entropy_threshold=ctx.comment_entropy_threshold,
2465 excluded_field_patterns=ctx.excluded_field_patterns,
2467 _dbg(
"OCT-LINTER", func,
2468 f
"secrets scan {rel}: clean={ok_secrets} "
2469 f
"(code entropy threshold {file_entropy})", 3)
2475 _waiver_outcome = apply_waivers(
2476 secrets_msg, waivers,
"no_hardcoded_secrets",
2478 legacy_waiver_lines = _waiver_outcome.legacy_lines
2479 secrets_msg = _waiver_outcome.message
2480 ok_secrets =
not _waiver_outcome.blocked
2481 result[
"no_hardcoded_secrets"] = ok_secrets
2482 report(ok_secrets, f
"Hardcoded secrets: {secrets_msg or 'OK'}", ctx=ctx)
2485 result[
"no_hardcoded_secrets"] =
True
2487 if _rule_active(
"dbg_assert_safety"):
2489 ok_dbg_assert, dbg_assert_msg =
True, _AST_SKIP_MSG
2491 ok_dbg_assert, dbg_assert_msg = check_dbg_assert_safety(
2492 text, tree=tree, filename=str(path)
2494 result[
"dbg_assert_safety"] = ok_dbg_assert
2495 report(ok_dbg_assert, f
"dbg_assert safety: {dbg_assert_msg or 'OK'}", ctx=ctx)
2497 ok_dbg_assert =
True
2498 result[
"dbg_assert_safety"] =
True
2500 if _rule_active(
"tests_exist"):
2501 ok_test = check_tests_exist(path, ctx)
2502 result[
"tests_exist"] = ok_test
2503 report(ok_test,
"Regression test exists", ctx=ctx)
2506 result[
"tests_exist"] =
True
2508 if _rule_active(
"prompt_invariants"):
2510 ok_prompt, prompt_msg = check_prompt_invariants(rel, text)
2511 result[
"prompt_invariants"] = ok_prompt
2512 report(ok_prompt, f
"Prompt invariants: {prompt_msg or 'OK'}", ctx=ctx)
2516 result[
"prompt_invariants"] =
True
2527 ok_legacy_waiver =
not legacy_waiver_lines
2528 legacy_waiver_msg =
"" if ok_legacy_waiver
else (
2529 "file-scoped secret waiver(s) at line "
2530 +
", ".join(str(ln)
for ln
in legacy_waiver_lines)
2531 +
" suppressed finding(s) elsewhere in the file; move the waiver "
2532 "onto the offending line or use 'disable-file=' for deliberate "
2535 result[
"legacy_file_scoped_waiver"] = ok_legacy_waiver
2538 for ww
in waiver_warnings:
2539 report(
False, f
"Waiver warning: {ww}", ctx=ctx)
2540 if not ok_legacy_waiver:
2541 report(
False, f
"Legacy waiver scope: {legacy_waiver_msg}", ctx=ctx)
2547 "syntax_error": ok_syntax_error,
2548 "header": ok_header
or fix_mode,
2549 "docstring": ok_doc,
2550 "dependencies_section": result.get(
"dependencies_section",
True),
2551 "dependencies_match": ok_match,
2552 "diagnostics_profile": ok_diag,
2553 "dbg_usage": ok_dbg,
2554 "dbg_import": ok_dbg_imp,
2555 "func_pattern": ok_func,
2556 "syntax_warnings": ok_syntax,
2557 "type_hints": ok_hints,
2558 "no_hardcoded_secrets": ok_secrets,
2559 "dbg_assert_safety": ok_dbg_assert,
2560 "tests_exist": ok_test,
2561 "prompt_invariants": ok_prompt,
2562 "legacy_file_scoped_waiver": ok_legacy_waiver,
2564 _check_severities = {
2565 rule: get_severity(rule, ctx.profile_name)
2566 for rule
in _check_results
2570 result[
"compliant"] = all(
2572 for rule, passed
in _check_results.items()
2573 if _check_severities[rule] == SEVERITY_BLOCKING
2575 _dbg(
"OCT-LINTER", func,
2576 f
"verdict {rel}: compliant={result['compliant']}", 3)
2578 def _check_block(passed: bool, message: str, rule: str) -> dict:
2582 "severity": _check_severities[rule],
2586 result[
"json_detail"] = {
2589 "syntax_error": _check_block(
2591 syntax_error_msg
if not ok_syntax_error
else "OK",
2594 "header": _check_block(
2595 ok_header
or (fix_mode
and not ok_header),
2596 ", ".join(header_errors)
if header_errors
else "OK",
2599 "docstring": _check_block(ok_doc, doc_msg
or "OK",
"docstring"),
2600 "dependencies_section": _check_block(
2601 result.get(
"dependencies_section",
True),
2602 "OK" if result.get(
"dependencies_section",
True)
else "Missing or malformed Dependencies section",
2603 "dependencies_section",
2605 "dependencies_match": _check_block(ok_match, match_msg,
"dependencies_match"),
2606 "diagnostics_profile": _check_block(ok_diag, diag_msg
or "OK",
"diagnostics_profile"),
2607 "dbg_usage": _check_block(ok_dbg, dbg_msg
or "OK",
"dbg_usage"),
2608 "dbg_import": _check_block(ok_dbg_imp, dbg_imp_msg
or "OK",
"dbg_import"),
2609 "func_pattern": _check_block(ok_func, func_msg
or "OK",
"func_pattern"),
2610 "syntax_warnings": _check_block(ok_syntax, syntax_msg
or "OK",
"syntax_warnings"),
2611 "type_hints": _check_block(ok_hints, hints_msg
or "OK",
"type_hints"),
2612 "no_hardcoded_secrets": _check_block(ok_secrets, secrets_msg
if not ok_secrets
else "OK",
"no_hardcoded_secrets"),
2613 "dbg_assert_safety": _check_block(ok_dbg_assert, dbg_assert_msg
or "OK",
"dbg_assert_safety"),
2614 "tests_exist": _check_block(ok_test,
"OK" if ok_test
else "No test found",
"tests_exist"),
2615 "prompt_invariants": _check_block(
2616 ok_prompt, prompt_msg
or "OK",
"prompt_invariants"
2618 "legacy_file_scoped_waiver": _check_block(
2620 legacy_waiver_msg
or "OK",
2621 "legacy_file_scoped_waiver",
2624 "compliant": result[
"compliant"],
2625 "waivers": result[
"waivers"],
2630 if path_rule_changes:
2631 result[
"json_detail"][
"path_rules"] = path_rule_changes
2632 _dbg_rule_outcomes(str(rel), result[
"json_detail"][
"checks"], cached=
False)
2635 if ctx.lint_cache
is not None and not fix_mode
and _file_hash
and _rules_hash:
2637 from datetime
import datetime
as _dt
2638 from oct
import __version__
as _ver
2639 cache_entry = LintCacheEntry(
2640 content_hash=_file_hash,
2642 k: v
for k, v
in result.items()
2643 if k
not in (
"fixed",
"rel",
"json_detail",
"waivers",
"compliant")
2645 json_detail=result[
"json_detail"],
2647 timestamp=_dt.now().isoformat(),
2649 ctx.lint_cache.put(_file_hash, _rules_hash, cache_entry)
2659def _maybe_dispatch_by_lens(project_root: Path, args) -> int |
None:
2660 """Fan ``oct lint`` out to one engine run per owning subproject.
2662 Plan 213 / D-D. Returns the merged (worst-wins) exit code when the
2663 invocation fanned out, or ``None`` to let the caller run the ordinary
2664 single-root pass. Returning ``None`` is the common case: a
2665 single-project repo, or an invocation from inside a subproject whose
2666 targets all live in that subproject, behaves byte-identically to
2669 Two situations fan out:
2671 1. *project_root* IS the workspace root. Every candidate file is
2672 grouped by owning subproject and the existing single-root engine
2673 runs once per group, under that group's root and octrc.
2674 2. *project_root* is a subproject but an explicit target resolves
2675 outside it - the sibling-file case. The target is linted under the
2676 lens that owns it rather than the invocation's.
2678 A target that resolves outside the workspace entirely can be given no
2679 lens; rather than emit a wrong-lens verdict the dispatcher refuses and
2680 names the directory to run from.
2682 if args.explain
or args.help:
2685 workspace, is_workspace_root = _load_workspace_for(project_root)
2686 if workspace
is None or not workspace.subprojects:
2689 exclude_dirs = set(EXCLUDE_DIRS)
2690 wildcard_exclude = list(WILDCARD_EXCLUDE_DIRS)
2691 flags = _linter_flag_argv(args)
2692 root_resolved = project_root.resolve()
2698 if is_workspace_root
and not args.targets
and not args.changed:
2699 exit_codes: list[int] = []
2700 for subproject
in workspace.subprojects:
2701 if not subproject.path.is_dir():
2704 run_linter(subproject.path, list(flags), _dispatch=
False)
2708 for path
in find_python_files(
2709 workspace.root, exclude_dirs, wildcard_exclude,
2712 _is_within(path, sp.path)
for sp
in workspace.subprojects
2716 exit_codes.append(run_linter(
2718 list(flags) + [str(p)
for p
in leftovers],
2721 return max(exit_codes, default=0)
2725 if not is_workspace_root:
2728 path
for path
in _git_changed_files(workspace.root)
2729 if path.is_file()
and path.suffix ==
".py"
2732 candidates = _expand_lint_targets(
2733 list(args.targets), exclude_dirs, wildcard_exclude,
2736 if not is_workspace_root
and all(
2737 _is_within(path, root_resolved)
for path
in candidates
2747 path
for path
in candidates
2748 if not _is_within(path, workspace.root)
2749 and _assigned_subproject(path, workspace)
is None
2752 names =
", ".join(str(p)
for p
in unlensed)
2754 "Error: no Option C lens governs these paths, so any verdict "
2755 f
"would be arbitrary: {names}\n"
2756 "They lie outside the workspace "
2757 f
"({workspace.root}) and outside every declared subproject. "
2758 "Run oct lint from the project that owns them.",
2763 groups = group_paths_by_lens(candidates, workspace)
2764 if len(groups) == 1
and root_resolved
in groups:
2769 for group_root, group_files
in groups.items():
2770 exit_codes.append(run_linter(
2772 list(flags) + [str(p)
for p
in group_files],
2775 return max(exit_codes, default=0)
2778def _is_within(path: Path, root: Path) -> bool:
2779 """True when *path* resolves inside *root*. Never raises."""
2781 Path(path).resolve().relative_to(Path(root).resolve())
2783 except (OSError, ValueError):
2787def _assigned_subproject(path: Path, workspace):
2788 """Thin wrapper over ``assign_to_subproject`` for readability."""
2789 from oct.core.workspace
import assign_to_subproject
2790 return assign_to_subproject(path, workspace)
2793def _print_non_compliant_section(results: list[dict]) ->
None:
2794 """Print each non-compliant file and the checks that failed it.
2796 Plan 260 P2. ``report()`` writes per-file PASS/FAIL lines to the log
2797 file only, so before this the operator got a summary of counts with
2798 no indication of which file or which rule was at fault. One line per
2799 failing check, taken verbatim from the per-file ``json_detail``
2800 block that ``--json`` already emitted: blocking findings first, then
2801 advisory ones suffixed ``[advisory]``.
2803 Nothing here re-runs a check or re-derives a verdict; the scanner's
2804 message is already truncated to a 24-character preview at the point
2805 of detection, so no secret is widened on the way out.
2807 non_compliant = [r
for r
in results
if not r.get(
"compliant",
True)]
2808 if not non_compliant:
2813 print(f
"\nNon-compliant files ({len(non_compliant)}):")
2814 for result
in non_compliant:
2815 detail = result.get(
"json_detail", {})
2816 print(f
" {detail.get('path', result.get('rel', '<unknown>'))}")
2817 checks = detail.get(
"checks", {})
2820 for rule, block
in checks.items()
2821 if not block.get(
"pass",
True)
2824 (rule, block)
for rule, block
in failing
2825 if block.get(
"severity", SEVERITY_BLOCKING) == SEVERITY_BLOCKING
2828 (rule, block)
for rule, block
in failing
2829 if block.get(
"severity", SEVERITY_BLOCKING) != SEVERITY_BLOCKING
2831 for rule, block
in blocking:
2832 print(f
" {rule}: {block.get('message', '')}")
2833 for rule, block
in advisory:
2834 print(f
" {rule}: {block.get('message', '')} [advisory]")
2839 argv: list[str] |
None =
None,
2841 _dispatch: bool =
True,
2844 Run the Option C linter on the given project root.
2846 ``_dispatch`` is the plan-213 re-entrancy guard: the workspace lens
2847 dispatcher re-invokes this function once per owning subproject with
2848 ``_dispatch=False`` so the per-group runs cannot fan out again.
2853 The detected Option C project root directory.
2855 Optional list of command-line arguments (e.g. ["--fix-headers"]).
2856 If None, ``argparse`` will use ``sys.argv``.
2861 Exit code: 0 if all checks passed, 1 if any failures detected.
2864 _dbg(
"OCT-LINTER", func, f
"lint run starting at {project_root}", 3)
2865 parser = argparse.ArgumentParser(add_help=
False)
2866 parser.add_argument(
"--help", action=
"store_true")
2867 parser.add_argument(
"--fix-headers", action=
"store_true")
2868 parser.add_argument(
"--dry-run", action=
"store_true",
2869 help=
"Preview changes without modifying files")
2870 parser.add_argument(
2872 action=
"store_true",
2873 help=
"Emit machine-readable JSON to stdout instead of colorized terminal output",
2875 parser.add_argument(
2879 help=
"Parallel workers for file processing (default: 1)",
2881 parser.add_argument(
2882 "--changed", action=
"store_true",
2883 help=
"Lint only git-modified Python files",
2885 parser.add_argument(
2886 "--quiet", action=
"store_true",
2887 help=
"Print the summary only; suppress the non-compliant file section",
2889 parser.add_argument(
2894 "Lint profile: proto, compact, strict. Layers on the project's "
2895 ".octrc.json linter.rules overrides"
2898 parser.add_argument(
2899 "--profile-only", action=
"store_true",
2901 "With --profile, ignore the project's .octrc.json linter.rules "
2902 "and linter.path_rules overrides and report what the profile "
2906 parser.add_argument(
2909 choices=(
"substring",
"word"),
2910 default=
"substring",
2912 "Secret-name matcher (OI-529): 'substring' (default, legacy) or "
2913 "'word' (segment-aware; avoids false positives like 'compass')."
2916 parser.add_argument(
2917 "--code-entropy-threshold",
2920 dest=
"entropy_threshold",
2921 help=
"Shannon entropy threshold for code string literals (default: 4.5)",
2923 parser.add_argument(
2925 action=
"store_true",
2926 help=
"Disable entropy-based secret detection",
2928 parser.add_argument(
2930 action=
"store_true",
2931 help=
"Bypass lint cache reads (still writes updated cache)",
2933 parser.add_argument(
2935 action=
"store_true",
2936 help=
"Save current violations as the baseline",
2938 parser.add_argument(
2939 "--against-baseline",
2940 action=
"store_true",
2941 help=
"Report only new/resolved violations vs saved baseline",
2943 parser.add_argument(
2947 help=
"Show documentation for a lint rule (rationale, pattern, spec ref)",
2949 parser.add_argument(
2950 "targets", nargs=
"*", default=
None,
2951 help=
"Files or directories to lint (default: entire project)",
2953 args = parser.parse_args(argv)
2956 print(
"Option C Linter")
2958 print(
" oct lint [--fix-headers] [--dry-run] [--json] [--jobs N] [--changed] [--profile P] [--secret-match MODE] [PATH ...]")
2959 print(
" oct lint --explain RULE [--json]")
2962 print(
" --help Show this help message")
2963 print(
" --fix-headers Automatically fix shebang, encoding, and path headers")
2964 print(
" --dry-run Preview changes without modifying files")
2965 print(
" --json Emit machine-readable JSON to stdout")
2966 print(
" --jobs N, -j N Parallel workers for file processing (default: 1)")
2967 print(
" --changed Lint only git-modified Python files")
2968 print(
" --quiet Summary only; no non-compliant file section")
2969 print(
" --profile P Lint profile: proto, compact, strict (layers on .octrc.json)")
2970 print(
" --profile-only With --profile, ignore .octrc.json rule and path-rule overrides")
2971 print(
" --secret-match MODE Secret-name matcher: substring (default) | word (segment-aware)")
2972 print(
" --code-entropy-threshold N Shannon entropy threshold for code strings (default: 4.5)")
2973 print(
" --no-entropy Disable entropy-based secret detection")
2974 print(
" --no-cache Bypass lint cache reads (still writes)")
2975 print(
" --baseline Save current violations as the baseline")
2976 print(
" --against-baseline Report only new/resolved vs saved baseline")
2977 print(
" --explain RULE Show rule documentation (rationale, pattern, spec ref)")
2978 print(
" PATH ... Files or directories to lint (default: entire project)")
2984 info = get_rule_info(args.explain)
2986 available =
", ".join(r.name
for r
in list_rules())
2987 print(f
"Error: unknown rule '{args.explain}'", file=sys.stderr)
2988 print(f
"Available rules: {available}", file=sys.stderr)
2991 from dataclasses
import asdict
2992 json.dump(asdict(info), sys.stdout, indent=2)
2995 print(f
"Rule: {info.name}")
2996 print(f
"Label: {info.label}")
2997 print(f
"Fixable: {'yes' if info.fixable else 'no'}")
2998 print(f
"Profiles: {', '.join(info.profiles)}")
2999 print(f
"Spec: {info.spec_reference}")
3002 print(f
" {info.rationale}")
3004 print(
"Correct pattern:")
3005 for line
in info.correct_pattern.splitlines():
3008 print(
"Common mistake:")
3009 print(f
" {info.common_mistake}")
3013 if args.baseline
and args.against_baseline:
3014 print(
"Error: --baseline and --against-baseline are mutually exclusive.",
3019 from oct.core.compat
import check_oc_diagnostics_compat
3020 compat_msg = check_oc_diagnostics_compat()
3022 print(compat_msg, file=sys.stderr)
3026 dispatched = _maybe_dispatch_by_lens(project_root, args)
3027 if dispatched
is not None:
3030 root = project_root.resolve()
3031 _oc_diag = root /
"oc_diagnostics"
3032 ctx = LinterContext(
3034 project_name=root.name,
3035 diagnostics_dir=_oc_diag
if _oc_diag.exists()
else root /
"diagnostics",
3036 tests_dir=root /
"tests",
3037 docs_dir=root /
"docs",
3038 secret_match_mode=args.secret_match,
3048 if ctx.diagnostics_dir.name ==
"diagnostics" and ctx.diagnostics_dir.is_dir():
3050 "Warning: 'diagnostics/' is deprecated and will be removed in v1.0.0. "
3051 "Rename to 'oc_diagnostics/'.",
3058 build_test_index(ctx)
3063 from oct.core.octrc
import load_octrc
3064 active_exclude_dirs = set(EXCLUDE_DIRS)
3065 active_wildcard_exclude = list(WILDCARD_EXCLUDE_DIRS)
3069 octrc_profile_name =
"default"
3070 octrc_rule_overrides: dict = {}
3071 octrc, octrc_errors = load_octrc(root)
3073 msg =
"; ".join(octrc_errors)
3074 if _strict_config_requested():
3075 print(f
"Error: {msg}", file=sys.stderr)
3077 print(f
"Warning: {msg}; using defaults.", file=sys.stderr)
3081 schema_errors = _validate_octrc_schema(octrc)
3083 msg = f
".octrc.json schema errors: {'; '.join(schema_errors)}"
3084 if _strict_config_requested():
3085 print(f
"Error: {msg}", file=sys.stderr)
3087 print(f
"Warning: {msg}", file=sys.stderr)
3089 linter_cfg = octrc.get(
"linter", {})
3090 if not isinstance(linter_cfg, dict):
3092 for key
in (
"exclude_dirs_add",
"exclude_dirs_remove",
"wildcard_exclude_add"):
3093 val = linter_cfg.get(key)
3094 if val
is not None and not isinstance(val, list):
3095 linter_cfg[key] = []
3096 if not isinstance(linter_cfg.get(
"profile",
"default"), str):
3097 linter_cfg[
"profile"] =
"default"
3098 if linter_cfg.get(
"rules")
is not None and not isinstance(linter_cfg[
"rules"], dict):
3099 linter_cfg[
"rules"] = {}
3100 for d
in linter_cfg.get(
"exclude_dirs_add", []):
3101 if isinstance(d, str):
3102 active_exclude_dirs.add(d)
3103 for d
in linter_cfg.get(
"exclude_dirs_remove", []):
3104 if isinstance(d, str):
3105 active_exclude_dirs.discard(d)
3106 for d
in linter_cfg.get(
"wildcard_exclude_add", []):
3107 if isinstance(d, str)
and d
not in active_wildcard_exclude:
3108 active_wildcard_exclude.append(d)
3110 octrc_profile_name = linter_cfg.get(
"profile",
"default")
3111 octrc_rule_overrides = linter_cfg.get(
"rules", {})
or {}
3112 ctx.profile_name = octrc_profile_name
3115 ctx.path_rules = parse_path_rules(linter_cfg)
3118 scanner_cfg = octrc.get(
"secret_scanner", {})
3119 if isinstance(scanner_cfg, dict):
3120 octrc_threshold = scanner_cfg.get(
"code_entropy_threshold")
3121 if isinstance(octrc_threshold, (int, float))
and not isinstance(octrc_threshold, bool):
3122 ctx.code_entropy_threshold = float(octrc_threshold)
3123 for key
in (
"docstring_entropy_threshold",
"comment_entropy_threshold"):
3124 val = scanner_cfg.get(key)
3125 if isinstance(val, (int, float))
and not isinstance(val, bool):
3126 setattr(ctx, key, float(val))
3129 exclusions = scanner_cfg.get(
"entropy_exclusions", [])
3130 if isinstance(exclusions, list):
3131 ctx.entropy_exclusions = exclusions
3132 ctx.excluded_field_patterns = [
3133 e[
"field_name_pattern"]
for e
in exclusions
3134 if e.get(
"context") ==
"namedtuple_field"
3135 and isinstance(e.get(
"field_name_pattern"), str)
3139 cli_profile: str |
None =
None
3141 candidate = args.profile.lower()
3142 if candidate
in _PROFILES:
3143 cli_profile = candidate
3144 ctx.profile_name = candidate
3147 f
"Warning: unknown profile '{args.profile}'; "
3148 f
"valid: {', '.join(p for p in _PROFILES if not p.startswith('_'))}",
3152 disabled_by_octrc: list[str] = []
3153 if cli_profile
is not None and args.profile_only:
3158 active_rules = dict(_PROFILES[cli_profile])
3164 active_rules, octrc_disabled = resolve_active_rules(
3165 octrc_profile_name, octrc_rule_overrides, cli_profile
3170 if cli_profile
is not None:
3171 disabled_by_octrc = octrc_disabled
3175 ctx.code_entropy_threshold = 0.0
3176 elif args.entropy_threshold
is not None:
3177 ctx.code_entropy_threshold = args.entropy_threshold
3180 from oct.core.option_c_dir
import option_c_dir
3181 from oct
import __version__
3183 if not args.fix_headers:
3188 ctx.lint_cache = LintCache(
3189 cache_dir /
"lint-cache.json",
3191 linter_identity=current_linter_identity(),
3193 ctx.no_cache_read = args.no_cache
3197 log_dir = root /
"logs"
3198 log_dir.mkdir(exist_ok=
True)
3199 _rotate_lint_logs(log_dir)
3200 timestamp = datetime.now().strftime(
"%Y%m%d-%H%M%S")
3201 log_file = log_dir / f
"oct_lint_output-{timestamp}.txt"
3202 ctx.log_handle = log_file.open(
"w", encoding=
"utf-8")
3203 ctx.log_path = log_file
3204 except OSError
as exc:
3205 print(f
"Warning: Could not open lint log file: {exc}", file=sys.stderr)
3206 ctx.log_handle =
None
3208 fix_mode = args.fix_headers
3209 dry_run = args.dry_run
3210 json_mode = args.json
3212 if fix_mode
and dry_run
and not json_mode:
3213 print(
"Dry-run mode: showing what --fix-headers would change. "
3214 "Fix details will be written to the log file.",
3218 print_header(
"Option C Linter - Project Scan", ctx)
3219 log(f
"Project root: {ctx.project_root}", ctx)
3220 log(f
"Project name: {ctx.project_name}", ctx)
3223 required_docs = project_required_docs(ctx)
3226 print_header(
"Project-Level Requirements", ctx)
3227 for req
in required_docs:
3228 report(req.exists(), f
"Required file: {req}", fix_mode=
False, ctx=ctx)
3232 print_header(
"Module-Level Checks", ctx)
3235 jobs = max(1, args.jobs)
3236 if fix_mode
and jobs > 1:
3239 print(
"Note: --jobs forced to 1 when --fix-headers is active.", file=sys.stderr)
3244 print(
"Note: --changed overrides explicit PATH targets.", file=sys.stderr)
3245 python_files = _git_changed_files(ctx.project_root)
3251 p
for p
in python_files
3252 if p.is_file()
and not any(
3253 is_excluded_dir(Path(part), active_exclude_dirs, active_wildcard_exclude)
3254 for part
in p.relative_to(ctx.project_root).parts[:-1]
3257 if not python_files:
3259 print(
"No modified Python files found.", file=sys.stderr)
3263 for t
in args.targets:
3264 p = Path(t).resolve()
3265 if p.is_file()
and p.suffix ==
".py":
3266 python_files.append(p)
3268 python_files.extend(
3269 find_python_files(p, active_exclude_dirs, active_wildcard_exclude)
3272 print(f
"Warning: Not a Python file or directory: {t}", file=sys.stderr)
3273 python_files = list(dict.fromkeys(python_files))
3274 if not python_files:
3275 print(
"No matching Python files found.", file=sys.stderr)
3278 python_files = list(find_python_files(ctx.project_root, active_exclude_dirs, active_wildcard_exclude))
3279 total_files = len(python_files)
3283 results = [_lint_single_file(p, ctx, active_rules, fix_mode, dry_run)
for p
in python_files]
3285 with concurrent.futures.ThreadPoolExecutor(max_workers=jobs)
as pool:
3286 futures = [pool.submit(_lint_single_file, p, ctx, active_rules, fix_mode, dry_run)
for p
in python_files]
3287 results = [f.result()
for f
in futures]
3295 rule: sum(1
for r
in results
if r.get(rule,
True))
3296 for rule, _label, _tabs, _json_key
in SUMMARY_ROWS
3298 fully_compliant = sum(1
for r
in results
if r[
"compliant"])
3299 fixed_files = sum(1
for r
in results
if r[
"fixed"])
3301 file_results: list[dict] = []
3303 file_results = [r[
"json_detail"]
for r
in results]
3306 print_header(
"Final Result", ctx)
3307 if fully_compliant == total_files:
3308 log(
"All checks passed. Project conforms to Option C.", ctx)
3310 log(
"Some checks failed. See above for details.", ctx)
3313 print_header(
"Summary", ctx)
3315 def pct(n: int) -> str:
3316 return f
"{(n / total_files * 100):.1f}%" if total_files
else "0%"
3318 log(f
"Number of files checked:\t{total_files}", ctx)
3319 for rule, label, log_tabs, _json_key
in SUMMARY_ROWS:
3320 n = pass_counts[rule]
3321 padding =
"\t" * log_tabs
3322 log(f
"[{label}]:{padding}{n} PASS ({pct(n)})", ctx)
3323 log(f
"[Compliant files]:\t\t{fully_compliant} PASS ({pct(fully_compliant)})", ctx)
3326 label =
"Would fix" if dry_run
else "Files fixed"
3327 log(f
"[{label}]:\t\t{fixed_files}", ctx)
3329 if ctx.log_handle
is not None:
3330 ctx.log_handle.close()
3331 ctx.log_handle =
None
3334 if ctx.lint_cache
is not None:
3336 ctx.lint_cache.save()
3341 baseline_info: dict |
None =
None
3344 baseline_path = cache_dir /
"lint-baseline.json"
3345 profile_name = args.profile
or "strict"
3346 count = save_baseline(results, baseline_path, profile_name)
3347 baseline_info = {
"action":
"saved",
"count": count,
"path": str(baseline_path)}
3349 print(f
"\nBaseline saved: {count} violation(s) -> {baseline_path}",
3352 if args.against_baseline:
3354 blocking_violations, diff_against_baseline, load_baseline,
3356 baseline_path = cache_dir /
"lint-baseline.json"
3357 baseline = load_baseline(baseline_path)
3358 if baseline
is None:
3359 print(
"Error: no baseline found. Run 'oct lint --baseline' first.",
3362 new_violations, resolved = diff_against_baseline(results, baseline)
3365 new_blocking = blocking_violations(new_violations, results)
3367 "action":
"compared",
3368 "new_violations": [{
"path": v[0],
"rule": v[1],
"message": v[2]}
for v
in new_violations],
3369 "resolved_violations": [{
"path": v[0],
"rule": v[1],
"message": v[2]}
for v
in resolved],
3370 "new_count": len(new_violations),
3371 "new_blocking_count": len(new_blocking),
3372 "resolved_count": len(resolved),
3373 "baselined_count": len(baseline.entries),
3377 print(f
"\n{RED}New violations ({len(new_violations)}, "
3378 f
"{len(new_blocking)} blocking):{RESET}", file=sys.stderr)
3379 blocking_set = set(new_blocking)
3380 for v
in new_violations:
3381 path_str, rule, msg = v
3382 note =
"" if v
in blocking_set
else " (advisory)"
3383 print(f
" {path_str}: [{rule}]{note} {msg}", file=sys.stderr)
3385 print(f
"\n{GREEN}Resolved ({len(resolved)}):{RESET}", file=sys.stderr)
3386 for path_str, rule, msg
in resolved:
3387 print(f
" {path_str}: [{rule}] {msg}", file=sys.stderr)
3388 if not new_violations:
3389 print(f
"\n{GREEN}No new violations vs baseline.{RESET}", file=sys.stderr)
3394 "project": ctx.project_name,
3395 "oct_version": __version__,
3396 "timestamp": datetime.now().isoformat(),
3398 "total_files": total_files,
3399 "fully_compliant": fully_compliant,
3404 json_key: pass_counts[rule]
3405 for rule, _label, _tabs, json_key
in SUMMARY_ROWS
3408 "files": file_results,
3412 if ctx.log_path
is not None:
3413 output[
"log_path"] = str(ctx.log_path)
3417 if disabled_by_octrc:
3418 output[
"disabled_by_octrc"] = disabled_by_octrc
3419 if baseline_info
is not None:
3420 output[
"baseline"] = baseline_info
3421 json.dump(output, sys.stdout, indent=2)
3423 if args.against_baseline
and baseline_info:
3424 return 1
if baseline_info.get(
"new_blocking_count", 0) > 0
else 0
3425 return 0
if fully_compliant == total_files
else 1
3430 if disabled_by_octrc
and not args.quiet:
3432 f
"note: profile '{cli_profile}' with {len(disabled_by_octrc)} "
3433 f
"rule(s) disabled by {to_project_relative(resolve_octrc(root), root)}: "
3434 f
"{', '.join(disabled_by_octrc)}",
3439 print(f
"{CYAN}Option C Linter Summary{RESET}")
3440 print(f
"Files checked: {total_files}")
3442 def color(n: int) -> str:
3443 return GREEN
if n == total_files
else RED
3445 def label_field(label: str) -> str:
3446 """Left-justify ``[label]:`` into the summary's label column."""
3447 return f
"[{label}]:".ljust(_TERMINAL_LABEL_WIDTH - 1) +
" "
3449 for rule, label, _tabs, _json_key
in SUMMARY_ROWS:
3450 n = pass_counts[rule]
3451 print(f
"{label_field(label)}{color(n)}{n}{RESET} PASS ({pct(n)})")
3453 f
"{label_field('Compliant files')}"
3454 f
"{color(fully_compliant)}{fully_compliant}{RESET} "
3455 f
"PASS ({pct(fully_compliant)})"
3459 label =
"Would fix" if dry_run
else "Files fixed"
3460 print(f
"[{label}]: {GREEN}{fixed_files}{RESET}")
3461 if dry_run
and fixed_files > 0
and ctx.log_path:
3462 print(f
"\nDry-run fix details written to: {ctx.log_path}")
3469 _print_non_compliant_section(results)
3471 if ctx.log_path
is not None:
3472 print(f
"\nLint log: {ctx.log_path}")
3474 if args.against_baseline
and baseline_info:
3475 return 1
if baseline_info.get(
"new_blocking_count", 0) > 0
else 0
3476 return 0
if fully_compliant == total_files
else 1
3481 Standalone entry point when running this module directly.
3483 raise SystemExit(run_linter(Path.cwd(),
None))
3486if __name__ ==
"__main__":