Option C Tools
Loading...
Searching...
No Matches
oct_lint.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/linter/oct_lint.py
4
5"""
6Purpose
7-------
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.
11
12Responsibilities
13----------------
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``).
26
27Diagnostics
28-----------
29Domain: OCT-LINTER
30Levels:
31 L2 — lifecycle
32 L3 — semantic details
33 L4 — deep tracing
34
35Contracts
36---------
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.
43
44Dependencies
45------------
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"""
51
52import argparse
53import ast
54import concurrent.futures
55import json
56import re
57import shutil
58import subprocess
59import threading
60import sys
61from dataclasses import dataclass, field
62from datetime import datetime
63from pathlib import Path
64from typing import Any
65
66# ---------------------------------------------------------------------
67# OI-513: log state is owned by :class:`LinterContext`. The previous
68# module-level ``LOG_HANDLE`` global is gone — concurrent linter runs
69# (parallel --jobs, MCP, health dashboard) each carry their own handle
70# on their own context without cross-contamination.
71# ---------------------------------------------------------------------
72
73# Resolved path to OCT's own package directory — used to exclude OCT internals
74# from linting when the project tree contains OCT itself.
75_OCT_PACKAGE_DIR = Path(__file__).resolve().parent.parent
76
77# ---------------------------------------------------------------------
78# Directory exclusion rules
79# ---------------------------------------------------------------------
80
81from oct.core.exclusions import LINTER_EXCLUDE_DIRS as EXCLUDE_DIRS
82from oct.core.exclusions import WILDCARD_EXCLUDE_DIRS
83from oct.core.parsing import safe_parse
84from oct.core.paths import to_project_relative
85from oct.core.waivers import parse_inline_waivers
86
87# ---------------------------------------------------------------------
88# Lint rule profiles
89# ---------------------------------------------------------------------
90
91_DEFAULT_RULES = {
92 "header": True,
93 "docstring": True,
94 "diagnostics_profile": True,
95 "dbg_usage": True,
96 "dbg_import": True,
97 "func_pattern": True,
98 "tests_exist": True,
99 "type_hints": True,
100 "no_hardcoded_secrets": True,
101 "dbg_assert_safety": True,
102 "dependencies_section": True,
103}
104
105_PROFILES = {
106 # V3.1 spec profiles: proto / compact / strict
107 "strict": dict(_DEFAULT_RULES),
108 "compact": {
109 "header": True,
110 "docstring": True,
111 "diagnostics_profile": True,
112 "dbg_usage": True,
113 "dbg_import": True,
114 "func_pattern": True,
115 "tests_exist": True,
116 "type_hints": True,
117 "no_hardcoded_secrets": True,
118 "dbg_assert_safety": True,
119 # Spec C1 / Drift A: Dependencies section enforced at strict only
120 "dependencies_section": False,
121 # compact: public-function-only checking for dbg/func/docstring
122 "_compact_mode": True,
123 },
124 "proto": {
125 "header": True,
126 "docstring": True,
127 "diagnostics_profile": False,
128 "dbg_usage": False,
129 "dbg_import": False,
130 "func_pattern": False,
131 "tests_exist": False,
132 "type_hints": False,
133 "no_hardcoded_secrets": False,
134 "dbg_assert_safety": False,
135 "dependencies_section": False,
136 },
137 # Deprecated aliases (backwards compat)
138 "default": dict(_DEFAULT_RULES),
139 "minimal": {
140 "header": True,
141 "docstring": True,
142 "diagnostics_profile": False,
143 "dbg_usage": False,
144 "dbg_import": False,
145 "func_pattern": False,
146 "tests_exist": False,
147 "type_hints": False,
148 "no_hardcoded_secrets": False,
149 "dbg_assert_safety": False,
150 "dependencies_section": False,
151 },
152}
153
154# ---------------------------------------------------------------------
155# .octrc.json schema validation (OI-411)
156# ---------------------------------------------------------------------
157
159 """Return True when strict-config enforcement is active.
160
161 Strict mode is opt-in via the ``OCT_STRICT_CONFIG`` environment variable
162 (any truthy value: ``1``, ``true``, ``yes``, ``on``). The ``--strict-config``
163 CLI flag wired up in ``oct/cli.py`` sets this env var before dispatch.
164 """
165 import os as _os
166 val = _os.environ.get("OCT_STRICT_CONFIG", "").strip().lower()
167 return val in ("1", "true", "yes", "on")
168
169
170def _validate_octrc_schema(cfg: dict) -> list[str]:
171 """Validate the structure of a loaded ``.octrc.json`` dict.
172
173 Returns a list of human-readable error strings. Empty list means the
174 config is structurally acceptable. Unknown keys are tolerated (forward
175 compat); only known keys are type-checked.
176 """
177 errors: list[str] = []
178 if not isinstance(cfg, dict):
179 return [f"top-level must be an object, got {type(cfg).__name__}"]
180
181 linter_cfg = cfg.get("linter")
182 if linter_cfg is not None and not isinstance(linter_cfg, dict):
183 errors.append(f"'linter' must be an object, got {type(linter_cfg).__name__}")
184 linter_cfg = None # skip further linter validation
185
186 profile = linter_cfg.get("profile") if isinstance(linter_cfg, dict) else None
187 if profile is not None:
188 if not isinstance(profile, str):
189 errors.append(f"'linter.profile' must be a string, got {type(profile).__name__}")
190 elif profile not in _PROFILES:
191 valid = ", ".join(p for p in _PROFILES if not p.startswith("_"))
192 errors.append(f"'linter.profile' unknown value '{profile}' (valid: {valid})")
193
194 if isinstance(linter_cfg, dict):
195 for key in ("exclude_dirs_add", "exclude_dirs_remove", "wildcard_exclude_add"):
196 val = linter_cfg.get(key)
197 if val is None:
198 continue
199 if not isinstance(val, list):
200 errors.append(f"'linter.{key}' must be a list, got {type(val).__name__}")
201 continue
202 for i, item in enumerate(val):
203 if not isinstance(item, str):
204 errors.append(f"'linter.{key}[{i}]' must be a string, got {type(item).__name__}")
205
206 rules = linter_cfg.get("rules")
207 if rules is not None:
208 if not isinstance(rules, dict):
209 errors.append(f"'linter.rules' must be an object, got {type(rules).__name__}")
210 else:
211 for rule_name, rule_val in rules.items():
212 if not isinstance(rule_val, bool):
213 errors.append(
214 f"'linter.rules.{rule_name}' must be a bool, got {type(rule_val).__name__}"
215 )
216
217 # OI-424: validate the optional formatter section. Only the
218 # ``max_backups_per_file`` key is currently type-checked; unknown
219 # keys stay tolerated for forward compatibility.
220 formatter_cfg = cfg.get("formatter")
221 if formatter_cfg is not None:
222 if not isinstance(formatter_cfg, dict):
223 errors.append(
224 f"'formatter' must be an object, got {type(formatter_cfg).__name__}"
225 )
226 else:
227 max_backups = formatter_cfg.get("max_backups_per_file")
228 if max_backups is not None:
229 if not isinstance(max_backups, int) or isinstance(max_backups, bool):
230 errors.append(
231 "'formatter.max_backups_per_file' must be an int, "
232 f"got {type(max_backups).__name__}"
233 )
234 elif max_backups < 0:
235 errors.append(
236 "'formatter.max_backups_per_file' must be >= 0, "
237 f"got {max_backups}"
238 )
239
240 # Phase 4E — G-E8: validate the optional git section (blueprint §13.1).
241 git_cfg = cfg.get("git")
242 if git_cfg is not None:
243 if not isinstance(git_cfg, dict):
244 errors.append(f"'git' must be an object, got {type(git_cfg).__name__}")
245 else:
246 _GIT_VALID_PROFILES = ("proto", "compact", "strict", "auto")
247
248 pb = git_cfg.get("protected_branches")
249 if pb is not None:
250 if not isinstance(pb, list):
251 errors.append(f"'git.protected_branches' must be a list, got {type(pb).__name__}")
252 else:
253 for i, item in enumerate(pb):
254 if not isinstance(item, str):
255 errors.append(f"'git.protected_branches[{i}]' must be a string, got {type(item).__name__}")
256
257 bn = git_cfg.get("branch_naming")
258 if bn is not None:
259 if not isinstance(bn, dict):
260 errors.append(f"'git.branch_naming' must be an object, got {type(bn).__name__}")
261 else:
262 bn_enabled = bn.get("enabled")
263 if bn_enabled is not None and not isinstance(bn_enabled, bool):
264 errors.append(f"'git.branch_naming.enabled' must be a bool, got {type(bn_enabled).__name__}")
265 bn_patterns = bn.get("patterns")
266 if bn_patterns is not None:
267 if not isinstance(bn_patterns, list):
268 errors.append(f"'git.branch_naming.patterns' must be a list, got {type(bn_patterns).__name__}")
269 else:
270 for i, pat in enumerate(bn_patterns):
271 if not isinstance(pat, str):
272 errors.append(f"'git.branch_naming.patterns[{i}]' must be a string, got {type(pat).__name__}")
273
274 pcp = git_cfg.get("pre_commit_profile")
275 if pcp is not None:
276 if not isinstance(pcp, str):
277 errors.append(f"'git.pre_commit_profile' must be a string, got {type(pcp).__name__}")
278 elif pcp not in _GIT_VALID_PROFILES:
279 errors.append(f"'git.pre_commit_profile' unknown value '{pcp}' (valid: {', '.join(_GIT_VALID_PROFILES)})")
280
281 for bool_key in (
282 "conventional_commits", "require_changelog_update",
283 "auto_branch_on_protected", "add_post_check",
284 ):
285 val = git_cfg.get(bool_key)
286 if val is not None and not isinstance(val, bool):
287 errors.append(f"'git.{bool_key}' must be a bool, got {type(val).__name__}")
288
289 sds = git_cfg.get("status_default_scope")
290 if sds is not None:
291 if not isinstance(sds, str):
292 errors.append(
293 f"'git.status_default_scope' must be a string, "
294 f"got {type(sds).__name__}"
295 )
296 elif sds not in ("project", "repo", "auto"):
297 errors.append(
298 f"'git.status_default_scope' unknown value "
299 f"'{sds}' (valid: project, repo, auto)"
300 )
301
302 oum = git_cfg.get("observe_unstaged_mutation")
303 if oum is not None:
304 if isinstance(oum, bool):
305 pass # bool form: False == "off", True == "warn"
306 elif isinstance(oum, str):
307 if oum not in ("off", "warn", "block"):
308 errors.append(
309 f"'git.observe_unstaged_mutation' unknown "
310 f"value '{oum}' (valid: off, warn, block)"
311 )
312 else:
313 errors.append(
314 f"'git.observe_unstaged_mutation' must be a "
315 f"bool or string, got {type(oum).__name__}"
316 )
317
318 ids = git_cfg.get("ignore_default_scope")
319 if ids is not None:
320 if not isinstance(ids, str):
321 errors.append(
322 f"'git.ignore_default_scope' must be a string, "
323 f"got {type(ids).__name__}"
324 )
325 elif ids not in ("workspace", "project", "nearest"):
326 errors.append(
327 f"'git.ignore_default_scope' unknown value "
328 f"'{ids}' (valid: workspace, project, nearest)"
329 )
330
331 for num_key in ("large_file_warn_mb", "timeout", "long_timeout"):
332 val = git_cfg.get(num_key)
333 if val is not None:
334 if not isinstance(val, (int, float)) or isinstance(val, bool):
335 errors.append(f"'git.{num_key}' must be a number, got {type(val).__name__}")
336 elif val <= 0:
337 errors.append(f"'git.{num_key}' must be > 0, got {val}")
338
339 # FS-507: validate the optional secret_scanner section.
340 scanner_cfg = cfg.get("secret_scanner")
341 if scanner_cfg is not None:
342 if not isinstance(scanner_cfg, dict):
343 errors.append(f"'secret_scanner' must be an object, got {type(scanner_cfg).__name__}")
344 else:
345 et = scanner_cfg.get("code_entropy_threshold")
346 if et is not None:
347 if not isinstance(et, (int, float)) or isinstance(et, bool):
348 errors.append(
349 f"'secret_scanner.code_entropy_threshold' must be a number, got {type(et).__name__}"
350 )
351 elif et < 0:
352 errors.append(
353 f"'secret_scanner.code_entropy_threshold' must be >= 0, got {et}"
354 )
355 for key in ("docstring_entropy_threshold", "comment_entropy_threshold"):
356 val = scanner_cfg.get(key)
357 if val is not None:
358 if not isinstance(val, (int, float)) or isinstance(val, bool):
359 errors.append(
360 f"'secret_scanner.{key}' must be a number, got {type(val).__name__}"
361 )
362 elif val < 0:
363 errors.append(
364 f"'secret_scanner.{key}' must be >= 0, got {val}"
365 )
366
367 # OI-540: validate entropy_exclusions array.
368 excl = scanner_cfg.get("entropy_exclusions")
369 if excl is not None:
370 if not isinstance(excl, list):
371 errors.append("'secret_scanner.entropy_exclusions' must be an array")
372 else:
373 for i, entry in enumerate(excl):
374 if not isinstance(entry, dict):
375 errors.append(
376 f"'secret_scanner.entropy_exclusions[{i}]' must be an object"
377 )
378 continue
379 ctx_val = entry.get("context")
380 if ctx_val not in ("namedtuple_field", "test_directory", "module_self_doc"):
381 errors.append(
382 f"'secret_scanner.entropy_exclusions[{i}].context' "
383 f"must be 'namedtuple_field', 'test_directory', or 'module_self_doc'"
384 )
385 if ctx_val == "namedtuple_field":
386 if not isinstance(entry.get("field_name_pattern"), str):
387 errors.append(
388 f"'secret_scanner.entropy_exclusions[{i}].field_name_pattern' "
389 f"must be a string"
390 )
391 elif ctx_val in ("test_directory", "module_self_doc"):
392 if not isinstance(entry.get("path_pattern"), str):
393 errors.append(
394 f"'secret_scanner.entropy_exclusions[{i}].path_pattern' "
395 f"must be a string"
396 )
397 restrictions = entry.get("restrictions", {})
398 if not isinstance(restrictions, dict):
399 errors.append(
400 f"'secret_scanner.entropy_exclusions[{i}].restrictions' "
401 f"must be an object"
402 )
403
404 return errors
405
406
407# ---------------------------------------------------------------------
408# Header constants
409# ---------------------------------------------------------------------
410
411SHEBANG = "#!/usr/bin/env python3"
412ENCODING = "# -*- coding: utf-8 -*-"
413
414
415# ---------------------------------------------------------------------
416# LinterContext
417# ---------------------------------------------------------------------
418
419
420@dataclass
422 """
423 Immutable per-run context for the linter. Replaces module-level globals
424 so that concurrent linter invocations do not share mutable state.
425 """
426
427 project_root: Path
428 project_name: str
429 diagnostics_dir: Path
430 tests_dir: Path
431 docs_dir: Path
432 log_handle: Any = None
433 log_path: Path | None = None
434 log_lock: threading.Lock = field(default_factory=threading.Lock)
435 # OI-510 / FS-516: one-shot test discovery index. ``name_stems`` is the
436 # set of module stems for which a ``test_<stem>.py`` file exists;
437 # ``imported_stems`` is the set of module stems imported (whole-word)
438 # by any file in tests_dir. ``None`` means "not yet built" — lazy path.
439 test_index: dict[str, set[str]] | None = None
440 # OI-529: secret-name matching strategy. "substring" (default) matches
441 # the legacy behaviour; "word" splits names on _/-/camelCase and only
442 # flags whole-segment hits, eliminating false positives like "compass"
443 # or "tokenizer".
444 secret_match_mode: str = "substring" # OCT-LINT: disable=no_hardcoded_secrets reason="Field name; documents the matcher mode, not a credential"
445 # FS-507: Shannon entropy threshold for string literals. Default 4.5
446 # bits/char. Set to 0 to disable entropy checking.
447 code_entropy_threshold: float = 4.5
448 # Per-context entropy thresholds (docstrings use strict >, code uses >=).
449 docstring_entropy_threshold: float = 5.0
450 comment_entropy_threshold: float = 5.0
451 # OI-540: entropy exclusions from octrc.
452 entropy_exclusions: list = field(default_factory=list)
453 excluded_field_patterns: list = field(default_factory=list)
454 # FS-C4 / Drift item C4: name of the active profile (proto / compact /
455 # strict). Used to resolve per-rule severity (advisory vs blocking)
456 # via :func:`oct.linter.rule_registry.get_severity`.
457 profile_name: str = "strict"
458 # FS-510: incremental lint cache. None means cache disabled.
459 lint_cache: Any = None
460 no_cache_read: bool = False
461
462
463# ---------------------------------------------------------------------
464# Logging helpers
465# ---------------------------------------------------------------------
466
467
468def log(message: str, ctx: LinterContext | None = None) -> None:
469 """
470 Write a single line to the linter log file.
471
472 OI-513: ``ctx`` is required in practice — every internal callsite
473 threads it through. The ``None`` branch is kept as a silent no-op so
474 external callers that import ``log()`` standalone do not crash.
475 """
476 if ctx is None or ctx.log_handle is None:
477 return
478 with ctx.log_lock:
479 ctx.log_handle.write(message + "\n")
480
481
482def print_header(title: str, ctx: LinterContext | None = None) -> None:
483 """
484 Write a section header to the log.
485 """
486 log("", ctx)
487 log("=" * 70, ctx)
488 log(title, ctx)
489 log("=" * 70, ctx)
490
491
492def report(result: bool, message: str, fix_mode: bool = False,
493 ctx: LinterContext | None = None) -> bool:
494 """
495 Log a PASS/FAIL (or FIXED) line and return the result.
496 """
497 if fix_mode and not result:
498 status = "FIXED"
499 else:
500 status = "PASS" if result else "FAIL"
501 log(f"[{status}] {message}", ctx)
502 return result
503
504
505# ---------------------------------------------------------------------
506# Utility helpers
507# ---------------------------------------------------------------------
508
509
510def is_excluded_dir(path: Path, exclude_dirs: set, wildcard_exclude_dirs: list) -> bool:
511 """
512 Return True if the directory name should be excluded from scanning.
513 """
514 name = path.name
515 if name in exclude_dirs:
516 return True
517 for pattern in wildcard_exclude_dirs:
518 if pattern in name:
519 return True
520 return False
521
522
523def read_file(path: Path, ctx: LinterContext | None = None) -> str:
524 """
525 Read a file as UTF-8, returning an empty string on failure.
526 """
527 try:
528 return path.read_text(encoding="utf-8")
529 except (OSError, UnicodeDecodeError) as e:
530 if ctx is not None:
531 log(f"[WARN] Could not read {path}: {e}", ctx)
532 return ""
533
534
535def _git_changed_files(root: Path) -> list[Path]:
536 """Return .py files modified in the git working tree (staged + unstaged).
537
538 Phase 4A (G-A7): thin shim over :func:`oct.core.git.git_changed_files`.
539 The core module enforces subprocess discipline, credential redaction,
540 and path-escape filtering; the linter's sole contract here is to
541 preserve the historical "never crash on any git failure" behaviour
542 by collapsing every ``GitError`` back to an empty list.
543 """
544 from oct.core import git as core_git
545 try:
546 return core_git.git_changed_files(root, extensions={".py"})
547 except core_git.GitError:
548 return []
549
550
552 root: Path,
553 exclude_dirs: set | None = None,
554 wildcard_exclude_dirs: list | None = None,
555):
556 """
557 Yield all Python files under root, excluding the linter itself and
558 excluded directories.
559
560 Parameters
561 ----------
562 root:
563 The project root to scan.
564 exclude_dirs:
565 Set of directory names to exclude. Defaults to the module-level
566 ``EXCLUDE_DIRS`` constant.
567 wildcard_exclude_dirs:
568 List of substrings; directories whose name contains any of these
569 are excluded. Defaults to ``WILDCARD_EXCLUDE_DIRS``.
570 """
571 if exclude_dirs is None:
572 exclude_dirs = EXCLUDE_DIRS
573 if wildcard_exclude_dirs is None:
574 wildcard_exclude_dirs = WILDCARD_EXCLUDE_DIRS
575 for path in root.rglob("*.py"):
576 # Skip files inside OCT's own package directory
577 try:
578 if path.resolve().is_relative_to(_OCT_PACKAGE_DIR):
579 continue
580 except (OSError, ValueError):
581 pass
582 parts = path.relative_to(root).parts
583 if any(is_excluded_dir(Path(p), exclude_dirs, wildcard_exclude_dirs) for p in parts):
584 continue
585 yield path
586
587
588# ---------------------------------------------------------------------
589# Header validation and fixing
590# ---------------------------------------------------------------------
591
592
593def expected_path_header(path: Path, ctx: LinterContext) -> str:
594 """
595 Compute the expected file identity header for a given module path.
596 """
597 rel = to_project_relative(path, ctx.project_root)
598 return f"# {rel}"
599
600
601def validate_header_block(lines: list[str], path: Path, ctx: LinterContext):
602 """
603 Validate the mandatory 4-line header block.
604
605 Returns (ok, errors) where:
606 - ok = True if header block is correct
607 - errors = list of error messages
608 """
609 errors: list[str] = []
610
611 if len(lines) < 4:
612 errors.append("File too short to contain mandatory header block")
613 return False, errors
614
615 if lines[0].strip() != SHEBANG:
616 errors.append("Shebang missing or incorrect")
617
618 if lines[1].strip() != ENCODING:
619 errors.append("Encoding line missing or incorrect")
620
621 if lines[2].strip() != expected_path_header(path, ctx):
622 errors.append("File identity header missing or incorrect")
623
624 if lines[3].strip() != "":
625 errors.append("Missing mandatory blank line after header block")
626
627 return len(errors) == 0, errors
628
629
630def _header_boundary(lines: list[str], path: Path, ctx: LinterContext) -> int:
631 """
632 Return the line index where file content begins, after any existing
633 header elements. OI-509: thin wrapper that delegates to
634 :func:`oct.core.parsing.header_boundary` so linter and formatter share
635 one implementation.
636 """
637 from oct.core.parsing import header_boundary
638 return header_boundary(
639 lines, path, expected_identity=expected_path_header(path, ctx),
640 )
641
642
643def fix_header_block(path: Path, text: str, ctx: LinterContext) -> bool:
644 """
645 Rewrite the top of the file to enforce the Option C 4-line header.
646 The original file is archived in a `.linter_archive` directory.
647 """
648 lines = text.splitlines()
649
650 # Archive original
651 archive_dir = path.parent / ".linter_archive"
652 archive_dir.mkdir(exist_ok=True)
653 ts = datetime.now().strftime("%Y%m%d-%H%M%S")
654 backup_path = archive_dir / f"{path.name}.{ts}.bak"
655 shutil.copy2(path, backup_path)
656
657 boundary = _header_boundary(lines, path, ctx)
658 content = lines[boundary:]
659
660 new_header = [
661 SHEBANG,
662 ENCODING,
663 expected_path_header(path, ctx),
664 "",
665 ]
666
667 new_text = "\n".join(new_header + content)
668 path.write_text(new_text, encoding="utf-8")
669 return True
670
671
672def preview_header_fix(path: Path, ctx: LinterContext) -> str:
673 """
674 Compute what fix_header_block would produce, without writing.
675 Returns the corrected 4-line header block as a string.
676 """
677 new_header = [
678 SHEBANG,
679 ENCODING,
680 expected_path_header(path, ctx),
681 "",
682 ]
683 return "\n".join(new_header)
684
685
686# ---------------------------------------------------------------------
687# Other rule checks
688# ---------------------------------------------------------------------
689
690
691def check_docstring(text: str):
692 """
693 Validate that a module docstring exists and contains the required
694 Option C sections.
695 """
696 m = re.search(r'"""(.*?)"""', text, re.DOTALL)
697 if not m:
698 return False, "Missing module docstring"
699 doc = m.group(1)
700 required = ["Purpose", "Responsibilities", "Diagnostics", "Contracts"]
701 missing = [s for s in required if s not in doc]
702 if missing:
703 return False, f"Missing sections: {', '.join(missing)}"
704 return True, ""
705
706
707_DEP_SECTION_RE = re.compile(
708 r'Dependencies\s*\n\s*[-]+\s*\n(.*?)(?:\n\s*\n|\Z)',
709 re.DOTALL,
710)
711_DEP_LINE_RE = re.compile(r'^\s*-\s+\S+\s*\‍([^)]+\‍)\s*$', re.MULTILINE)
712_DEP_BULLET_RE = re.compile(r'^\s*[-*]\s+\S', re.MULTILINE)
713
714
716 """
717 Validate the Dependencies section of a module docstring at strict profile.
718
719 Spec reference: Appendix C — Dependencies docstring format. Each entry
720 must follow ``- module_name (purpose description)`` so ``oct deps`` can
721 parse the dependency graph.
722
723 Returns ``(ok, msg)``. Empty Dependencies sections are accepted (a module
724 may have no project-internal dependencies); malformed entries are rejected.
725 """
726 m = re.search(r'"""(.*?)"""', text, re.DOTALL)
727 if not m:
728 return False, "Missing module docstring"
729 doc = m.group(1)
730 if "Dependencies" not in doc:
731 return False, "Missing Dependencies section in module docstring"
732 section_match = _DEP_SECTION_RE.search(doc)
733 if not section_match:
734 return False, "Malformed Dependencies section (missing underline)"
735 section = section_match.group(1)
736 bullets = _DEP_BULLET_RE.findall(section)
737 if not bullets:
738 return True, ""
739 valid_lines = _DEP_LINE_RE.findall(section)
740 if len(valid_lines) != len(bullets):
741 return False, (
742 "Malformed Dependencies entry; expected '- module_name (purpose)'"
743 )
744 return True, ""
745
746
747def _extract_module_docstring(text: str) -> str | None:
748 """
749 Return the text of the first module-level triple-quoted string found
750 before the first ``def`` or ``class`` statement, or ``None`` if absent.
751
752 Scoping to pre-code text prevents false positives from in-function
753 string literals that happen to contain keywords like 'L2', 'Diagnostics',
754 or 'Domain:'.
755 """
756 pre_code_match = re.search(r'^\s*(def|class)\s', text, re.MULTILINE)
757 pre_code = text[:pre_code_match.start()] if pre_code_match else text
758 for pattern in (r'"""(.*?)"""', r"'''(.*?)'''"):
759 m = re.search(pattern, pre_code, re.DOTALL)
760 if m:
761 return m.group(1)
762 return None
763
764
766 """
767 Validate that a Diagnostics profile is present in the module docstring.
768
769 Scopes the search to the module docstring (text before the first
770 ``def``/``class``) to avoid false positives from in-code string literals
771 that happen to contain 'L2', 'L3', 'L4', 'Diagnostics', or 'Domain:'.
772 Falls back to the full file if no module docstring is found.
773 """
774 docstring = _extract_module_docstring(text)
775 scope = docstring if docstring is not None else text
776 if "Diagnostics" not in scope:
777 return False, "Missing Diagnostics section in module docstring"
778 if "Domain:" not in scope:
779 return False, "Missing Diagnostics domain in module docstring"
780 if "L2" not in scope or "L3" not in scope or "L4" not in scope:
781 return False, "Missing Diagnostics level definitions in module docstring"
782 return True, ""
783
784
785def check_dbg_usage(text: str, tree: ast.Module | None = None):
786 """
787 Validate that non-trivial modules call ``_dbg()`` for diagnostics.
788
789 ``_dbg`` is the structured debug logger from ``oc_diagnostics``
790 (``from oc_diagnostics import _dbg``). Files shorter than 20 lines
791 are considered trivial scaffolding and are exempt from this check.
792
793 Uses AST parsing to detect actual ``_dbg()`` call nodes, avoiding
794 false positives from comments or string literals.
795 """
796 if len(text.splitlines()) < 20:
797 return True, ""
798 if tree is None:
799 tree, _ = safe_parse(text)
800 if tree is None:
801 return True, ""
802 for node in ast.walk(tree):
803 if (isinstance(node, ast.Call)
804 and isinstance(node.func, ast.Name)
805 and node.func.id == "_dbg"):
806 return True, ""
807 if (isinstance(node, ast.Call)
808 and isinstance(node.func, ast.Attribute)
809 and node.func.attr == "_dbg"):
810 return True, ""
811 return False, "Missing _dbg usage"
812
813
814def check_dbg_import(text: str, tree: ast.Module | None = None):
815 """
816 Verify that ``_dbg`` is imported from ``oc_diagnostics`` at module level.
817
818 The canonical import is ``from oc_diagnostics import _dbg``. Files shorter
819 than 20 lines are exempt (same threshold as ``check_dbg_usage``). Aliased
820 imports (``import _dbg as ...``) are not accepted — the Option C spec
821 requires the canonical form.
822 """
823 if len(text.splitlines()) < 20:
824 return True, ""
825 if tree is None:
826 tree, _ = safe_parse(text)
827 if tree is None:
828 return True, ""
829 for node in ast.iter_child_nodes(tree):
830 if (isinstance(node, ast.ImportFrom)
831 and node.module == "oc_diagnostics"
832 and any(alias.name == "_dbg" and alias.asname is None
833 for alias in node.names)):
834 return True, ""
835 return False, "Missing `from oc_diagnostics import _dbg`"
836
837
838def _walk_function_body(func_node: ast.FunctionDef | ast.AsyncFunctionDef):
839 """
840 Yield AST nodes within a function body, stopping at nested function
841 boundaries so that each function gets an independent check.
842 """
843 for node in ast.walk(func_node):
844 if node is func_node:
845 continue
846 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
847 # Don't descend into nested functions; they are checked separately
848 continue
849 yield node
850
851
852def _direct_body_nodes(func_node):
853 """
854 Yield AST nodes that are directly in this function's body (not in
855 nested function bodies). Uses an iterative approach to avoid
856 descending into nested FunctionDef/AsyncFunctionDef.
857 """
858 stack = list(func_node.body)
859 while stack:
860 node = stack.pop()
861 yield node
862 for child in ast.iter_child_nodes(node):
863 if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
864 continue
865 stack.append(child)
866
867
869 text: str,
870 tree: ast.Module | None = None,
871) -> list[tuple[str, int]]:
872 """
873 Return ``[(func_name, lineno), ...]`` for every function that calls
874 ``_dbg()`` without a prior ``func = "..."`` assignment.
875
876 Returns an empty list for trivial files (< 20 lines) or files with
877 syntax errors. This is the single source of truth for func-pattern
878 detection — used by :func:`check_func_pattern` (linter pass/fail)
879 and by :func:`oct.formatter.oct_format.fix_func_pattern` (auto-fix).
880 """
881 lines = text.splitlines()
882 if len(lines) < 20:
883 return []
884
885 if tree is None:
886 tree, _ = safe_parse(text)
887 if tree is None:
888 return []
889
890 violations: list[tuple[str, int]] = []
891
892 func_nodes = [
893 node for node in ast.walk(tree)
894 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
895 ]
896
897 for func_node in func_nodes:
898 has_func_var = False
899 has_dbg_call = False
900
901 # Check for func = "..." in first 3 non-docstring statements
902 non_doc_seen = 0
903 for stmt in func_node.body:
904 if (isinstance(stmt, ast.Expr)
905 and isinstance(stmt.value, ast.Constant)
906 and isinstance(stmt.value.value, str)):
907 continue
908 if (isinstance(stmt, ast.Assign)
909 and len(stmt.targets) == 1
910 and isinstance(stmt.targets[0], ast.Name)
911 and stmt.targets[0].id == "func"
912 and isinstance(stmt.value, ast.Constant)
913 and isinstance(stmt.value.value, str)):
914 has_func_var = True
915 break
916 non_doc_seen += 1
917 if non_doc_seen >= 3:
918 break
919
920 # Check for _dbg() anywhere in direct body (Name or Attribute form)
921 for node in _direct_body_nodes(func_node):
922 if isinstance(node, ast.Call):
923 if (isinstance(node.func, ast.Name)
924 and node.func.id == "_dbg"):
925 has_dbg_call = True
926 if (isinstance(node.func, ast.Attribute)
927 and node.func.attr == "_dbg"):
928 has_dbg_call = True
929
930 if has_dbg_call and not has_func_var:
931 violations.append((func_node.name, func_node.lineno))
932
933 return violations
934
935
936def check_func_pattern(text: str, tree: ast.Module | None = None):
937 """
938 Validate that every function body containing ``_dbg()`` first defines
939 ``func = "function_name"``.
940
941 Uses ``ast.parse()`` to build a syntax tree, then walks each function's
942 body (stopping at nested function boundaries) to check for the pattern.
943 Exempt trivial files (< 20 lines) — same threshold as check_dbg_usage.
944
945 Delegates to :func:`find_func_pattern_violations` for the actual
946 detection; this wrapper converts the result into a ``(bool, message)``
947 pair for the linter pipeline.
948 """
949 lines = text.splitlines()
950 if len(lines) < 20:
951 return True, "trivial"
952
953 if tree is None:
954 tree, _ = safe_parse(text)
955 if tree is None:
956 return True, "syntax error; skipped"
957
958 violations = find_func_pattern_violations(text, tree=tree)
959 if violations:
960 names = ", ".join(f"{name}()" for name, _ in violations)
961 return False, f'Missing `func = "..."` before `_dbg()` in: {names}'
962 return True, ""
963
964
965# OI-514: secret-scanning logic lives in :mod:`oct.tools.secret_scanner`.
966# The symbols are re-exported below so any external callers that import
967# them from ``oct.linter.oct_lint`` (historical location) keep working.
968from oct.tools.secret_scanner import ( # noqa: E402 (re-export)
969 _SECRET_NAME_PATTERNS,
970 _is_string_like,
971 _is_string_literal,
972 _name_matches_secret,
973 check_no_hardcoded_secrets,
974)
975
976
977def check_syntax_warnings(text: str, warnings_list: list[str] | None = None):
978 """
979 Check for Python syntax warnings (invalid escape sequences etc.).
980
981 Python 3.12+ emits ``SyntaxWarning`` for invalid escape sequences in
982 string literals. These will become ``SyntaxError`` in a future Python
983 version, so they should be surfaced as lint findings.
984 """
985 if warnings_list is None:
986 _, warnings_list = safe_parse(text)
987 if warnings_list:
988 return False, f"{len(warnings_list)} syntax warning(s): {'; '.join(warnings_list)}"
989 return True, ""
990
991
992def check_type_hints(text: str, tree: ast.Module | None = None, compact_mode: bool = False):
993 """
994 Validate that public functions have type annotations on all parameters
995 and a return annotation.
996
997 Private functions (leading underscore) and dunder methods are always
998 exempt — they are internal implementation details (OI-417). The
999 ``compact_mode`` parameter is retained for API compatibility but no
1000 longer changes behaviour.
1001
1002 Trivial files (< 20 lines) are exempt.
1003 """
1004 if len(text.splitlines()) < 20:
1005 return True, ""
1006 if tree is None:
1007 tree, _ = safe_parse(text)
1008 if tree is None:
1009 return True, "syntax error; skipped"
1010
1011 violations: list[str] = []
1012
1013 for node in ast.walk(tree):
1014 if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1015 continue
1016 # Skip dunder methods (always exempt)
1017 if node.name.startswith("__") and node.name.endswith("__"):
1018 continue
1019 # Private functions (single leading underscore) are exempt from type hint
1020 # requirements in all profiles — internal implementation details (OI-417).
1021 if node.name.startswith("_"):
1022 continue
1023
1024 # Check parameters (skip 'self' and 'cls')
1025 missing_params = []
1026 for arg in node.args.args:
1027 if arg.arg in ("self", "cls"):
1028 continue
1029 if arg.annotation is None:
1030 missing_params.append(arg.arg)
1031
1032 # Check return annotation
1033 missing_return = node.returns is None
1034
1035 if missing_params or missing_return:
1036 parts = []
1037 if missing_params:
1038 parts.append(f"params: {', '.join(missing_params)}")
1039 if missing_return:
1040 parts.append("return type")
1041 violations.append(f"{node.name}() missing {'; '.join(parts)}")
1042
1043 if violations:
1044 return False, f"Type hints: {'; '.join(violations[:5])}"
1045 return True, ""
1046
1047
1048# Keywords that mark a function/class/module as "security-sensitive" for
1049# the purposes of OI-403. A ``_dbg_assert`` call inside any such context is
1050# flagged because ``_dbg_assert`` is a no-op in production (AP-16) and must
1051# never be relied upon for safety invariants.
1052_SECURITY_SENSITIVE_KEYWORDS = frozenset({
1053 "auth", "authenticate", "authorize", "permission", "security",
1054 "validate", "verify", "credential", "login", "session", "sanitize",
1055 "encrypt", "decrypt", "password", "token",
1056})
1057
1058
1059def _is_security_sensitive_name(name: str) -> bool:
1060 lowered = name.lower()
1061 return any(kw in lowered for kw in _SECURITY_SENSITIVE_KEYWORDS)
1062
1063
1065 text: str, tree: ast.Module | None = None, filename: str = ""
1066) -> tuple[bool, str]:
1067 """
1068 Flag ``_dbg_assert`` calls made inside security-sensitive contexts.
1069
1070 ``_dbg_assert`` is a diagnostic aid — in production mode it is a no-op.
1071 Per AP-16 it must never be used for safety invariants (authentication,
1072 authorization, input validation, crypto, etc.). This check walks the AST
1073 looking for calls named ``_dbg_assert`` (bare or attribute access) and
1074 verifies that none of the enclosing functions, classes, or the module's
1075 filename stem contain a security-sensitive keyword.
1076 """
1077 if tree is None:
1078 tree, _ = safe_parse(text)
1079 if tree is None:
1080 return True, "syntax error; skipped"
1081
1082 # Is the module itself security-sensitive by filename?
1083 module_sensitive = False
1084 if filename:
1085 stem = Path(filename).stem
1087 module_sensitive = True
1088
1089 violations: list[str] = []
1090
1091 def _walk(node: ast.AST, scopes: list[str]) -> None:
1092 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
1093 new_scopes = scopes + [node.name]
1094 for child in ast.iter_child_nodes(node):
1095 _walk(child, new_scopes)
1096 return
1097
1098 if isinstance(node, ast.Call):
1099 func = node.func
1100 call_name = ""
1101 if isinstance(func, ast.Name):
1102 call_name = func.id
1103 elif isinstance(func, ast.Attribute):
1104 call_name = func.attr
1105 if call_name == "_dbg_assert":
1106 sensitive_scope = None
1107 for scope in scopes:
1109 sensitive_scope = scope
1110 break
1111 if sensitive_scope is None and module_sensitive:
1112 sensitive_scope = f"module '{Path(filename).stem}'"
1113 if sensitive_scope is not None:
1114 violations.append(
1115 f"L{node.lineno}: _dbg_assert used in security-sensitive "
1116 f"context '{sensitive_scope}' — use a hard assertion (AP-16)"
1117 )
1118
1119 for child in ast.iter_child_nodes(node):
1120 _walk(child, scopes)
1121
1122 _walk(tree, [])
1123
1124 if violations:
1125 return False, "; ".join(violations[:5])
1126 return True, ""
1127
1128
1129# ---- Inline waiver parsing ----
1130# OI-540: parse_inline_waivers() moved to oct.core.waivers (imported above).
1131
1132
1133_IMPORT_STMT_RE = re.compile(
1134 r'(?:^|\s)(?:import|from)\s+([a-zA-Z_][a-zA-Z0-9_]*)',
1135 re.MULTILINE,
1136)
1137
1138
1139def build_test_index(ctx: LinterContext) -> dict[str, set[str]]:
1140 """
1141 OI-510 / FS-516: one-shot scan of ``ctx.tests_dir`` producing two
1142 lookup sets so :func:`check_tests_exist` becomes O(1) per module
1143 instead of re-walking the tests tree for every file linted.
1144
1145 The index is cached on :attr:`LinterContext.test_index` — subsequent
1146 calls return the cached value directly.
1147
1148 Returns a dict with:
1149
1150 * ``name_stems`` — stems ``<s>`` for which ``test_<s>.py`` exists.
1151 * ``imported_stems`` — module stems referenced in any test file by
1152 a whole-word ``import`` / ``from`` statement.
1153 """
1154 if ctx.test_index is not None:
1155 return ctx.test_index
1156
1157 name_stems: set[str] = set()
1158 imported_stems: set[str] = set()
1159
1160 if ctx.tests_dir.is_dir():
1161 for test_file in ctx.tests_dir.rglob("*.py"):
1162 stem = test_file.stem
1163 if stem.startswith("test_"):
1164 name_stems.add(stem[len("test_"):])
1165 try:
1166 text = test_file.read_text(encoding="utf-8")
1167 except (OSError, UnicodeDecodeError):
1168 continue
1169 for m in _IMPORT_STMT_RE.finditer(text):
1170 imported_stems.add(m.group(1))
1171
1172 ctx.test_index = {
1173 "name_stems": name_stems,
1174 "imported_stems": imported_stems,
1175 }
1176 return ctx.test_index
1177
1178
1179def check_tests_exist(module_path: Path, ctx: LinterContext):
1180 """
1181 Validate that at least one test file references the module either by
1182 naming convention (``test_<module>.py``) or by an explicit import
1183 statement (``import <module>`` or ``from <module>``).
1184
1185 Two-stage check, now O(1) per call: consults the index built once
1186 per :func:`run_linter` invocation by :func:`build_test_index`.
1187 """
1188 index = build_test_index(ctx)
1189 stem = module_path.stem
1190 return stem in index["name_stems"] or stem in index["imported_stems"]
1191
1192
1193# ---------------------------------------------------------------------
1194# Terminal color helpers
1195# ---------------------------------------------------------------------
1196
1197from oct.core.terminal import CYAN, GREEN, RED, RESET
1198
1199
1200_MAX_LINT_LOG_FILES = 10
1201
1202
1203def _rotate_lint_logs(log_dir: Path, max_files: int = _MAX_LINT_LOG_FILES) -> None:
1204 """Remove old lint log files, keeping only the most recent max_files."""
1205 logs = sorted(log_dir.glob("oct_lint_output-*.txt"), key=lambda p: p.name)
1206 excess = len(logs) - max_files
1207 for old in logs[:excess]:
1208 try:
1209 old.unlink()
1210 except OSError:
1211 pass
1212
1213
1215 path: Path,
1216 ctx: LinterContext,
1217 active_rules: dict,
1218 fix_mode: bool,
1219 dry_run: bool = False,
1220) -> dict:
1221 """Lint one Python file and return a result dict with check outcomes.
1222
1223 Thread-safe: logging uses ctx.log_lock internally.
1224 """
1225 text = read_file(path, ctx)
1226 rel = to_project_relative(path, ctx.project_root)
1227
1228 log("", ctx)
1229 log(f"--- {rel} ---", ctx)
1230
1231 # FS-510: check lint cache before running checks
1232 _file_hash: str | None = None
1233 _rules_hash: str | None = None
1234 if ctx.lint_cache is not None and not fix_mode:
1235 from oct.linter.lint_cache import content_hash, rules_hash
1236 _file_hash = content_hash(text)
1237 _rules_hash = rules_hash(active_rules)
1238 if not ctx.no_cache_read:
1239 cached = ctx.lint_cache.get(_file_hash, _rules_hash)
1240 if cached is not None:
1241 log(" (cached)", ctx)
1242 result = dict(cached.rule_results)
1243 result["json_detail"] = cached.json_detail
1244 result["fixed"] = False
1245 result["rel"] = str(rel)
1246 # FS-510 fix: recompute "compliant" — excluded from
1247 # cache storage, must be derived from per-check severity
1248 # in the cached json_detail (FS-C4: advisory failures
1249 # don't break compliance).
1250 from oct.linter.rule_registry import SEVERITY_BLOCKING as _SB
1251 _checks = (cached.json_detail or {}).get("checks", {})
1252 if _checks:
1253 result["compliant"] = all(
1254 c.get("pass", True)
1255 for c in _checks.values()
1256 if c.get("severity", _SB) == _SB
1257 )
1258 else:
1259 # Pre-FS-C4 cache entries have no severity field.
1260 # Fall back to the legacy "all booleans must pass"
1261 # rule so we don't suddenly flip results during the
1262 # rollover window.
1263 result["compliant"] = all(
1264 v for k, v in result.items()
1265 if isinstance(v, bool) and k != "fixed"
1266 )
1267 return result
1268
1269 # Parse AST once and share across all check functions
1270 tree, syntax_warnings_list = safe_parse(text)
1271
1272 lines = text.splitlines()
1273 header_errors: list[str] = []
1274 doc_msg = diag_msg = dbg_msg = dbg_imp_msg = func_msg = secrets_msg = "" # OCT-LINT: disable=no_hardcoded_secrets reason="Variable holds lint output, not a credential"
1275
1276 compact_mode = active_rules.get("_compact_mode", False)
1277
1278 # Parse inline waivers (# OCT-LINT: disable=<rule>)
1279 waivers = parse_inline_waivers(text)
1280 waived_rules: set[str] = {w["rule"] for w in waivers.values() if w["reason"] is not None}
1281 waiver_warnings: list[str] = []
1282 for lineno, w in waivers.items():
1283 if w["reason"] is None:
1284 waiver_warnings.append(
1285 f"Line {lineno}: waiver for '{w['rule']}' has no reason"
1286 )
1287
1288 result: dict = {
1289 "header": False, "docstring": False, "diagnostics_profile": False,
1290 "dbg_usage": False, "dbg_import": False, "func_pattern": False,
1291 "syntax_warnings": False, "tests_exist": False, "type_hints": False,
1292 "no_hardcoded_secrets": False,
1293 "fixed": False, "rel": str(rel),
1294 "waivers": [{"line": ln, **w} for ln, w in waivers.items()],
1295 }
1296
1297 # Helper: check if a rule is active (enabled in profile AND not waived)
1298 def _rule_active(rule: str) -> bool:
1299 return active_rules.get(rule, True) and rule not in waived_rules
1300
1301 if _rule_active("header"):
1302 ok_header, header_errors = validate_header_block(lines, path, ctx)
1303 if fix_mode and not ok_header:
1304 if dry_run:
1305 result["fixed"] = True
1306 result["header"] = False
1307 report(False, f"Would fix header: {', '.join(header_errors)}", fix_mode=True, ctx=ctx)
1308 preview = preview_header_fix(path, ctx)
1309 boundary = _header_boundary(lines, path, ctx)
1310 log(" Current header lines:", ctx)
1311 if boundary == 0:
1312 log(" (none)", ctx)
1313 else:
1314 for i, hl in enumerate(lines[:boundary], 1):
1315 log(f" {i}: {hl}", ctx)
1316 log(" Corrected header:", ctx)
1317 for i, hl in enumerate(preview.splitlines() + [""], 1):
1318 log(f" {i}: {hl}", ctx)
1319 else:
1320 fix_header_block(path, text, ctx)
1321 result["fixed"] = True
1322 result["header"] = True
1323 report(False, f"Header block corrected: {', '.join(header_errors)}", fix_mode=True, ctx=ctx)
1324 else:
1325 result["header"] = ok_header
1326 report(ok_header, f"Header block: {', '.join(header_errors) if header_errors else 'OK'}", ctx=ctx)
1327 else:
1328 ok_header = True
1329 result["header"] = True
1330
1331 if _rule_active("docstring"):
1332 ok_doc, doc_msg = check_docstring(text)
1333 result["docstring"] = ok_doc
1334 report(ok_doc, f"Docstring structure: {doc_msg or 'OK'}", ctx=ctx)
1335 else:
1336 ok_doc = True
1337 result["docstring"] = True
1338
1339 if _rule_active("dependencies_section"):
1340 ok_deps, deps_msg = check_dependencies_section(text)
1341 result["dependencies_section"] = ok_deps
1342 report(ok_deps, f"Dependencies section: {deps_msg or 'OK'}", ctx=ctx)
1343 else:
1344 ok_deps = True
1345 result["dependencies_section"] = True
1346
1347 if _rule_active("diagnostics_profile"):
1348 ok_diag, diag_msg = check_diagnostics_profile(text)
1349 result["diagnostics_profile"] = ok_diag
1350 report(ok_diag, f"Diagnostics profile: {diag_msg or 'OK'}", ctx=ctx)
1351 else:
1352 ok_diag = True
1353 result["diagnostics_profile"] = True
1354
1355 if _rule_active("dbg_usage"):
1356 ok_dbg, dbg_msg = check_dbg_usage(text, tree=tree)
1357 result["dbg_usage"] = ok_dbg
1358 report(ok_dbg, f"_dbg usage: {dbg_msg or 'OK'}", ctx=ctx)
1359 else:
1360 ok_dbg = True
1361 result["dbg_usage"] = True
1362
1363 if _rule_active("dbg_import"):
1364 ok_dbg_imp, dbg_imp_msg = check_dbg_import(text, tree=tree)
1365 result["dbg_import"] = ok_dbg_imp
1366 report(ok_dbg_imp, f"_dbg import: {dbg_imp_msg or 'OK'}", ctx=ctx)
1367 else:
1368 ok_dbg_imp = True
1369 result["dbg_import"] = True
1370
1371 if _rule_active("func_pattern"):
1372 ok_func, func_msg = check_func_pattern(text, tree=tree)
1373 result["func_pattern"] = ok_func
1374 report(ok_func, f"func pattern: {func_msg or 'OK'}", ctx=ctx)
1375 else:
1376 ok_func = True
1377 result["func_pattern"] = True
1378
1379 ok_syntax, syntax_msg = check_syntax_warnings(text, warnings_list=syntax_warnings_list)
1380 result["syntax_warnings"] = ok_syntax
1381 report(ok_syntax, f"Syntax warnings: {syntax_msg or 'OK'}", ctx=ctx)
1382
1383 if _rule_active("type_hints"):
1384 ok_hints, hints_msg = check_type_hints(text, tree=tree, compact_mode=compact_mode)
1385 result["type_hints"] = ok_hints
1386 report(ok_hints, f"Type hints: {hints_msg or 'OK'}", ctx=ctx)
1387 else:
1388 ok_hints = True
1389 result["type_hints"] = True
1390
1391 if _rule_active("no_hardcoded_secrets"):
1392 # OI-540: path-based entropy exclusions. Both ``test_directory``
1393 # (test fixtures) and ``module_self_doc`` (source files dominated
1394 # by help-text/templates/scaffold strings) honour the same
1395 # ``exempt_entropy_only`` semantics.
1396 file_entropy = ctx.code_entropy_threshold
1397 for excl in ctx.entropy_exclusions:
1398 if excl.get("context") in ("test_directory", "module_self_doc"):
1399 pattern = excl.get("path_pattern", "")
1400 restrictions = excl.get("restrictions", {})
1401 if pattern and rel.replace("\\", "/").startswith(pattern):
1402 if restrictions.get("exempt_entropy_only"):
1403 file_entropy = 0
1404 break
1405 ok_secrets, secrets_msg = check_no_hardcoded_secrets(
1406 text, tree=tree, mode=ctx.secret_match_mode,
1407 code_entropy_threshold=file_entropy,
1408 docstring_entropy_threshold=ctx.docstring_entropy_threshold,
1409 comment_entropy_threshold=ctx.comment_entropy_threshold,
1410 excluded_field_patterns=ctx.excluded_field_patterns,
1411 )
1412 result["no_hardcoded_secrets"] = ok_secrets
1413 report(ok_secrets, f"Hardcoded secrets: {secrets_msg or 'OK'}", ctx=ctx)
1414 else:
1415 ok_secrets = True
1416 result["no_hardcoded_secrets"] = True
1417
1418 if _rule_active("dbg_assert_safety"):
1419 ok_dbg_assert, dbg_assert_msg = check_dbg_assert_safety(
1420 text, tree=tree, filename=str(path)
1421 )
1422 result["dbg_assert_safety"] = ok_dbg_assert
1423 report(ok_dbg_assert, f"dbg_assert safety: {dbg_assert_msg or 'OK'}", ctx=ctx)
1424 else:
1425 ok_dbg_assert = True
1426 result["dbg_assert_safety"] = True
1427
1428 if _rule_active("tests_exist"):
1429 ok_test = check_tests_exist(path, ctx)
1430 result["tests_exist"] = ok_test
1431 report(ok_test, "Regression test exists", ctx=ctx)
1432 else:
1433 ok_test = True
1434 result["tests_exist"] = True
1435
1436 # Log waiver warnings
1437 for ww in waiver_warnings:
1438 report(False, f"Waiver warning: {ww}", ctx=ctx)
1439
1440 # FS-C4: per-rule severity lookup. A failed rule classified as
1441 # ``advisory`` is reported but does NOT contribute to ``compliant``.
1442 from oct.linter.rule_registry import get_severity, SEVERITY_BLOCKING
1443 _check_results = {
1444 "header": ok_header or fix_mode,
1445 "docstring": ok_doc,
1446 "dependencies_section": result.get("dependencies_section", True),
1447 "diagnostics_profile": ok_diag,
1448 "dbg_usage": ok_dbg,
1449 "dbg_import": ok_dbg_imp,
1450 "func_pattern": ok_func,
1451 "syntax_warnings": ok_syntax,
1452 "type_hints": ok_hints,
1453 "no_hardcoded_secrets": ok_secrets,
1454 "dbg_assert_safety": ok_dbg_assert,
1455 "tests_exist": ok_test,
1456 }
1457 _check_severities = {
1458 rule: get_severity(rule, ctx.profile_name)
1459 for rule in _check_results
1460 }
1461 # ``compliant`` ignores advisory failures so AI agents and CI can
1462 # surface advisory items without flipping the build red.
1463 result["compliant"] = all(
1464 passed
1465 for rule, passed in _check_results.items()
1466 if _check_severities[rule] == SEVERITY_BLOCKING
1467 )
1468
1469 def _check_block(passed: bool, message: str, rule: str) -> dict:
1470 return {
1471 "pass": passed,
1472 "message": message,
1473 "severity": _check_severities[rule],
1474 }
1475
1476 # JSON-mode detail block
1477 result["json_detail"] = {
1478 "path": rel,
1479 "checks": {
1480 "header": _check_block(
1481 ok_header or (fix_mode and not ok_header),
1482 ", ".join(header_errors) if header_errors else "OK",
1483 "header",
1484 ),
1485 "docstring": _check_block(ok_doc, doc_msg or "OK", "docstring"),
1486 "dependencies_section": _check_block(
1487 result.get("dependencies_section", True),
1488 "OK" if result.get("dependencies_section", True) else "Missing or malformed Dependencies section",
1489 "dependencies_section",
1490 ),
1491 "diagnostics_profile": _check_block(ok_diag, diag_msg or "OK", "diagnostics_profile"),
1492 "dbg_usage": _check_block(ok_dbg, dbg_msg or "OK", "dbg_usage"),
1493 "dbg_import": _check_block(ok_dbg_imp, dbg_imp_msg or "OK", "dbg_import"),
1494 "func_pattern": _check_block(ok_func, func_msg or "OK", "func_pattern"),
1495 "syntax_warnings": _check_block(ok_syntax, syntax_msg or "OK", "syntax_warnings"),
1496 "type_hints": _check_block(ok_hints, hints_msg if not ok_hints else "OK", "type_hints"),
1497 "no_hardcoded_secrets": _check_block(ok_secrets, secrets_msg if not ok_secrets else "OK", "no_hardcoded_secrets"),
1498 "dbg_assert_safety": _check_block(ok_dbg_assert, dbg_assert_msg if not ok_dbg_assert else "OK", "dbg_assert_safety"),
1499 "tests_exist": _check_block(ok_test, "OK" if ok_test else "No test found", "tests_exist"),
1500 },
1501 "compliant": result["compliant"],
1502 "waivers": result["waivers"],
1503 }
1504
1505 # FS-510: store result in cache
1506 if ctx.lint_cache is not None and not fix_mode and _file_hash and _rules_hash:
1507 from oct.linter.lint_cache import LintCacheEntry
1508 from datetime import datetime as _dt
1509 from oct import __version__ as _ver
1510 cache_entry = LintCacheEntry(
1511 content_hash=_file_hash,
1512 rule_results={
1513 k: v for k, v in result.items()
1514 if k not in ("fixed", "rel", "json_detail", "waivers", "compliant")
1515 },
1516 json_detail=result["json_detail"],
1517 oct_version=_ver,
1518 timestamp=_dt.now().isoformat(),
1519 )
1520 ctx.lint_cache.put(_file_hash, _rules_hash, cache_entry)
1521
1522 return result
1523
1524
1525# ---------------------------------------------------------------------
1526# Main linter entry point
1527# ---------------------------------------------------------------------
1528
1529
1530def run_linter(project_root: Path, argv: list[str] | None = None) -> int:
1531 """
1532 Run the Option C linter on the given project root.
1533
1534 Parameters
1535 ----------
1536 project_root:
1537 The detected Option C project root directory.
1538 argv:
1539 Optional list of command-line arguments (e.g. ["--fix-headers"]).
1540 If None, ``argparse`` will use ``sys.argv``.
1541
1542 Returns
1543 -------
1544 int
1545 Exit code: 0 if all checks passed, 1 if any failures detected.
1546 """
1547 parser = argparse.ArgumentParser(add_help=False)
1548 parser.add_argument("--help", action="store_true")
1549 parser.add_argument("--fix-headers", action="store_true")
1550 parser.add_argument("--dry-run", action="store_true",
1551 help="Preview changes without modifying files")
1552 parser.add_argument(
1553 "--json",
1554 action="store_true",
1555 help="Emit machine-readable JSON to stdout instead of colorized terminal output",
1556 )
1557 parser.add_argument(
1558 "--jobs", "-j",
1559 type=int,
1560 default=1,
1561 help="Parallel workers for file processing (default: 1)",
1562 )
1563 parser.add_argument(
1564 "--changed", action="store_true",
1565 help="Lint only git-modified Python files",
1566 )
1567 parser.add_argument(
1568 "--profile",
1569 type=str,
1570 default=None,
1571 help="Lint profile: proto, compact, strict (overrides .octrc.json)",
1572 )
1573 parser.add_argument(
1574 "--secret-match",
1575 type=str,
1576 choices=("substring", "word"),
1577 default="substring",
1578 help=(
1579 "Secret-name matcher (OI-529): 'substring' (default, legacy) or "
1580 "'word' (segment-aware; avoids false positives like 'compass')."
1581 ),
1582 )
1583 parser.add_argument(
1584 "--code-entropy-threshold",
1585 type=float,
1586 default=None,
1587 dest="entropy_threshold",
1588 help="Shannon entropy threshold for code string literals (default: 4.5)",
1589 )
1590 parser.add_argument(
1591 "--no-entropy",
1592 action="store_true",
1593 help="Disable entropy-based secret detection",
1594 )
1595 parser.add_argument(
1596 "--no-cache",
1597 action="store_true",
1598 help="Bypass lint cache reads (still writes updated cache)",
1599 )
1600 parser.add_argument(
1601 "--baseline",
1602 action="store_true",
1603 help="Save current violations as the baseline",
1604 )
1605 parser.add_argument(
1606 "--against-baseline",
1607 action="store_true",
1608 help="Report only new/resolved violations vs saved baseline",
1609 )
1610 parser.add_argument(
1611 "--explain",
1612 type=str,
1613 default=None,
1614 help="Show documentation for a lint rule (rationale, pattern, spec ref)",
1615 )
1616 parser.add_argument(
1617 "targets", nargs="*", default=None,
1618 help="Files or directories to lint (default: entire project)",
1619 )
1620 args = parser.parse_args(argv)
1621
1622 if args.help:
1623 print("Option C Linter")
1624 print("Usage:")
1625 print(" oct lint [--fix-headers] [--dry-run] [--json] [--jobs N] [--changed] [--profile P] [--secret-match MODE] [PATH ...]")
1626 print(" oct lint --explain RULE [--json]")
1627 print("")
1628 print("Arguments:")
1629 print(" --help Show this help message")
1630 print(" --fix-headers Automatically fix shebang, encoding, and path headers")
1631 print(" --dry-run Preview changes without modifying files")
1632 print(" --json Emit machine-readable JSON to stdout")
1633 print(" --jobs N, -j N Parallel workers for file processing (default: 1)")
1634 print(" --changed Lint only git-modified Python files")
1635 print(" --profile P Lint profile: proto, compact, strict (overrides .octrc.json)")
1636 print(" --secret-match MODE Secret-name matcher: substring (default) | word (segment-aware)")
1637 print(" --code-entropy-threshold N Shannon entropy threshold for code strings (default: 4.5)")
1638 print(" --no-entropy Disable entropy-based secret detection")
1639 print(" --no-cache Bypass lint cache reads (still writes)")
1640 print(" --baseline Save current violations as the baseline")
1641 print(" --against-baseline Report only new/resolved vs saved baseline")
1642 print(" --explain RULE Show rule documentation (rationale, pattern, spec ref)")
1643 print(" PATH ... Files or directories to lint (default: entire project)")
1644 return 0
1645
1646 # ---- FS-509: --explain handler ----
1647 if args.explain:
1648 from oct.linter.rule_registry import get_rule_info, list_rules
1649 info = get_rule_info(args.explain)
1650 if info is None:
1651 available = ", ".join(r.name for r in list_rules())
1652 print(f"Error: unknown rule '{args.explain}'", file=sys.stderr)
1653 print(f"Available rules: {available}", file=sys.stderr)
1654 return 1
1655 if args.json:
1656 from dataclasses import asdict
1657 json.dump(asdict(info), sys.stdout, indent=2)
1658 print()
1659 else:
1660 print(f"Rule: {info.name}")
1661 print(f"Label: {info.label}")
1662 print(f"Fixable: {'yes' if info.fixable else 'no'}")
1663 print(f"Profiles: {', '.join(info.profiles)}")
1664 print(f"Spec: {info.spec_reference}")
1665 print("")
1666 print("Rationale:")
1667 print(f" {info.rationale}")
1668 print("")
1669 print("Correct pattern:")
1670 for line in info.correct_pattern.splitlines():
1671 print(f" {line}")
1672 print("")
1673 print("Common mistake:")
1674 print(f" {info.common_mistake}")
1675 return 0
1676
1677 # FS-508: --baseline and --against-baseline are mutually exclusive
1678 if args.baseline and args.against_baseline:
1679 print("Error: --baseline and --against-baseline are mutually exclusive.",
1680 file=sys.stderr)
1681 return 1
1682
1683 # Version handshake with oc_diagnostics (warning-only, always stderr)
1684 from oct.core.compat import check_oc_diagnostics_compat
1685 compat_msg = check_oc_diagnostics_compat()
1686 if compat_msg:
1687 print(compat_msg, file=sys.stderr)
1688
1689 root = project_root.resolve()
1690 _oc_diag = root / "oc_diagnostics"
1691 ctx = LinterContext(
1692 project_root=root,
1693 project_name=root.name,
1694 diagnostics_dir=_oc_diag if _oc_diag.exists() else root / "diagnostics",
1695 tests_dir=root / "tests",
1696 docs_dir=root / "docs",
1697 secret_match_mode=args.secret_match,
1698 )
1699
1700 if ctx.diagnostics_dir.name == "diagnostics":
1701 print(
1702 "Warning: 'diagnostics/' is deprecated and will be removed in v1.0.0. "
1703 "Rename to 'oc_diagnostics/'.",
1704 file=sys.stderr,
1705 )
1706
1707 # OI-510 / FS-516: build the test discovery index once, up front — the
1708 # per-file tests_exist rule then performs O(1) set lookups instead of
1709 # re-walking tests/ for every linted file.
1710 build_test_index(ctx)
1711
1712 # ---- Per-project overrides from .octrc.json ----
1713 # OI-519: routed through central oct.core.octrc loader; strict mode
1714 # still escalates JSON/schema errors to exit 2.
1715 from oct.core.octrc import load_octrc
1716 active_exclude_dirs = set(EXCLUDE_DIRS)
1717 active_wildcard_exclude = list(WILDCARD_EXCLUDE_DIRS)
1718 active_rules = dict(_DEFAULT_RULES)
1719 octrc, octrc_errors = load_octrc(root)
1720 if octrc_errors:
1721 msg = "; ".join(octrc_errors)
1723 print(f"Error: {msg}", file=sys.stderr)
1724 raise SystemExit(2)
1725 print(f"Warning: {msg}; using defaults.", file=sys.stderr)
1726
1727 if octrc:
1728 # OI-411: validate schema and enforce in strict mode
1729 schema_errors = _validate_octrc_schema(octrc)
1730 if schema_errors:
1731 msg = f".octrc.json schema errors: {'; '.join(schema_errors)}"
1733 print(f"Error: {msg}", file=sys.stderr)
1734 raise SystemExit(2)
1735 print(f"Warning: {msg}", file=sys.stderr)
1736
1737 linter_cfg = octrc.get("linter", {})
1738 if not isinstance(linter_cfg, dict):
1739 linter_cfg = {}
1740 for key in ("exclude_dirs_add", "exclude_dirs_remove", "wildcard_exclude_add"):
1741 val = linter_cfg.get(key)
1742 if val is not None and not isinstance(val, list):
1743 linter_cfg[key] = []
1744 if not isinstance(linter_cfg.get("profile", "default"), str):
1745 linter_cfg["profile"] = "default"
1746 if linter_cfg.get("rules") is not None and not isinstance(linter_cfg["rules"], dict):
1747 linter_cfg["rules"] = {}
1748 for d in linter_cfg.get("exclude_dirs_add", []):
1749 if isinstance(d, str):
1750 active_exclude_dirs.add(d)
1751 for d in linter_cfg.get("exclude_dirs_remove", []):
1752 if isinstance(d, str):
1753 active_exclude_dirs.discard(d)
1754 for d in linter_cfg.get("wildcard_exclude_add", []):
1755 if isinstance(d, str) and d not in active_wildcard_exclude:
1756 active_wildcard_exclude.append(d)
1757 # Rule profile and per-rule overrides
1758 profile_name = linter_cfg.get("profile", "default")
1759 active_rules = dict(_PROFILES.get(profile_name, _DEFAULT_RULES))
1760 rule_overrides = linter_cfg.get("rules", {}) or {}
1761 active_rules.update({k: v for k, v in rule_overrides.items() if isinstance(v, bool)})
1762 ctx.profile_name = profile_name # FS-C4: route profile to severity lookup
1763
1764 # FS-507: read entropy thresholds from octrc
1765 scanner_cfg = octrc.get("secret_scanner", {})
1766 if isinstance(scanner_cfg, dict):
1767 octrc_threshold = scanner_cfg.get("code_entropy_threshold")
1768 if isinstance(octrc_threshold, (int, float)) and not isinstance(octrc_threshold, bool):
1769 ctx.code_entropy_threshold = float(octrc_threshold)
1770 for key in ("docstring_entropy_threshold", "comment_entropy_threshold"):
1771 val = scanner_cfg.get(key)
1772 if isinstance(val, (int, float)) and not isinstance(val, bool):
1773 setattr(ctx, key, float(val))
1774
1775 # OI-540: load entropy exclusions.
1776 exclusions = scanner_cfg.get("entropy_exclusions", [])
1777 if isinstance(exclusions, list):
1778 ctx.entropy_exclusions = exclusions
1779 ctx.excluded_field_patterns = [
1780 e["field_name_pattern"] for e in exclusions
1781 if e.get("context") == "namedtuple_field"
1782 and isinstance(e.get("field_name_pattern"), str)
1783 ]
1784
1785 # ---- CLI --profile flag overrides .octrc.json ----
1786 if args.profile:
1787 cli_profile = args.profile.lower()
1788 if cli_profile in _PROFILES:
1789 active_rules = dict(_PROFILES[cli_profile])
1790 ctx.profile_name = cli_profile # FS-C4: route profile to severity lookup
1791 else:
1792 print(
1793 f"Warning: unknown profile '{args.profile}'; "
1794 f"valid: {', '.join(p for p in _PROFILES if not p.startswith('_'))}",
1795 file=sys.stderr,
1796 )
1797
1798 # ---- FS-507: CLI entropy flags override octrc ----
1799 if args.no_entropy:
1800 ctx.code_entropy_threshold = 0.0
1801 elif args.entropy_threshold is not None:
1802 ctx.code_entropy_threshold = args.entropy_threshold
1803
1804 # ---- FS-510: initialise lint cache ----
1805 from oct.core.option_c_dir import option_c_dir
1806 from oct import __version__
1807 cache_dir = option_c_dir(root) / "cache"
1808 if not args.fix_headers:
1809 from oct.linter.lint_cache import LintCache
1810 ctx.lint_cache = LintCache(cache_dir / "lint-cache.json", __version__)
1811 ctx.no_cache_read = args.no_cache
1812
1813 # ---- Open log file (non-fatal if unwritable) ----
1814 try:
1815 log_dir = root / "logs"
1816 log_dir.mkdir(exist_ok=True)
1817 _rotate_lint_logs(log_dir)
1818 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
1819 log_file = log_dir / f"oct_lint_output-{timestamp}.txt"
1820 ctx.log_handle = log_file.open("w", encoding="utf-8")
1821 ctx.log_path = log_file
1822 except OSError as exc:
1823 print(f"Warning: Could not open lint log file: {exc}", file=sys.stderr)
1824 ctx.log_handle = None
1825
1826 fix_mode = args.fix_headers
1827 dry_run = args.dry_run
1828 json_mode = args.json
1829
1830 if fix_mode and dry_run and not json_mode:
1831 print("Dry-run mode: showing what --fix-headers would change. "
1832 "Fix details will be written to the log file.",
1833 file=sys.stderr)
1834
1835 if not json_mode:
1836 print_header("Option C Linter — Project Scan", ctx)
1837 log(f"Project root: {ctx.project_root}", ctx)
1838 log(f"Project name: {ctx.project_name}", ctx)
1839
1840 # Project-level requirements
1841 required_docs = [
1842 ctx.docs_dir / "ARCHITECTURE.md",
1843 ctx.tests_dir / "run_tests.py",
1844 ctx.tests_dir / "Tests_README.md",
1845 ctx.diagnostics_dir / "debug_config.json",
1846 ]
1847
1848 if not json_mode:
1849 print_header("Project-Level Requirements", ctx)
1850 for req in required_docs:
1851 report(req.exists(), f"Required file: {req}", fix_mode=False, ctx=ctx)
1852
1853 # Module-level checks
1854 if not json_mode:
1855 print_header("Module-Level Checks", ctx)
1856
1857 # Force sequential when fixing (parallel writes are unsafe)
1858 jobs = max(1, args.jobs)
1859 if fix_mode and jobs > 1:
1860 jobs = 1
1861 if not json_mode:
1862 print("Note: --jobs forced to 1 when --fix-headers is active.", file=sys.stderr)
1863
1864 if args.changed:
1865 if args.targets:
1866 if not json_mode:
1867 print("Note: --changed overrides explicit PATH targets.", file=sys.stderr)
1868 python_files = _git_changed_files(ctx.project_root)
1869 # Apply exclusion filters
1870 python_files = [
1871 p for p in python_files
1872 if p.is_file() and not any(
1873 is_excluded_dir(Path(part), active_exclude_dirs, active_wildcard_exclude)
1874 for part in p.relative_to(ctx.project_root).parts
1875 )
1876 ]
1877 if not python_files:
1878 if not json_mode:
1879 print("No modified Python files found.", file=sys.stderr)
1880 return 0
1881 elif args.targets:
1882 python_files = []
1883 for t in args.targets:
1884 p = Path(t).resolve()
1885 if p.is_file() and p.suffix == ".py":
1886 python_files.append(p)
1887 elif p.is_dir():
1888 python_files.extend(
1889 find_python_files(p, active_exclude_dirs, active_wildcard_exclude)
1890 )
1891 else:
1892 print(f"Warning: Not a Python file or directory: {t}", file=sys.stderr)
1893 python_files = list(dict.fromkeys(python_files))
1894 if not python_files:
1895 print("No matching Python files found.", file=sys.stderr)
1896 return 0
1897 else:
1898 python_files = list(find_python_files(ctx.project_root, active_exclude_dirs, active_wildcard_exclude))
1899 total_files = len(python_files)
1900
1901 # Dispatch per-file linting (sequential or parallel)
1902 if jobs <= 1:
1903 results = [_lint_single_file(p, ctx, active_rules, fix_mode, dry_run) for p in python_files]
1904 else:
1905 with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as pool:
1906 futures = [pool.submit(_lint_single_file, p, ctx, active_rules, fix_mode, dry_run) for p in python_files]
1907 results = [f.result() for f in futures]
1908
1909 # Aggregate counters from per-file results
1910 pass_header = sum(1 for r in results if r["header"])
1911 pass_docstring = sum(1 for r in results if r["docstring"])
1912 pass_diag = sum(1 for r in results if r["diagnostics_profile"])
1913 pass_dbg = sum(1 for r in results if r["dbg_usage"])
1914 pass_dbg_import = sum(1 for r in results if r["dbg_import"])
1915 pass_func = sum(1 for r in results if r["func_pattern"])
1916 pass_syntax = sum(1 for r in results if r["syntax_warnings"])
1917 pass_hints = sum(1 for r in results if r["type_hints"])
1918 pass_tests = sum(1 for r in results if r["tests_exist"])
1919 fully_compliant = sum(1 for r in results if r["compliant"])
1920 fixed_files = sum(1 for r in results if r["fixed"])
1921
1922 file_results: list[dict] = []
1923 if json_mode:
1924 file_results = [r["json_detail"] for r in results]
1925
1926 if not json_mode:
1927 print_header("Final Result", ctx)
1928 if fully_compliant == total_files:
1929 log("All checks passed. Project conforms to Option C.", ctx)
1930 else:
1931 log("Some checks failed. See above for details.", ctx)
1932
1933 if not json_mode:
1934 print_header("Summary", ctx)
1935
1936 def pct(n: int) -> str:
1937 return f"{(n / total_files * 100):.1f}%" if total_files else "0%"
1938
1939 log(f"Number of files checked:\t{total_files}", ctx)
1940 log(f"[Header block]:\t\t{pass_header} PASS ({pct(pass_header)})", ctx)
1941 log(f"[Docstring structure]:\t\t{pass_docstring} PASS ({pct(pass_docstring)})", ctx)
1942 log(f"[Diagnostics profile]:\t\t{pass_diag} PASS ({pct(pass_diag)})", ctx)
1943 log(f"[_dbg usage]:\t\t\t{pass_dbg} PASS ({pct(pass_dbg)})", ctx)
1944 log(f"[_dbg import]:\t\t\t{pass_dbg_import} PASS ({pct(pass_dbg_import)})", ctx)
1945 log(f"[func pattern]:\t\t\t{pass_func} PASS ({pct(pass_func)})", ctx)
1946 log(f"[Syntax warnings]:\t\t{pass_syntax} PASS ({pct(pass_syntax)})", ctx)
1947 log(f"[Type hints]:\t\t\t{pass_hints} PASS ({pct(pass_hints)})", ctx)
1948 log(f"[Regression test exists]:\t{pass_tests} PASS ({pct(pass_tests)})", ctx)
1949 log(f"[Compliant files]:\t\t{fully_compliant} PASS ({pct(fully_compliant)})", ctx)
1950
1951 if fix_mode:
1952 label = "Would fix" if dry_run else "Files fixed"
1953 log(f"[{label}]:\t\t{fixed_files}", ctx)
1954
1955 if ctx.log_handle is not None:
1956 ctx.log_handle.close()
1957 ctx.log_handle = None
1958
1959 # FS-510: save lint cache
1960 if ctx.lint_cache is not None:
1961 try:
1962 ctx.lint_cache.save()
1963 except OSError:
1964 pass
1965
1966 # FS-508: baseline handling
1967 baseline_info: dict | None = None
1968 if args.baseline:
1969 from oct.linter.lint_baseline import save_baseline
1970 baseline_path = cache_dir / "lint-baseline.json"
1971 profile_name = args.profile or "strict"
1972 count = save_baseline(results, baseline_path, profile_name)
1973 baseline_info = {"action": "saved", "count": count, "path": str(baseline_path)}
1974 if not json_mode:
1975 print(f"\nBaseline saved: {count} violation(s) -> {baseline_path}",
1976 file=sys.stderr)
1977
1978 if args.against_baseline:
1979 from oct.linter.lint_baseline import load_baseline, diff_against_baseline
1980 baseline_path = cache_dir / "lint-baseline.json"
1981 baseline = load_baseline(baseline_path)
1982 if baseline is None:
1983 print("Error: no baseline found. Run 'oct lint --baseline' first.",
1984 file=sys.stderr)
1985 return 1
1986 new_violations, resolved = diff_against_baseline(results, baseline)
1987 baseline_info = {
1988 "action": "compared",
1989 "new_violations": [{"path": v[0], "rule": v[1], "message": v[2]} for v in new_violations],
1990 "resolved_violations": [{"path": v[0], "rule": v[1], "message": v[2]} for v in resolved],
1991 "new_count": len(new_violations),
1992 "resolved_count": len(resolved),
1993 "baselined_count": len(baseline.entries),
1994 }
1995 if not json_mode:
1996 if new_violations:
1997 print(f"\n{RED}New violations ({len(new_violations)}):{RESET}", file=sys.stderr)
1998 for path_str, rule, msg in new_violations:
1999 print(f" {path_str}: [{rule}] {msg}", file=sys.stderr)
2000 if resolved:
2001 print(f"\n{GREEN}Resolved ({len(resolved)}):{RESET}", file=sys.stderr)
2002 for path_str, rule, msg in resolved:
2003 print(f" {path_str}: [{rule}] {msg}", file=sys.stderr)
2004 if not new_violations:
2005 print(f"\n{GREEN}No new violations vs baseline.{RESET}", file=sys.stderr)
2006
2007 # JSON output (stdout — separate from the log file)
2008 if json_mode:
2009 output = {
2010 "project": ctx.project_name,
2011 "oct_version": __version__,
2012 "timestamp": datetime.now().isoformat(),
2013 "summary": {
2014 "total_files": total_files,
2015 "fully_compliant": fully_compliant,
2016 "pass_header": pass_header,
2017 "pass_docstring": pass_docstring,
2018 "pass_diagnostics_profile": pass_diag,
2019 "pass_dbg": pass_dbg,
2020 "pass_dbg_import": pass_dbg_import,
2021 "pass_func": pass_func,
2022 "pass_syntax": pass_syntax,
2023 "pass_type_hints": pass_hints,
2024 "pass_tests": pass_tests,
2025 },
2026 "files": file_results,
2027 }
2028 if baseline_info is not None:
2029 output["baseline"] = baseline_info
2030 json.dump(output, sys.stdout, indent=2)
2031 print() # trailing newline after JSON block
2032 if args.against_baseline and baseline_info:
2033 return 1 if baseline_info.get("new_count", 0) > 0 else 0
2034 return 0 if fully_compliant == total_files else 1
2035
2036 # Terminal summary (colorized)
2037 print(f"{CYAN}Option C Linter Summary{RESET}")
2038 print(f"Files checked: {total_files}")
2039
2040 def color(n: int) -> str:
2041 return GREEN if n == total_files else RED
2042
2043 print(f"[Header block]: {color(pass_header)}{pass_header}{RESET} PASS ({pct(pass_header)})")
2044 print(f"[Docstring structure]: {color(pass_docstring)}{pass_docstring}{RESET} PASS ({pct(pass_docstring)})")
2045 print(f"[Diagnostics profile]: {color(pass_diag)}{pass_diag}{RESET} PASS ({pct(pass_diag)})")
2046 print(f"[_dbg usage]: {color(pass_dbg)}{pass_dbg}{RESET} PASS ({pct(pass_dbg)})")
2047 print(f"[_dbg import]: {color(pass_dbg_import)}{pass_dbg_import}{RESET} PASS ({pct(pass_dbg_import)})")
2048 print(f"[func pattern]: {color(pass_func)}{pass_func}{RESET} PASS ({pct(pass_func)})")
2049 print(f"[Syntax warnings]: {color(pass_syntax)}{pass_syntax}{RESET} PASS ({pct(pass_syntax)})")
2050 print(f"[Type hints]: {color(pass_hints)}{pass_hints}{RESET} PASS ({pct(pass_hints)})")
2051 print(f"[Regression test exists]: {color(pass_tests)}{pass_tests}{RESET} PASS ({pct(pass_tests)})")
2052 print(f"[Compliant files]: {color(fully_compliant)}{fully_compliant}{RESET} PASS ({pct(fully_compliant)})")
2053
2054 if fix_mode:
2055 label = "Would fix" if dry_run else "Files fixed"
2056 print(f"[{label}]: {GREEN}{fixed_files}{RESET}")
2057 if dry_run and fixed_files > 0 and ctx.log_path:
2058 print(f"\nDry-run fix details written to: {ctx.log_path}")
2059
2060 if args.against_baseline and baseline_info:
2061 return 1 if baseline_info.get("new_count", 0) > 0 else 0
2062 return 0 if fully_compliant == total_files else 1
2063
2064
2065def main() -> None:
2066 """
2067 Standalone entry point when running this module directly.
2068 """
2069 raise SystemExit(run_linter(Path.cwd(), None))
2070
2071
2072if __name__ == "__main__":
2073 main()
list[Path] _git_changed_files(Path root)
Definition oct_lint.py:535
find_python_files(Path root, set|None exclude_dirs=None, list|None wildcard_exclude_dirs=None)
Definition oct_lint.py:555
check_docstring(str text)
Definition oct_lint.py:691
check_syntax_warnings(str text, list[str]|None warnings_list=None)
Definition oct_lint.py:977
None log(str message, LinterContext|None ctx=None)
Definition oct_lint.py:468
validate_header_block(list[str] lines, Path path, LinterContext ctx)
Definition oct_lint.py:601
str read_file(Path path, LinterContext|None ctx=None)
Definition oct_lint.py:523
None print_header(str title, LinterContext|None ctx=None)
Definition oct_lint.py:482
None _rotate_lint_logs(Path log_dir, int max_files=_MAX_LINT_LOG_FILES)
Definition oct_lint.py:1203
str|None _extract_module_docstring(str text)
Definition oct_lint.py:747
check_type_hints(str text, ast.Module|None tree=None, bool compact_mode=False)
Definition oct_lint.py:992
bool fix_header_block(Path path, str text, LinterContext ctx)
Definition oct_lint.py:643
bool is_excluded_dir(Path path, set exclude_dirs, list wildcard_exclude_dirs)
Definition oct_lint.py:510
bool _is_security_sensitive_name(str name)
Definition oct_lint.py:1059
_walk_function_body(ast.FunctionDef|ast.AsyncFunctionDef func_node)
Definition oct_lint.py:838
dict[str, set[str]] build_test_index(LinterContext ctx)
Definition oct_lint.py:1139
tuple[bool, str] check_dbg_assert_safety(str text, ast.Module|None tree=None, str filename="")
Definition oct_lint.py:1066
check_func_pattern(str text, ast.Module|None tree=None)
Definition oct_lint.py:936
int run_linter(Path project_root, list[str]|None argv=None)
Definition oct_lint.py:1530
check_diagnostics_profile(str text)
Definition oct_lint.py:765
str preview_header_fix(Path path, LinterContext ctx)
Definition oct_lint.py:672
int _header_boundary(list[str] lines, Path path, LinterContext ctx)
Definition oct_lint.py:630
_direct_body_nodes(func_node)
Definition oct_lint.py:852
bool _strict_config_requested()
Definition oct_lint.py:158
list[str] _validate_octrc_schema(dict cfg)
Definition oct_lint.py:170
str expected_path_header(Path path, LinterContext ctx)
Definition oct_lint.py:593
check_dbg_import(str text, ast.Module|None tree=None)
Definition oct_lint.py:814
check_dependencies_section(str text)
Definition oct_lint.py:715
dict _lint_single_file(Path path, LinterContext ctx, dict active_rules, bool fix_mode, bool dry_run=False)
Definition oct_lint.py:1220
bool report(bool result, str message, bool fix_mode=False, LinterContext|None ctx=None)
Definition oct_lint.py:493
check_tests_exist(Path module_path, LinterContext ctx)
Definition oct_lint.py:1179
list[tuple[str, int]] find_func_pattern_violations(str text, ast.Module|None tree=None)
Definition oct_lint.py:871
check_dbg_usage(str text, ast.Module|None tree=None)
Definition oct_lint.py:785