oct
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- 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)
64"""
65
66import argparse
67import ast
68import concurrent.futures
69import json
70import re
71import shutil
72import subprocess
73import threading
74import sys
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
80
81if TYPE_CHECKING: # pragma: no cover - typing only
82 from oct.core.workspace import Workspace
83
84# ---------------------------------------------------------------------
85# OI-513: log state is owned by :class:`LinterContext`. The previous
86# module-level ``LOG_HANDLE`` global is gone - concurrent linter runs
87# (parallel --jobs, MCP, health dashboard) each carry their own handle
88# on their own context without cross-contamination.
89# ---------------------------------------------------------------------
90
91# Resolved path to OCT's own package directory - used to exclude OCT internals
92# from linting when the project tree contains OCT itself.
93_OCT_PACKAGE_DIR = Path(__file__).resolve().parent.parent
94
95# ---------------------------------------------------------------------
96# Directory exclusion rules
97# ---------------------------------------------------------------------
98
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
105
106# ---------------------------------------------------------------------
107# Lint rule profiles
108# ---------------------------------------------------------------------
109
110_DEFAULT_RULES = {
111 # F21 / Plan 170: unconditionally active at every profile - never
112 # gated behind `_rule_active()` in `_lint_single_file` (see below),
113 # so no profile or octrc rule override can waive an unparseable file
114 # into "compliant". Present here only so it registers with
115 # rule_registry's completeness tests and `--explain syntax_error`.
116 "syntax_error": True,
117 "header": True,
118 "docstring": True,
119 "diagnostics_profile": True,
120 "dbg_usage": True,
121 "dbg_import": True,
122 "func_pattern": True,
123 "tests_exist": True,
124 "type_hints": True,
125 "no_hardcoded_secrets": True,
126 "dbg_assert_safety": True,
127 "dependencies_section": True,
128 # 2026-09-28 (operator rulings): ADVISORY check that the Dependencies
129 # section matches what the module imports - every first- and third-party
130 # import declared, lazy ones marked, nothing stale. Runs at EVERY
131 # profile (it never blocks) and exempts test modules itself.
132 "dependencies_match": True,
133 # Plan 151 T6: OC Chat prompt invariants (Plan 140), lifted from the
134 # OC Chat pytest wrapper into the strict profile. Scoped to the OC Chat
135 # runtime source tree - a no-op for every other project.
136 "prompt_invariants": True,
137 # Plan 193: ADVISORY burn-down counter for secret waivers still
138 # relying on pre-193 whole-file semantics. Never blocks; reaching
139 # zero tree-wide is the precondition for deleting the legacy branch.
140 "legacy_file_scoped_waiver": True,
141}
142
143_PROFILES = {
144 # V3.1 spec profiles: proto / compact / strict
145 "strict": dict(_DEFAULT_RULES),
146 "compact": {
147 "header": True,
148 "docstring": True,
149 "diagnostics_profile": True,
150 "dbg_usage": True,
151 "dbg_import": True,
152 "func_pattern": True,
153 "tests_exist": True,
154 "type_hints": True,
155 "no_hardcoded_secrets": True,
156 "dbg_assert_safety": True,
157 # Spec C1 / Drift A: Dependencies section enforced at strict only
158 "dependencies_section": False,
159 "dependencies_match": True,
160 # Plan 151 T6: prompt invariants are a strict-only rule. _rule_active
161 # defaults a missing key to True, so non-strict profiles must set it
162 # False explicitly.
163 "prompt_invariants": False,
164 # Plan 193: advisory-only; active wherever no_hardcoded_secrets is.
165 "legacy_file_scoped_waiver": True,
166 # compact: public-function-only checking for dbg/func/docstring
167 "_compact_mode": True,
168 },
169 "proto": {
170 "header": True,
171 "docstring": True,
172 "diagnostics_profile": False,
173 "dbg_usage": False,
174 "dbg_import": False,
175 "func_pattern": False,
176 "tests_exist": False,
177 "type_hints": False,
178 "no_hardcoded_secrets": False,
179 "dbg_assert_safety": False,
180 "dependencies_section": False,
181 "dependencies_match": True,
182 "prompt_invariants": False,
183 # Plan 193: proto does not scan for secrets, so there is no
184 # waiver blindness to count down.
185 "legacy_file_scoped_waiver": False,
186 },
187 # Deprecated aliases (backwards compat)
188 "default": dict(_DEFAULT_RULES),
189 "minimal": {
190 "header": True,
191 "docstring": True,
192 "diagnostics_profile": False,
193 "dbg_usage": False,
194 "dbg_import": False,
195 "func_pattern": False,
196 "tests_exist": False,
197 "type_hints": 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,
204 },
205}
206
207# Plan 260: one table drives the log summary, the terminal summary and
208# the JSON counters, so a rule cannot be checked without being reported.
209# Each row is ``(rule_key, label, log_tabs, json_key)``:
210#
211# - ``rule_key`` indexes both the per-file result dict and the per-file
212# ``json_detail["checks"]`` block. Before this table the summary named
213# ten of the fifteen rules, so a file could be non-compliant with every
214# printed row at 100% and nothing on stdout saying which check failed.
215# - ``label`` is the bracketed name printed in both text blocks.
216# - ``log_tabs`` reproduces the log block's hand-tuned tab padding
217# byte-for-byte. It is NOT derivable from the label width: "[Syntax
218# error]:" and "[func pattern]:" are both 15 columns and carry two and
219# three tabs respectively. The terminal block's padding IS uniform
220# (label field width 27) and is computed instead of stored.
221# - ``json_key`` is the summary counter name. The ten pre-existing names
222# are irregular (``dbg_usage`` counts as ``pass_dbg``, ``tests_exist``
223# as ``pass_tests``) and are kept byte-identical so no consumer breaks;
224# the five rules added here use ``pass_<rule_key>``.
225#
226# The ``[Compliant files]`` row is deliberately NOT in this table: it is
227# a derived aggregate over the blocking rules, not a rule, and it keeps
228# its own hand-written line after the loop.
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"),
240 # Plan 260: the five rules that ran but were never summarised.
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"),
245 (
246 "legacy_file_scoped_waiver",
247 "Legacy file-scoped waiver",
248 1,
249 "pass_legacy_file_scoped_waiver",
250 ),
251 # 2026-09-28: the declared-versus-actual Dependencies check.
252 ("dependencies_match", "Dependencies match imports", 1, "pass_dependencies_match"),
253)
254
255# Width of the bracketed label field in the colourised terminal summary.
256# The label is left-justified to one less than this and a single space
257# follows, so a label longer than the field still gets a separator.
258_TERMINAL_LABEL_WIDTH = 27
259
260
261# ---------------------------------------------------------------------
262# Rule-set resolution (plan 262)
263# ---------------------------------------------------------------------
264
265
266def resolve_active_rules(
267 profile_name: str,
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.
272
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.
280
281 Parameters
282 ----------
283 profile_name
284 The profile named by the project's ``linter.profile`` key, or
285 ``"default"`` when the project names none.
286 octrc_rules
287 The project's ``linter.rules`` block. Non-boolean values are
288 ignored, exactly as they were before this plan.
289 cli_profile
290 A validated ``--profile`` name, or ``None``. When given it
291 chooses the BASE rule set; the overrides still apply on top.
292
293 Returns
294 -------
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.
302 """
303 base_profile = cli_profile if cli_profile else profile_name
304 active_rules = dict(_PROFILES.get(base_profile, _DEFAULT_RULES))
305 overrides = {
306 key: value
307 for key, value in (octrc_rules or {}).items()
308 if isinstance(value, bool)
309 }
310 disabled_by_octrc = sorted(
311 key
312 for key, value in overrides.items()
313 if value is False
314 and not key.startswith("_")
315 # ``_rule_active`` treats a key missing from the profile table as
316 # active, so the same default decides what was "otherwise on".
317 and active_rules.get(key, True)
318 )
319 active_rules.update(overrides)
320 return active_rules, disabled_by_octrc
321
322
323# ---------------------------------------------------------------------
324# Path-scoped rule overrides (plan 268)
325# ---------------------------------------------------------------------
326
327
328def _path_rule_matches(prefix: str, rel_posix: str) -> bool:
329 """Return True when *rel_posix* is covered by *prefix*.
330
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
337 without running it.
338 """
339 if prefix.endswith("/"):
340 return rel_posix.startswith(prefix)
341 return rel_posix == prefix
342
343
344def effective_rules(
345 active_rules: dict[str, bool],
346 rel_posix: str,
347 path_rules: list,
348) -> tuple[dict[str, bool], list[str]]:
349 """Resolve the rule set for ONE file under the project's path rules.
350
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.
357
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.
365
366 Parameters
367 ----------
368 active_rules
369 The run's rule set from :func:`resolve_active_rules`. Never
370 mutated - the parallel linter shares one dict across threads.
371 rel_posix
372 The file's project-relative POSIX path, as
373 :func:`~oct.core.paths.to_project_relative` renders it.
374 path_rules
375 Validated ``(prefix, {rule: bool})`` pairs from
376 :func:`parse_path_rules`, usually ``ctx.path_rules``.
377
378 Returns
379 -------
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.
387 """
388 file_rules = dict(active_rules)
389 if not path_rules:
390 return file_rules, []
391
392 touched: set[str] = set()
393 for prefix, rules in path_rules:
394 if not _path_rule_matches(prefix, rel_posix):
395 continue
396 for rule, value in rules.items():
397 if not isinstance(value, bool):
398 continue
399 if rule.startswith("_") or rule in LINE_SCOPED_RULES:
400 continue
401 file_rules[rule] = value
402 touched.add(rule)
403
404 changed = sorted(
405 rule for rule in touched
406 if file_rules.get(rule, True) != active_rules.get(rule, True)
407 )
408 return file_rules, changed
409
410
411def parse_path_rules(linter_cfg: dict | None) -> list:
412 """Return the validated ``linter.path_rules`` entries, worst case ``[]``.
413
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
419 change a verdict.
420
421 Returns a list of ``(prefix, {rule: bool})`` pairs in file order, the
422 form :func:`effective_rules` and :class:`LinterContext.path_rules`
423 carry.
424 """
425 func = "parse_path_rules"
426 if not isinstance(linter_cfg, dict):
427 return []
428 raw = linter_cfg.get("path_rules")
429 if not isinstance(raw, list):
430 return []
431
432 parsed: list = []
433 for entry in raw:
434 if not isinstance(entry, dict):
435 continue
436 prefix = entry.get("path_prefix")
437 if not isinstance(prefix, str) or not prefix:
438 continue
439 if ".." in prefix or prefix.startswith("/"):
440 continue
441 rules = entry.get("rules")
442 if not isinstance(rules, dict):
443 continue
444 usable = {
445 name: value
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
450 }
451 if not usable:
452 continue
453 parsed.append((prefix, usable))
454
455 _dbg("OCT-LINTER", func, f"parsed {len(parsed)} path rule(s)", 3)
456 return parsed
457
458
459# ---------------------------------------------------------------------
460# .octrc.json schema validation (OI-411)
461# ---------------------------------------------------------------------
462
463def _strict_config_requested() -> bool:
464 """Return True when strict-config enforcement is active.
465
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.
469 """
470 import os as _os
471 val = _os.environ.get("OCT_STRICT_CONFIG", "").strip().lower()
472 return val in ("1", "true", "yes", "on")
473
474
475#: Plan 197 (T3) - top-level keys that LOOK like a real setting but are
476#: read by nothing, mapped to the nested key that actually applies.
477#: These are rejected rather than aliased: a security-relevant setting
478#: must have exactly ONE spelling, or every audit has to check two
479#: places and a near-miss typo stays silent. Silently ignoring them is
480#: what let ``oc_chat`` declare ``linter_profile: "strict"`` while the
481#: quality gate ran it at ``compact``. Genuinely unknown keys remain
482#: tolerated for forward compatibility - only this known-misplaced set
483#: is flagged.
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",
489}
490
491
492def _validate_path_rule_entry(index: int, entry: object) -> list[str]:
493 """Validate one ``linter.path_rules`` entry (plan 268).
494
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.
499 """
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__}"]
503
504 errors: list[str] = []
505
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")
509 else:
510 if ".." in prefix:
511 errors.append(f"'{where}.path_prefix' must not contain '..'")
512 if prefix.startswith("/"):
513 errors.append(
514 f"'{where}.path_prefix' must be project-relative "
515 f"(no leading '/')"
516 )
517
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__}")
521 else:
522 for name, value in rules.items():
523 if name in LINE_SCOPED_RULES:
524 # The one refusal that is a security invariant rather than
525 # a typo: a line-scoped rule is never switched off for a
526 # whole file (plan 193), so it is never switched off for a
527 # whole directory either.
528 errors.append(
529 f"'{where}.rules.{name}' cannot be scoped by path "
530 f"(line-scoped rules are never switched off)"
531 )
532 elif name.startswith("_"):
533 errors.append(
534 f"'{where}.rules.{name}' is not a rule "
535 f"(keys starting with '_' are mode markers)"
536 )
537 elif not isinstance(value, bool):
538 errors.append(
539 f"'{where}.rules.{name}' must be a bool, "
540 f"got {type(value).__name__}"
541 )
542
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__}")
546
547 return errors
548
549
550def _validate_octrc_schema(cfg: dict) -> list[str]:
551 """Validate the structure of a loaded ``.octrc.json`` dict.
552
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.
556
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.
561 """
562 errors: list[str] = []
563 if not isinstance(cfg, dict):
564 return [f"top-level must be an object, got {type(cfg).__name__}"]
565
566 # Plan 197 (T3): misplaced top-level keys that nothing reads.
567 for bad_key, correct_key in _MISPLACED_TOP_LEVEL_KEYS.items():
568 if bad_key in cfg:
569 errors.append(
570 f"'{bad_key}' is not a top-level key and is IGNORED; "
571 f"move it to '{correct_key}'"
572 )
573
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__}")
577 linter_cfg = None # skip further linter validation
578
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})")
586
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)
590 if val is None:
591 continue
592 if not isinstance(val, list):
593 errors.append(f"'linter.{key}' must be a list, got {type(val).__name__}")
594 continue
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__}")
598
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__}")
603 else:
604 for rule_name, rule_val in rules.items():
605 if not isinstance(rule_val, bool):
606 errors.append(
607 f"'linter.rules.{rule_name}' must be a bool, got {type(rule_val).__name__}"
608 )
609
610 # Plan 268: per-path rule overrides, applied after the profile and
611 # after ``linter.rules``. A malformed entry is reported and then
612 # ignored by ``parse_path_rules``; the entries around it apply.
613 path_rules = linter_cfg.get("path_rules")
614 if path_rules is not None:
615 if not isinstance(path_rules, list):
616 errors.append(
617 f"'linter.path_rules' must be a list, "
618 f"got {type(path_rules).__name__}"
619 )
620 else:
621 for i, entry in enumerate(path_rules):
622 errors.extend(_validate_path_rule_entry(i, entry))
623
624 # OI-424: validate the optional formatter section. Only the
625 # ``max_backups_per_file`` key is currently type-checked; unknown
626 # keys stay tolerated for forward compatibility.
627 formatter_cfg = cfg.get("formatter")
628 if formatter_cfg is not None:
629 if not isinstance(formatter_cfg, dict):
630 errors.append(
631 f"'formatter' must be an object, got {type(formatter_cfg).__name__}"
632 )
633 else:
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):
637 errors.append(
638 "'formatter.max_backups_per_file' must be an int, "
639 f"got {type(max_backups).__name__}"
640 )
641 elif max_backups < 0:
642 errors.append(
643 "'formatter.max_backups_per_file' must be >= 0, "
644 f"got {max_backups}"
645 )
646
647 # Phase 4E - G-E8: validate the optional git section (blueprint §13.1).
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__}")
652 else:
653 _GIT_VALID_PROFILES = ("proto", "compact", "strict", "auto")
654
655 pb = git_cfg.get("protected_branches")
656 if pb is not None:
657 if not isinstance(pb, list):
658 errors.append(f"'git.protected_branches' must be a list, got {type(pb).__name__}")
659 else:
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__}")
663
664 bn = git_cfg.get("branch_naming")
665 if bn is not None:
666 if not isinstance(bn, dict):
667 errors.append(f"'git.branch_naming' must be an object, got {type(bn).__name__}")
668 else:
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__}")
676 else:
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__}")
680
681 pcp = git_cfg.get("pre_commit_profile")
682 if pcp is not None:
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)})")
687
688 for bool_key in (
689 "conventional_commits", "require_changelog_update",
690 "auto_branch_on_protected", "add_post_check",
691 ):
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__}")
695
696 sds = git_cfg.get("status_default_scope")
697 if sds is not None:
698 if not isinstance(sds, str):
699 errors.append(
700 f"'git.status_default_scope' must be a string, "
701 f"got {type(sds).__name__}"
702 )
703 elif sds not in ("project", "repo", "auto"):
704 errors.append(
705 f"'git.status_default_scope' unknown value "
706 f"'{sds}' (valid: project, repo, auto)"
707 )
708
709 oum = git_cfg.get("observe_unstaged_mutation")
710 if oum is not None:
711 if isinstance(oum, bool):
712 pass # bool form: False == "off", True == "warn"
713 elif isinstance(oum, str):
714 if oum not in ("off", "warn", "block"):
715 errors.append(
716 f"'git.observe_unstaged_mutation' unknown "
717 f"value '{oum}' (valid: off, warn, block)"
718 )
719 else:
720 errors.append(
721 f"'git.observe_unstaged_mutation' must be a "
722 f"bool or string, got {type(oum).__name__}"
723 )
724
725 ids = git_cfg.get("ignore_default_scope")
726 if ids is not None:
727 if not isinstance(ids, str):
728 errors.append(
729 f"'git.ignore_default_scope' must be a string, "
730 f"got {type(ids).__name__}"
731 )
732 elif ids not in ("workspace", "project", "nearest"):
733 errors.append(
734 f"'git.ignore_default_scope' unknown value "
735 f"'{ids}' (valid: workspace, project, nearest)"
736 )
737
738 for num_key in ("large_file_warn_mb", "timeout", "long_timeout"):
739 val = git_cfg.get(num_key)
740 if val is not None:
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__}")
743 elif val <= 0:
744 errors.append(f"'git.{num_key}' must be > 0, got {val}")
745
746 # FS-507: validate the optional secret_scanner section.
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__}")
751 else:
752 et = scanner_cfg.get("code_entropy_threshold")
753 if et is not None:
754 if not isinstance(et, (int, float)) or isinstance(et, bool):
755 errors.append(
756 f"'secret_scanner.code_entropy_threshold' must be a number, got {type(et).__name__}"
757 )
758 elif et < 0:
759 errors.append(
760 f"'secret_scanner.code_entropy_threshold' must be >= 0, got {et}"
761 )
762 for key in ("docstring_entropy_threshold", "comment_entropy_threshold"):
763 val = scanner_cfg.get(key)
764 if val is not None:
765 if not isinstance(val, (int, float)) or isinstance(val, bool):
766 errors.append(
767 f"'secret_scanner.{key}' must be a number, got {type(val).__name__}"
768 )
769 elif val < 0:
770 errors.append(
771 f"'secret_scanner.{key}' must be >= 0, got {val}"
772 )
773
774 # OI-540: validate entropy_exclusions array.
775 excl = scanner_cfg.get("entropy_exclusions")
776 if excl is not None:
777 if not isinstance(excl, list):
778 errors.append("'secret_scanner.entropy_exclusions' must be an array")
779 else:
780 for i, entry in enumerate(excl):
781 if not isinstance(entry, dict):
782 errors.append(
783 f"'secret_scanner.entropy_exclusions[{i}]' must be an object"
784 )
785 continue
786 ctx_val = entry.get("context")
787 if ctx_val not in ("namedtuple_field", "test_directory", "module_self_doc"):
788 errors.append(
789 f"'secret_scanner.entropy_exclusions[{i}].context' "
790 f"must be 'namedtuple_field', 'test_directory', or 'module_self_doc'"
791 )
792 if ctx_val == "namedtuple_field":
793 if not isinstance(entry.get("field_name_pattern"), str):
794 errors.append(
795 f"'secret_scanner.entropy_exclusions[{i}].field_name_pattern' "
796 f"must be a string"
797 )
798 elif ctx_val in ("test_directory", "module_self_doc"):
799 if not isinstance(entry.get("path_pattern"), str):
800 errors.append(
801 f"'secret_scanner.entropy_exclusions[{i}].path_pattern' "
802 f"must be a string"
803 )
804 restrictions = entry.get("restrictions", {})
805 if not isinstance(restrictions, dict):
806 errors.append(
807 f"'secret_scanner.entropy_exclusions[{i}].restrictions' "
808 f"must be an object"
809 )
810
811 return errors
812
813
814# ---------------------------------------------------------------------
815# Header constants
816# ---------------------------------------------------------------------
817
818SHEBANG = "#!/usr/bin/env python3"
819ENCODING = "# -*- coding: utf-8 -*-"
820
821
822# ---------------------------------------------------------------------
823# LinterContext
824# ---------------------------------------------------------------------
825
826
827@dataclass
828class LinterContext:
829 """
830 Immutable per-run context for the linter. Replaces module-level globals
831 so that concurrent linter invocations do not share mutable state.
832 """
833
834 project_root: Path
835 project_name: str
836 diagnostics_dir: Path
837 tests_dir: Path
838 docs_dir: Path
839 log_handle: Any = None
840 log_path: Path | None = None
841 log_lock: threading.Lock = field(default_factory=threading.Lock)
842 # OI-510 / FS-516: one-shot test discovery index. ``name_stems`` is the
843 # set of module stems for which a ``test_<stem>.py`` file exists;
844 # ``imported_stems`` is the set of module stems imported (whole-word)
845 # by any file in tests_dir. ``None`` means "not yet built" - lazy path.
846 test_index: dict[str, set[str]] | None = None
847 # OI-529: secret-name matching strategy. "substring" (default) matches
848 # the legacy behaviour; "word" splits names on _/-/camelCase and only
849 # flags whole-segment hits, eliminating false positives like "compass"
850 # or "tokenizer".
851 # OCT-LINT: disable=no_hardcoded_secrets reason="Field name; documents the matcher mode, not a credential"
852 secret_match_mode: str = "substring"
853 # FS-507: Shannon entropy threshold for string literals. Default 4.5
854 # bits/char. Set to 0 to disable entropy checking.
855 code_entropy_threshold: float = 4.5
856 # Per-context entropy thresholds (docstrings use strict >, code uses >=).
857 docstring_entropy_threshold: float = 5.0
858 comment_entropy_threshold: float = 5.0
859 # OI-540: entropy exclusions from octrc.
860 entropy_exclusions: list = field(default_factory=list)
861 excluded_field_patterns: list = field(default_factory=list)
862 # FS-C4 / Drift item C4: name of the active profile (proto / compact /
863 # strict). Used to resolve per-rule severity (advisory vs blocking)
864 # via :func:`oct.linter.rule_registry.get_severity`.
865 profile_name: str = "strict"
866 # Plan 268: validated ``(prefix, {rule: bool})`` pairs from the
867 # project's ``linter.path_rules``, produced by
868 # :func:`parse_path_rules` and applied per file by
869 # :func:`effective_rules`. Empty means "no path scoping", which is
870 # every project that does not ask for it.
871 path_rules: list = field(default_factory=list)
872 # FS-510: incremental lint cache. None means cache disabled.
873 lint_cache: Any = None
874 no_cache_read: bool = False
875 # dependencies_match: the filesystem probes of
876 # :mod:`oct.deps.dependency_check`, memoised for this run only, so a
877 # long-lived process sees files created between runs.
878 dependency_memo: dict = field(default_factory=dict)
879
880
881# ---------------------------------------------------------------------
882# Logging helpers
883# ---------------------------------------------------------------------
884
885
886def log(message: str, ctx: LinterContext | None = None) -> None:
887 """
888 Write a single line to the linter log file.
889
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.
893 """
894 if ctx is None or ctx.log_handle is None:
895 return
896 with ctx.log_lock:
897 ctx.log_handle.write(message + "\n")
898
899
900def print_header(title: str, ctx: LinterContext | None = None) -> None:
901 """
902 Write a section header to the log.
903 """
904 log("", ctx)
905 log("=" * 70, ctx)
906 log(title, ctx)
907 log("=" * 70, ctx)
908
909
910def report(result: bool, message: str, fix_mode: bool = False,
911 ctx: LinterContext | None = None) -> bool:
912 """
913 Log a PASS/FAIL (or FIXED) line and return the result.
914 """
915 if fix_mode and not result:
916 status = "FIXED"
917 else:
918 status = "PASS" if result else "FAIL"
919 log(f"[{status}] {message}", ctx)
920 return result
921
922
923# ---------------------------------------------------------------------
924# Utility helpers
925# ---------------------------------------------------------------------
926
927
928def is_excluded_dir(path: Path, exclude_dirs: set, wildcard_exclude_dirs: list) -> bool:
929 """
930 Return True if the directory name should be excluded from scanning.
931 """
932 name = path.name
933 if name in exclude_dirs:
934 return True
935 for pattern in wildcard_exclude_dirs:
936 if pattern in name:
937 return True
938 return False
939
940
941def read_file(path: Path, ctx: LinterContext | None = None) -> str:
942 """
943 Read a file as UTF-8, returning an empty string on failure.
944 """
945 try:
946 return path.read_text(encoding="utf-8")
947 except (OSError, UnicodeDecodeError) as e:
948 if ctx is not None:
949 log(f"[WARN] Could not read {path}: {e}", ctx)
950 return ""
951
952
953def _git_changed_files(root: Path) -> list[Path]:
954 """Return .py files modified in the git working tree (staged + unstaged).
955
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.
961 """
962 from oct.core import git as core_git
963 try:
964 return core_git.git_changed_files(root, extensions={".py"})
965 except core_git.GitError:
966 return []
967
968
969def find_python_files(
970 root: Path,
971 exclude_dirs: set | None = None,
972 wildcard_exclude_dirs: list | None = None,
973) -> Iterator[Path]:
974 """
975 Yield all Python files under root, excluding the linter itself and
976 excluded directories.
977
978 Parameters
979 ----------
980 root:
981 The project root to scan.
982 exclude_dirs:
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``.
988 """
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"):
994 # Skip files inside OCT's own package directory
995 try:
996 if path.resolve().is_relative_to(_OCT_PACKAGE_DIR):
997 continue
998 except (OSError, ValueError):
999 pass
1000 # Plan 282: the exclusion answers a question about DIRECTORIES, so
1001 # it is asked of the directory parts ONLY. ``parts`` of a file path
1002 # ends with the filename, and asking it there dropped every module
1003 # whose NAME contained an excluded substring - ``venv_sitecustomize.py``,
1004 # ``lint_cache.py``, ``logs.py`` - from the linter, the formatter, the
1005 # dashboard and the quality gate's all-files scope, which is the list
1006 # its secrets pass scans. The predicate itself is unchanged.
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):
1009 continue
1010 yield path
1011
1012
1013# ---------------------------------------------------------------------
1014# Plan 213 / D-D - the lens for a file is a function of the FILE
1015# ---------------------------------------------------------------------
1016
1017
1018def _load_workspace_for(root: Path):
1019 """Return ``(workspace, is_workspace_root)`` for *root*, or ``(None, False)``.
1020
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``
1024 from linting.
1025 """
1026 try:
1027 from oct.core.workspace import find_workspace_root, load_workspace
1028 except ImportError:
1029 return None, False
1030 ws_path = find_workspace_root(root)
1031 if ws_path is None:
1032 return None, False
1033 try:
1034 workspace = load_workspace(ws_path)
1035 except Exception:
1036 return None, False
1037 return workspace, ws_path.resolve() == root.resolve()
1038
1039
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.
1044
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
1052 lens.
1053
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.
1058
1059 The returned mapping partitions *files*: every input path appears
1060 under exactly one root.
1061 """
1062 from oct.core.workspace import assign_to_subproject
1063
1064 groups: dict[Path, list[Path]] = {}
1065 for path in files:
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)
1069 return groups
1070
1071
1072def _linter_flag_argv(args) -> list[str]:
1073 """Rebuild the non-positional argv from parsed *args*.
1074
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
1079 positional targets.
1080 """
1081 flags: list[str] = []
1082 if args.fix_headers:
1083 flags.append("--fix-headers")
1084 if args.dry_run:
1085 flags.append("--dry-run")
1086 if args.json:
1087 flags.append("--json")
1088 if args.jobs != 1:
1089 flags.extend(["--jobs", str(args.jobs)])
1090 if args.profile:
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:
1095 flags.extend(
1096 ["--code-entropy-threshold", str(args.entropy_threshold)]
1097 )
1098 if args.no_entropy:
1099 flags.append("--no-entropy")
1100 if args.no_cache:
1101 flags.append("--no-cache")
1102 if args.baseline:
1103 flags.append("--baseline")
1104 if args.against_baseline:
1105 flags.append("--against-baseline")
1106 return flags
1107
1108
1109def _expand_lint_targets(
1110 targets: list[str], exclude_dirs, wildcard_exclude,
1111) -> list[Path]:
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":
1117 files.append(path)
1118 elif path.is_dir():
1119 files.extend(
1120 find_python_files(path, exclude_dirs, wildcard_exclude)
1121 )
1122 return list(dict.fromkeys(files))
1123
1124
1125# ---------------------------------------------------------------------
1126# Project-level requirements
1127# ---------------------------------------------------------------------
1128
1129
1130def project_required_docs(ctx: LinterContext) -> list[Path]:
1131 """Return the project-level documents ``oct lint`` requires for *ctx*.
1132
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.
1142
1143 ``ctx.diagnostics_dir`` is untouched: it still drives the legacy
1144 ``diagnostics/`` deprecation warning in :func:`run_linter`.
1145 """
1146 return [
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),
1151 ]
1152
1153
1154# ---------------------------------------------------------------------
1155# Header validation and fixing
1156# ---------------------------------------------------------------------
1157
1158
1159def expected_path_header(path: Path, ctx: LinterContext) -> str:
1160 """
1161 Compute the expected file identity header for a given module path.
1162 """
1163 rel = to_project_relative(path, ctx.project_root)
1164 return f"# {rel}"
1165
1166
1167def validate_header_block(
1168 lines: list[str], path: Path, ctx: LinterContext,
1169) -> tuple[bool, list[str]]:
1170 """
1171 Validate the mandatory 4-line header block.
1172
1173 Returns (ok, errors) where:
1174 - ok = True if header block is correct
1175 - errors = list of error messages
1176 """
1177 errors: list[str] = []
1178
1179 if len(lines) < 4:
1180 errors.append("File too short to contain mandatory header block")
1181 return False, errors
1182
1183 if lines[0].strip() != SHEBANG:
1184 errors.append("Shebang missing or incorrect")
1185
1186 if lines[1].strip() != ENCODING:
1187 errors.append("Encoding line missing or incorrect")
1188
1189 if lines[2].strip() != expected_path_header(path, ctx):
1190 errors.append("File identity header missing or incorrect")
1191
1192 if lines[3].strip() != "":
1193 errors.append("Missing mandatory blank line after header block")
1194
1195 return len(errors) == 0, errors
1196
1197
1198def _header_boundary(lines: list[str], path: Path, ctx: LinterContext) -> int:
1199 """
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
1203 one implementation.
1204 """
1205 from oct.core.parsing import header_boundary
1206 return header_boundary(
1207 lines, path, expected_identity=expected_path_header(path, ctx),
1208 )
1209
1210
1211def fix_header_block(path: Path, text: str, ctx: LinterContext) -> bool:
1212 """
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.
1215 """
1216 lines = text.splitlines()
1217
1218 # Archive original
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)
1224
1225 boundary = _header_boundary(lines, path, ctx)
1226 content = lines[boundary:]
1227
1228 new_header = [
1229 SHEBANG,
1230 ENCODING,
1231 expected_path_header(path, ctx),
1232 "",
1233 ]
1234
1235 new_text = "\n".join(new_header + content)
1236 path.write_text(new_text, encoding="utf-8")
1237 return True
1238
1239
1240def preview_header_fix(path: Path, ctx: LinterContext) -> str:
1241 """
1242 Compute what fix_header_block would produce, without writing.
1243 Returns the corrected 4-line header block as a string.
1244 """
1245 new_header = [
1246 SHEBANG,
1247 ENCODING,
1248 expected_path_header(path, ctx),
1249 "",
1250 ]
1251 return "\n".join(new_header)
1252
1253
1254# ---------------------------------------------------------------------
1255# Other rule checks
1256# ---------------------------------------------------------------------
1257
1258
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.
1263
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).
1270
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.
1274
1275 Returns ``(docstring, "")`` or ``(None, message)``. *tree*, when given,
1276 must be the parse of *text*; it spares both parses below.
1277 """
1278 doc = _extract_module_docstring(text, tree)
1279 if doc is not None:
1280 return doc, ""
1281 if tree is None:
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")
1287
1288
1289def check_docstring(text: str, tree: ast.Module | None = None) -> tuple[bool, str]:
1290 """
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.
1294 """
1295 doc, why = _docstring_for_rule(text, tree)
1296 if doc is None:
1297 return False, why
1298 required = ["Purpose", "Responsibilities", "Diagnostics", "Contracts"]
1299 missing = [s for s in required if s not in doc]
1300 if missing:
1301 return False, f"Missing sections: {', '.join(missing)}"
1302 return True, ""
1303
1304
1305_DEP_SECTION_RE = re.compile(
1306 r'Dependencies\s*\n\s*[-]+\s*\n(.*?)(?:\n\s*\n|\Z)',
1307 re.DOTALL,
1308)
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)
1311
1312
1313def check_dependencies_section(
1314 text: str, tree: ast.Module | None = None,
1315) -> tuple[bool, str]:
1316 """
1317 Validate the Dependencies section of a module docstring at strict profile.
1318
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.
1322
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.
1328 """
1329 doc, why = _docstring_for_rule(text, tree)
1330 if doc is None:
1331 return False, why
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)
1339 if not bullets:
1340 return True, ""
1341 valid_lines = _DEP_LINE_RE.findall(section)
1342 if len(valid_lines) != len(bullets):
1343 return False, (
1344 "Malformed Dependencies entry; expected '- module_name (purpose)'"
1345 )
1346 return True, ""
1347
1348
1349#: Line breaks exactly as the Python parser sees them. ``str.splitlines``
1350#: also splits on form feed, vertical tab and U+2028, which would desynchronise
1351#: line numbers from ``ast``'s.
1352_SOURCE_LINE_BREAK_RE = re.compile(r'\r\n|\r|\n')
1353
1354#: The ``r``/``u`` prefix a string literal may carry before its quotes.
1355_STRING_PREFIX_RE = re.compile(r'[A-Za-z]*')
1356
1357
1358class _DocstringSlice(NamedTuple):
1359 """Where the module docstring is, in SOURCE-TEXT terms.
1360
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.
1368 """
1369
1370 text: str
1371 open_index: int
1372 close_index: int
1373 quote: str
1374 prefix: str
1375
1376
1377def _source_offset(
1378 text: str, line_starts: list[int], lineno: int, col_offset: int,
1379) -> int | None:
1380 """
1381 Convert an AST ``(lineno, col_offset)`` pair into an offset into *text*.
1382
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.
1387 """
1388 if lineno < 1 or lineno > len(line_starts):
1389 return None
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]
1393 if line.isascii():
1394 return line_start + col_offset if col_offset <= len(line) else None
1395 try:
1396 prefix = line.encode("utf-8")[:col_offset].decode("utf-8")
1397 except UnicodeDecodeError:
1398 return None
1399 return line_start + len(prefix)
1400
1401
1402def _module_docstring_slice(
1403 text: str, tree: ast.Module | None = None,
1404) -> _DocstringSlice | None:
1405 """
1406 Locate the module docstring structurally, and report it as SOURCE TEXT.
1407
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.
1413
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).
1420
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``.
1426
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.
1432 """
1433 if tree is None:
1434 try:
1435 tree = ast.parse(text)
1436 except (SyntaxError, ValueError):
1437 return None
1438 if not tree.body:
1439 return None
1440 node = tree.body[0]
1441 if not isinstance(node, ast.Expr):
1442 return None
1443 literal = node.value
1444 if not isinstance(literal, ast.Constant) or not isinstance(literal.value, str):
1445 return None
1446 if node.end_lineno is None or node.end_col_offset is None:
1447 return None
1448
1449 line_starts = [0]
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):
1454 return None
1455
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"}:
1460 return None
1461 body = segment[len(prefix):]
1462 quote = next(
1463 (q for q in ('"""', "'''", '"', "'") if body.startswith(q)), None,
1464 )
1465 if quote is None or len(body) < 2 * len(quote) or not body.endswith(quote):
1466 return None
1467 inner = body[len(quote):-len(quote)]
1468 if quote in inner:
1469 # Implicitly concatenated literals ("a" "b") are one docstring to
1470 # Python but not one slice of source; there is no single span to
1471 # hand the formatter, so report no docstring rather than a wrong one.
1472 return None
1473 try:
1474 if ast.literal_eval(segment) != literal.value:
1475 return None
1476 except (SyntaxError, ValueError):
1477 return None
1478
1479 open_index = begin + len(prefix) + len(quote)
1480 return _DocstringSlice(
1481 inner, open_index, open_index + len(inner), quote, prefix,
1482 )
1483
1484
1485def _extract_module_docstring(
1486 text: str, tree: ast.Module | None = None,
1487) -> str | None:
1488 """
1489 Return the module docstring as it is written in the source, or ``None``.
1490
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
1495 to it unchanged.
1496 """
1497 located = _module_docstring_slice(text, tree)
1498 return None if located is None else located.text
1499
1500
1501def check_diagnostics_profile(
1502 text: str, tree: ast.Module | None = None,
1503) -> tuple[bool, str]:
1504 """
1505 Validate that a Diagnostics profile is present in the module docstring.
1506
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
1509 satisfy the rule.
1510
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.
1519 """
1520 docstring = _extract_module_docstring(text, tree)
1521 if docstring is None:
1522 return False, "Missing Diagnostics section in module docstring"
1523 scope = 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"
1530 return True, ""
1531
1532
1533def check_dbg_usage(text: str, tree: ast.Module | None = None) -> tuple[bool, str]:
1534 """
1535 Validate that non-trivial modules call ``_dbg()`` for diagnostics.
1536
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.
1540
1541 Uses AST parsing to detect actual ``_dbg()`` call nodes, avoiding
1542 false positives from comments or string literals.
1543 """
1544 if len(text.splitlines()) < 20:
1545 return True, ""
1546 if tree is None:
1547 tree, _ = safe_parse(text)
1548 if tree is None:
1549 return True, ""
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"):
1554 return True, ""
1555 if (isinstance(node, ast.Call)
1556 and isinstance(node.func, ast.Attribute)
1557 and node.func.attr == "_dbg"):
1558 return True, ""
1559 return False, "Missing _dbg usage"
1560
1561
1562#: The modules a ``_dbg`` import may name. ``oc_diagnostics`` is the
1563#: canonical source for every Option C project. The second entry exists
1564#: because 263 resolved a contradiction inside oct itself: oct declares
1565#: no dependency on the diagnostics package, so it re-exports ``_dbg``
1566#: through one fail-soft module of its own
1567#: (``oct/core/diagnostics.py``, which degrades to a signature-identical
1568#: no-op when the package is absent), and oct's module contract requires
1569#: every oct module to import from there rather than duplicate the
1570#: try/except stub. The rule accepted only the direct import, so the
1571#: contract and the rule contradicted each other and 19 modules that
1572#: obeyed the contract failed the rule. A package that must run without
1573#: the diagnostics runtime may name one such re-export module here; the
1574#: list is exact - no prefix, alias or wildcard is honoured.
1575_DBG_IMPORT_SOURCES = ("oc_diagnostics", "oct.core.diagnostics")
1576
1577
1578def check_dbg_import(text: str, tree: ast.Module | None = None) -> tuple[bool, str]:
1579 """
1580 Verify that ``_dbg`` is imported at module level from an accepted source.
1581
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.
1589 """
1590 if len(text.splitlines()) < 20:
1591 return True, ""
1592 if tree is None:
1593 tree, _ = safe_parse(text)
1594 if tree is None:
1595 return True, ""
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)):
1601 return True, ""
1602 return False, (
1603 "Missing `from oc_diagnostics import _dbg` "
1604 "(or `from oct.core.diagnostics import _dbg` within oct)"
1605 )
1606
1607
1608def _walk_function_body(
1609 func_node: ast.FunctionDef | ast.AsyncFunctionDef,
1610) -> Iterator[ast.AST]:
1611 """
1612 Yield AST nodes within a function body, stopping at nested function
1613 boundaries so that each function gets an independent check.
1614 """
1615 for node in ast.walk(func_node):
1616 if node is func_node:
1617 continue
1618 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1619 # Don't descend into nested functions; they are checked separately
1620 continue
1621 yield node
1622
1623
1624def _direct_body_nodes(func_node: ast.AST) -> Iterator[ast.AST]:
1625 """
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.
1629
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.
1635 """
1636 stack = [
1637 stmt for stmt in func_node.body
1638 if not isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef))
1639 ]
1640 while stack:
1641 node = stack.pop()
1642 yield node
1643 for child in ast.iter_child_nodes(node):
1644 if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
1645 continue
1646 stack.append(child)
1647
1648
1649def find_func_pattern_violations(
1650 text: str,
1651 tree: ast.Module | None = None,
1652) -> list[tuple[str, int]]:
1653 """
1654 Return ``[(func_name, lineno), ...]`` for every function that calls
1655 ``_dbg()`` without a prior ``func = "..."`` assignment.
1656
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.
1664
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).
1669 """
1670 lines = text.splitlines()
1671 if len(lines) < 20:
1672 return []
1673
1674 if tree is None:
1675 tree, _ = safe_parse(text)
1676 if tree is None:
1677 return []
1678
1679 violations: list[tuple[str, int]] = []
1680
1681 func_nodes = [
1682 node for node in ast.walk(tree)
1683 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
1684 ]
1685
1686 for func_node in func_nodes:
1687 params = func_node.args
1688 has_func_var = any(
1689 a.arg == "func"
1690 for a in (*params.posonlyargs, *params.args, *params.kwonlyargs)
1691 )
1692 has_dbg_call = False
1693
1694 # Check for func = <value> in first 3 non-docstring statements
1695 non_doc_seen = 0
1696 for stmt in func_node.body:
1697 if has_func_var:
1698 break
1699 if (isinstance(stmt, ast.Expr)
1700 and isinstance(stmt.value, ast.Constant)
1701 and isinstance(stmt.value.value, str)):
1702 continue
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"):
1707 has_func_var = True
1708 break
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"):
1713 has_func_var = True
1714 break
1715 non_doc_seen += 1
1716 if non_doc_seen >= 3:
1717 break
1718
1719 # A function that sets ``func`` cannot be a finding, so its body is
1720 # not walked at all, and the walk stops at the first ``_dbg`` call
1721 # (2026-09-28: the full walk of every body was most of this rule's
1722 # cost, and this rule was the costliest in lint, the gate and health).
1723 if has_func_var:
1724 continue
1725
1726 # Check for _dbg() anywhere in direct body (Name or Attribute form)
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")
1732 ):
1733 has_dbg_call = True
1734 break
1735
1736 if has_dbg_call:
1737 violations.append((func_node.name, func_node.lineno))
1738
1739 return violations
1740
1741
1742def check_func_pattern(text: str, tree: ast.Module | None = None) -> tuple[bool, str]:
1743 """
1744 Validate that every function body containing ``_dbg()`` first defines
1745 ``func = "function_name"``.
1746
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.
1750
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.
1754 """
1755 lines = text.splitlines()
1756 if len(lines) < 20:
1757 return True, "trivial"
1758
1759 if tree is None:
1760 tree, _ = safe_parse(text)
1761 if tree is None:
1762 return True, "syntax error; skipped"
1763
1764 violations = find_func_pattern_violations(text, tree=tree)
1765 if violations:
1766 names = ", ".join(f"{name}()" for name, _ in violations)
1767 return False, f'Missing `func = "..."` before `_dbg()` in: {names}'
1768 return True, ""
1769
1770
1771# OI-514: secret-scanning logic lives in :mod:`oct.tools.secret_scanner`.
1772# The symbols are re-exported below so any external callers that import
1773# them from ``oct.linter.oct_lint`` (historical location) keep working.
1774from oct.tools.secret_scanner import ( # noqa: E402 (re-export)
1775 _SECRET_NAME_PATTERNS,
1776 _is_string_like,
1777 _is_string_literal,
1778 _name_matches_secret,
1779 check_no_hardcoded_secrets,
1780)
1781
1782
1783def check_syntax_warnings(
1784 text: str, warnings_list: list[str] | None = None,
1785) -> tuple[bool, str]:
1786 """
1787 Check for Python syntax warnings (invalid escape sequences etc.).
1788
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.
1792 """
1793 if warnings_list is None:
1794 _, warnings_list = safe_parse(text)
1795 if warnings_list:
1796 return False, f"{len(warnings_list)} syntax warning(s): {'; '.join(warnings_list)}"
1797 return True, ""
1798
1799
1800# F21 / Plan 170: shared message for every AST-dependent check
1801# (dbg_usage, dbg_import, type_hints, dbg_assert_safety, syntax_warnings)
1802# when the file failed to parse. Distinguishes "not run because the file
1803# is unparseable" from a genuine "OK" - the vacuous-PASS shape F21 found.
1804_AST_SKIP_MSG = "NOT-RUN (syntax error prevents AST parsing)"
1805
1806
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.
1810
1811 ``safe_parse()`` collapses every parse failure to ``(None, [])`` -
1812 this is the one place that re-raises to read the actual message.
1813 """
1814 try:
1815 ast.parse(text)
1816 except SyntaxError as e:
1817 # Some messages already carry a line reference (e.g. "unterminated
1818 # string literal (detected at line N)"); only append one when the
1819 # message doesn't already have it.
1820 if e.lineno is not None and "line" not in e.msg.lower():
1821 return f"{e.msg} (line {e.lineno})"
1822 return e.msg
1823 except (ValueError, RecursionError) as e:
1824 # ast.parse() also documents these for null bytes / excessive
1825 # nesting; still "unparseable", still must block.
1826 return str(e)
1827 # Unreachable in practice (safe_parse() only returns tree=None on one
1828 # of the exceptions above) but never claim a pass we can't explain.
1829 return "unknown parse failure"
1830
1831
1832def check_type_hints(
1833 text: str, tree: ast.Module | None = None, compact_mode: bool = False,
1834) -> tuple[bool, str]:
1835 """
1836 Validate that public functions have type annotations on all parameters
1837 and a return annotation.
1838
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.
1843
1844 Trivial files (< 20 lines) are exempt.
1845 """
1846 if len(text.splitlines()) < 20:
1847 return True, ""
1848 if tree is None:
1849 tree, _ = safe_parse(text)
1850 if tree is None:
1851 return True, "syntax error; skipped"
1852
1853 violations: list[str] = []
1854
1855 for node in ast.walk(tree):
1856 if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1857 continue
1858 # Skip dunder methods (always exempt)
1859 if node.name.startswith("__") and node.name.endswith("__"):
1860 continue
1861 # Private functions (single leading underscore) are exempt from type hint
1862 # requirements in all profiles - internal implementation details (OI-417).
1863 if node.name.startswith("_"):
1864 continue
1865
1866 # Check parameters (skip 'self' and 'cls')
1867 missing_params = []
1868 for arg in node.args.args:
1869 if arg.arg in ("self", "cls"):
1870 continue
1871 if arg.annotation is None:
1872 missing_params.append(arg.arg)
1873
1874 # Check return annotation
1875 missing_return = node.returns is None
1876
1877 if missing_params or missing_return:
1878 parts = []
1879 if missing_params:
1880 parts.append(f"params: {', '.join(missing_params)}")
1881 if missing_return:
1882 parts.append("return type")
1883 violations.append(f"{node.name}() missing {'; '.join(parts)}")
1884
1885 if violations:
1886 return False, f"Type hints: {'; '.join(violations[:5])}"
1887 return True, ""
1888
1889
1890# Keywords that mark a function/class/module as "security-sensitive" for
1891# the purposes of OI-403. A ``_dbg_assert`` call inside any such context is
1892# flagged because ``_dbg_assert`` is a no-op in production (AP-16) and must
1893# never be relied upon for safety invariants.
1894_SECURITY_SENSITIVE_KEYWORDS = frozenset({
1895 "auth", "authenticate", "authorize", "permission", "security",
1896 "validate", "verify", "credential", "login", "session", "sanitize",
1897 "encrypt", "decrypt", "password", "token",
1898})
1899
1900
1901def _is_security_sensitive_name(name: str) -> bool:
1902 lowered = name.lower()
1903 return any(kw in lowered for kw in _SECURITY_SENSITIVE_KEYWORDS)
1904
1905
1906def check_dbg_assert_safety(
1907 text: str, tree: ast.Module | None = None, filename: str = ""
1908) -> tuple[bool, str]:
1909 """
1910 Flag ``_dbg_assert`` calls made inside security-sensitive contexts.
1911
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.
1918 """
1919 if tree is None:
1920 tree, _ = safe_parse(text)
1921 if tree is None:
1922 return True, "syntax error; skipped"
1923
1924 # Is the module itself security-sensitive by filename?
1925 module_sensitive = False
1926 if filename:
1927 stem = Path(filename).stem
1928 if _is_security_sensitive_name(stem):
1929 module_sensitive = True
1930
1931 violations: list[str] = []
1932
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)
1938 return
1939
1940 if isinstance(node, ast.Call):
1941 func = node.func
1942 call_name = ""
1943 if isinstance(func, ast.Name):
1944 call_name = func.id
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
1952 break
1953 if sensitive_scope is None and module_sensitive:
1954 sensitive_scope = f"module '{Path(filename).stem}'"
1955 if sensitive_scope is not None:
1956 violations.append(
1957 f"L{node.lineno}: _dbg_assert used in security-sensitive "
1958 f"context '{sensitive_scope}' - use a hard assertion (AP-16)"
1959 )
1960
1961 for child in ast.iter_child_nodes(node):
1962 _walk(child, scopes)
1963
1964 _walk(tree, [])
1965
1966 if violations:
1967 return False, "; ".join(violations[:5])
1968 return True, ""
1969
1970
1971# ---- Inline waiver parsing ----
1972# OI-540: parse_inline_waivers() moved to oct.core.waivers (imported above).
1973
1974
1975_IMPORT_STMT_RE = re.compile(
1976 r'(?:^|\s)(?:import|from)\s+([a-zA-Z_][a-zA-Z0-9_]*)',
1977 re.MULTILINE,
1978)
1979
1980
1981def build_test_index(ctx: LinterContext) -> dict[str, set[str]]:
1982 """
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.
1986
1987 The index is cached on :attr:`LinterContext.test_index` - subsequent
1988 calls return the cached value directly.
1989
1990 Returns a dict with:
1991
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.
1995 """
1996 if ctx.test_index is not None:
1997 return ctx.test_index
1998
1999 name_stems: set[str] = set()
2000 imported_stems: set[str] = set()
2001
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_"):])
2007 try:
2008 text = test_file.read_text(encoding="utf-8")
2009 except (OSError, UnicodeDecodeError):
2010 continue
2011 for m in _IMPORT_STMT_RE.finditer(text):
2012 imported_stems.add(m.group(1))
2013
2014 ctx.test_index = {
2015 "name_stems": name_stems,
2016 "imported_stems": imported_stems,
2017 }
2018 return ctx.test_index
2019
2020
2021def check_tests_exist(module_path: Path, ctx: LinterContext) -> bool:
2022 """
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>``).
2026
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`.
2029 """
2030 index = build_test_index(ctx)
2031 stem = module_path.stem
2032 return stem in index["name_stems"] or stem in index["imported_stems"]
2033
2034
2035# ---------------------------------------------------------------------
2036# Terminal color helpers
2037# ---------------------------------------------------------------------
2038
2039from oct.core.terminal import CYAN, GREEN, RED, RESET
2040from oct.core.diagnostics import _dbg
2041
2042
2043_MAX_LINT_LOG_FILES = 10
2044
2045
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]:
2051 try:
2052 old.unlink()
2053 except OSError:
2054 pass
2055
2056
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.
2059
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.
2066 """
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))
2071 detail = ""
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)
2077
2078
2079def _lint_single_file(
2080 path: Path,
2081 ctx: LinterContext,
2082 active_rules: dict,
2083 fix_mode: bool,
2084 dry_run: bool = False,
2085) -> dict:
2086 """Lint one Python file and return a result dict with check outcomes.
2087
2088 Thread-safe: logging uses ctx.log_lock internally.
2089 """
2090 func = "_lint_single_file"
2091 text = read_file(path, ctx)
2092 rel = to_project_relative(path, ctx.project_root)
2093
2094 # Plan 268: the project's path-scoped overrides are the LAST thing to
2095 # move a rule, and they move it for this file only. Rebinding
2096 # ``active_rules`` here is deliberate: every decision below - the
2097 # ``_rule_active`` gate, ``_compact_mode``, and the cache's rules
2098 # hash - then reads the per-file set without a second spelling. The
2099 # returned dict is always a fresh one, so the single ``active_rules``
2100 # the parallel linter shares across threads is never touched.
2101 active_rules, path_rule_changes = effective_rules(
2102 active_rules, rel, ctx.path_rules,
2103 )
2104
2105 log("", ctx)
2106 log(f"--- {rel} ---", ctx)
2107
2108 # Parse inline waivers (# OCT-LINT: disable=<rule>) up front - needed by
2109 # both the cache-hit fast path (Plan 168 fix 1 recompute) and the normal
2110 # full-check path below.
2111 waivers = parse_inline_waivers(text)
2112 # Plan 193 (sec): a waiver no longer switches a LINE-SCOPED rule off
2113 # for the whole file. Those rules' checks always RUN and their
2114 # findings are scoped per line at the call site (see
2115 # ``no_hardcoded_secrets`` below), so one waived false positive can
2116 # no longer blind the file to a real secret. Every other rule keeps
2117 # whole-file waiver semantics - its check reports a verdict, not
2118 # located findings, so there is nothing to scope against. An
2119 # explicit ``disable-file=`` waiver is handled by ``apply_waivers``
2120 # for line-scoped rules and here for the rest.
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
2124 }
2125
2126 # Helper: check if a rule is active (enabled in profile AND not waived)
2127 def _rule_active(rule: str) -> bool:
2128 return active_rules.get(rule, True) and rule not in waived_rules
2129
2130 # FS-510: check lint cache before running checks
2131 _file_hash: str | None = None
2132 _rules_hash: str | None = None
2133 if ctx.lint_cache is not None and not fix_mode:
2134 from oct.linter.lint_cache import content_hash, rules_hash
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)
2142 # FS-510 stale-path fix (Plan 151 T6): the cache is keyed by
2143 # CONTENT hash, so two byte-identical files at different paths
2144 # collide on one entry. json_detail carries the ORIGINAL
2145 # file's ``path`` - rebind it to the CURRENT file so a
2146 # cache-hit never reports a stale path (e.g. two empty
2147 # ``__init__.py`` package markers). Copy first, never mutate
2148 # the shared cached dict.
2149 _detail = dict(cached.json_detail) if cached.json_detail else {}
2150 _detail["path"] = rel
2151 # Plan 268: the changed-rule list is a function of the
2152 # file's PATH, like ``path`` above. Two files can share a
2153 # content hash AND an effective rule set while only one of
2154 # them was moved there by a path entry, so recompute it
2155 # rather than inherit the writer's.
2156 if path_rule_changes:
2157 _detail["path_rules"] = path_rule_changes
2158 else:
2159 _detail.pop("path_rules", None)
2160 result["json_detail"] = _detail
2161 result["fixed"] = False
2162 result["rel"] = str(rel)
2163
2164 # Plan 168 fix 1: ``header``, ``tests_exist`` and
2165 # ``prompt_invariants`` are functions of the file's PATH /
2166 # STEM, not its content - two content-identical files can
2167 # legitimately disagree on them (e.g. "foo.py" has a
2168 # "test_foo.py" but same-content "bar.py" has no
2169 # "test_bar.py"). The path-display rebind above does not
2170 # correct these booleans, so recompute them fresh on every
2171 # cache hit instead of trusting the cached verdict.
2172 # A copy: ``_detail`` above is shallow, so its "checks" is
2173 # still the cached dict every later hit on this content reads.
2174 _detail_checks = dict(_detail.get("checks", {}))
2175
2176 # F21 / Plan 170: "syntax_error" is content-derived (a
2177 # cache hit's content_hash guarantees the same parse
2178 # outcome), but cache entries written by a pre-fix oct
2179 # version carry no "syntax_error" key at all. Recompute
2180 # fresh on every cache hit - cheap (one ast.parse) - so a
2181 # stale disk cache can never resurrect the vacuous-PASS
2182 # shape (F21) for an already-cached broken file.
2183 from oct.linter.rule_registry import SEVERITY_BLOCKING as _SB_CACHE
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)
2188 )
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,
2195 }
2196
2197 if _rule_active("header"):
2198 _lines = text.splitlines()
2199 ok_header_fresh, header_errors_fresh = validate_header_block(
2200 _lines, path, ctx,
2201 )
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,
2207 "message": (
2208 ", ".join(header_errors_fresh)
2209 if header_errors_fresh else "OK"
2210 ),
2211 }
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",
2220 }
2221 if _rule_active("prompt_invariants"):
2222 from oct.linter.prompt_invariant_rules import check_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",
2230 }
2231 # dependencies_match depends on the file's PATH (its own
2232 # dotted name, relative imports) and on which OTHER modules
2233 # exist, neither of which the content-keyed cache sees, so
2234 # it is recomputed on every hit like the path rules above.
2235 if _rule_active("dependencies_match"):
2236 from oct.deps.dependency_check import check_dependencies_match
2237 ok_match_fresh, match_msg_fresh = check_dependencies_match(
2238 path, ctx.project_root, _cache_tree, memo=ctx.dependency_memo,
2239 )
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,
2246 }
2247 _detail["checks"] = _detail_checks
2248
2249 # FS-510 fix: recompute "compliant" - excluded from
2250 # cache storage, must be derived from per-check severity
2251 # in the cached json_detail (FS-C4: advisory failures
2252 # don't break compliance).
2253 from oct.linter.rule_registry import SEVERITY_BLOCKING as _SB
2254 _checks = _detail_checks
2255 if _checks:
2256 result["compliant"] = all(
2257 c.get("pass", True)
2258 for c in _checks.values()
2259 if c.get("severity", _SB) == _SB
2260 )
2261 else:
2262 # Pre-FS-C4 cache entries have no severity field.
2263 # Fall back to the legacy "all booleans must pass"
2264 # rule so we don't suddenly flip results during the
2265 # rollover window.
2266 result["compliant"] = all(
2267 v for k, v in result.items()
2268 if isinstance(v, bool) and k != "fixed"
2269 )
2270 _dbg_rule_outcomes(str(rel), _detail_checks, cached=True)
2271 return result
2272
2273 # Parse AST once and share across all check functions
2274 tree, syntax_warnings_list = safe_parse(text)
2275
2276 # F21 / Plan 170: safe_parse() swallows SyntaxError into (None, []) -
2277 # indistinguishable from "parsed cleanly, zero warnings". Re-parse here
2278 # (broken-file path only) to recover the actual SyntaxError text and
2279 # line number for the syntax_error blocking finding below.
2280 syntax_error_msg = None if tree is not None else _describe_syntax_error(text)
2281
2282 lines = text.splitlines()
2283 header_errors: list[str] = []
2284 # OCT-LINT: disable=no_hardcoded_secrets reason="Variable holds lint output, not a credential"
2285 doc_msg = diag_msg = dbg_msg = dbg_imp_msg = func_msg = secrets_msg = ""
2286 hints_msg = dbg_assert_msg = ""
2287
2288 compact_mode = active_rules.get("_compact_mode", False)
2289
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"
2295 )
2296
2297 # Plan 193 (sec): lines of bare ``disable=`` waivers that suppressed
2298 # findings ONLY via the pre-193 whole-file fallback. Populated by the
2299 # ``no_hardcoded_secrets`` check below and surfaced as the advisory
2300 # ``legacy_file_scoped_waiver`` burn-down finding. Stays empty when
2301 # the rule is inactive - a file whose secret scanning is off has no
2302 # waiver blindness to report.
2303 legacy_waiver_lines: tuple[int, ...] = ()
2304
2305 result: dict = {
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()],
2314 }
2315
2316 # F21 / Plan 170: unconditional - never gated by `_rule_active()` /
2317 # inline waivers. Unparseable Python is never compliant, at any
2318 # profile (see the _DEFAULT_RULES comment on "syntax_error").
2319 ok_syntax_error = tree is not None
2320 result["syntax_error"] = ok_syntax_error
2321 report(
2322 ok_syntax_error,
2323 f"Syntax error: {syntax_error_msg if not ok_syntax_error else 'OK'}",
2324 ctx=ctx,
2325 )
2326
2327 if _rule_active("header"):
2328 ok_header, header_errors = validate_header_block(lines, path, ctx)
2329 if fix_mode and not ok_header:
2330 if dry_run:
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)
2337 if boundary == 0:
2338 log(" (none)", ctx)
2339 else:
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)
2345 else:
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)
2350 else:
2351 result["header"] = ok_header
2352 report(ok_header, f"Header block: {', '.join(header_errors) if header_errors else 'OK'}", ctx=ctx)
2353 else:
2354 ok_header = True
2355 result["header"] = True
2356
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)
2361 else:
2362 ok_doc = True
2363 result["docstring"] = True
2364
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)
2369 else:
2370 ok_deps = True
2371 result["dependencies_section"] = True
2372
2373 # Advisory: the Dependencies section against the module's real imports
2374 # (operator rulings 2026-09-28). Never raises; exempts test modules.
2375 # The docstring comes from *tree*; an unparseable file is "not run".
2376 if _rule_active("dependencies_match"):
2377 from oct.deps.dependency_check import check_dependencies_match
2378 ok_match, match_msg = check_dependencies_match(
2379 path, ctx.project_root, tree, memo=ctx.dependency_memo,
2380 )
2381 result["dependencies_match"] = ok_match
2382 report(ok_match, f"Dependencies match imports: {match_msg}", ctx=ctx)
2383 else:
2384 ok_match, match_msg = True, "OK"
2385 result["dependencies_match"] = True
2386
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)
2391 else:
2392 ok_diag = True
2393 result["diagnostics_profile"] = True
2394
2395 if _rule_active("dbg_usage"):
2396 if tree is None:
2397 ok_dbg, dbg_msg = True, _AST_SKIP_MSG
2398 else:
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)
2402 else:
2403 ok_dbg = True
2404 result["dbg_usage"] = True
2405
2406 if _rule_active("dbg_import"):
2407 if tree is None:
2408 ok_dbg_imp, dbg_imp_msg = True, _AST_SKIP_MSG
2409 else:
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)
2413 else:
2414 ok_dbg_imp = True
2415 result["dbg_import"] = True
2416
2417 if _rule_active("func_pattern"):
2418 if tree is None:
2419 ok_func, func_msg = True, _AST_SKIP_MSG
2420 else:
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)
2424 else:
2425 ok_func = True
2426 result["func_pattern"] = True
2427
2428 if tree is None:
2429 ok_syntax, syntax_msg = True, _AST_SKIP_MSG
2430 else:
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)
2434
2435 if _rule_active("type_hints"):
2436 if tree is None:
2437 ok_hints, hints_msg = True, _AST_SKIP_MSG
2438 else:
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)
2442 else:
2443 ok_hints = True
2444 result["type_hints"] = True
2445
2446 if _rule_active("no_hardcoded_secrets"):
2447 # OI-540: path-based entropy exclusions. Both ``test_directory``
2448 # (test fixtures) and ``module_self_doc`` (source files dominated
2449 # by help-text/templates/scaffold strings) honour the same
2450 # ``exempt_entropy_only`` semantics.
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"):
2458 file_entropy = 0
2459 break
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,
2466 )
2467 _dbg("OCT-LINTER", func,
2468 f"secrets scan {rel}: clean={ok_secrets} "
2469 f"(code entropy threshold {file_entropy})", 3)
2470 if not ok_secrets:
2471 # Plan 193 (sec): scope the file's waivers over the findings
2472 # the scanner just reported instead of dropping the whole
2473 # rule. Detection is untouched - only which findings are
2474 # excused changes, and strictly fewer are.
2475 _waiver_outcome = apply_waivers(
2476 secrets_msg, waivers, "no_hardcoded_secrets",
2477 )
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)
2483 else:
2484 ok_secrets = True
2485 result["no_hardcoded_secrets"] = True
2486
2487 if _rule_active("dbg_assert_safety"):
2488 if tree is None:
2489 ok_dbg_assert, dbg_assert_msg = True, _AST_SKIP_MSG
2490 else:
2491 ok_dbg_assert, dbg_assert_msg = check_dbg_assert_safety(
2492 text, tree=tree, filename=str(path)
2493 )
2494 result["dbg_assert_safety"] = ok_dbg_assert
2495 report(ok_dbg_assert, f"dbg_assert safety: {dbg_assert_msg or 'OK'}", ctx=ctx)
2496 else:
2497 ok_dbg_assert = True
2498 result["dbg_assert_safety"] = True
2499
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)
2504 else:
2505 ok_test = True
2506 result["tests_exist"] = True
2507
2508 if _rule_active("prompt_invariants"):
2509 from oct.linter.prompt_invariant_rules import check_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)
2513 else:
2514 ok_prompt = True
2515 prompt_msg = ""
2516 result["prompt_invariants"] = True
2517
2518 # Plan 193 (sec): the migration burn-down counter. A bare
2519 # ``disable=<rule>`` waiver that is adjacent to none of the findings
2520 # it excuses is relying on pre-193 whole-file semantics; it still
2521 # suppresses (so the ~200 legacy files do not storm the gate in one
2522 # landing) but says so, as an ADVISORY that never blocks. Migrate the
2523 # file - move the waiver onto the offending line, or state whole-file
2524 # scope with ``disable-file=`` - and this clears. When the tree-wide
2525 # count reaches zero the legacy branch in ``apply_waivers`` can be
2526 # deleted as a behavioural no-op.
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 "
2533 "whole-file scope"
2534 )
2535 result["legacy_file_scoped_waiver"] = ok_legacy_waiver
2536
2537 # Log waiver warnings
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)
2542
2543 # FS-C4: per-rule severity lookup. A failed rule classified as
2544 # ``advisory`` is reported but does NOT contribute to ``compliant``.
2545 from oct.linter.rule_registry import get_severity, SEVERITY_BLOCKING
2546 _check_results = {
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,
2563 }
2564 _check_severities = {
2565 rule: get_severity(rule, ctx.profile_name)
2566 for rule in _check_results
2567 }
2568 # ``compliant`` ignores advisory failures so AI agents and CI can
2569 # surface advisory items without flipping the build red.
2570 result["compliant"] = all(
2571 passed
2572 for rule, passed in _check_results.items()
2573 if _check_severities[rule] == SEVERITY_BLOCKING
2574 )
2575 _dbg("OCT-LINTER", func,
2576 f"verdict {rel}: compliant={result['compliant']}", 3)
2577
2578 def _check_block(passed: bool, message: str, rule: str) -> dict:
2579 return {
2580 "pass": passed,
2581 "message": message,
2582 "severity": _check_severities[rule],
2583 }
2584
2585 # JSON-mode detail block
2586 result["json_detail"] = {
2587 "path": rel,
2588 "checks": {
2589 "syntax_error": _check_block(
2590 ok_syntax_error,
2591 syntax_error_msg if not ok_syntax_error else "OK",
2592 "syntax_error",
2593 ),
2594 "header": _check_block(
2595 ok_header or (fix_mode and not ok_header),
2596 ", ".join(header_errors) if header_errors else "OK",
2597 "header",
2598 ),
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",
2604 ),
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"
2617 ),
2618 "legacy_file_scoped_waiver": _check_block(
2619 ok_legacy_waiver,
2620 legacy_waiver_msg or "OK",
2621 "legacy_file_scoped_waiver",
2622 ),
2623 },
2624 "compliant": result["compliant"],
2625 "waivers": result["waivers"],
2626 }
2627 # Plan 268: name the rules a path entry moved for THIS file, and only
2628 # when one did. A project that scopes nothing serialises exactly the
2629 # document it serialised before this plan.
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)
2633
2634 # FS-510: store result in cache
2635 if ctx.lint_cache is not None and not fix_mode and _file_hash and _rules_hash:
2636 from oct.linter.lint_cache import LintCacheEntry
2637 from datetime import datetime as _dt
2638 from oct import __version__ as _ver
2639 cache_entry = LintCacheEntry(
2640 content_hash=_file_hash,
2641 rule_results={
2642 k: v for k, v in result.items()
2643 if k not in ("fixed", "rel", "json_detail", "waivers", "compliant")
2644 },
2645 json_detail=result["json_detail"],
2646 oct_version=_ver,
2647 timestamp=_dt.now().isoformat(),
2648 )
2649 ctx.lint_cache.put(_file_hash, _rules_hash, cache_entry)
2650
2651 return result
2652
2653
2654# ---------------------------------------------------------------------
2655# Main linter entry point
2656# ---------------------------------------------------------------------
2657
2658
2659def _maybe_dispatch_by_lens(project_root: Path, args) -> int | None:
2660 """Fan ``oct lint`` out to one engine run per owning subproject.
2661
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
2667 before.
2668
2669 Two situations fan out:
2670
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.
2677
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.
2681 """
2682 if args.explain or args.help:
2683 return None
2684
2685 workspace, is_workspace_root = _load_workspace_for(project_root)
2686 if workspace is None or not workspace.subprojects:
2687 return None
2688
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()
2693
2694 # -- Case 1: bare project scan at the workspace root ---------------
2695 # Fan out to a full per-subproject scan so each subproject's own
2696 # required-docs and octrc rules apply, then sweep the workspace-root
2697 # leftovers (files under no subproject) under the workspace lens.
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():
2702 continue
2703 exit_codes.append(
2704 run_linter(subproject.path, list(flags), _dispatch=False)
2705 )
2706 leftovers = [
2707 path
2708 for path in find_python_files(
2709 workspace.root, exclude_dirs, wildcard_exclude,
2710 )
2711 if not any(
2712 _is_within(path, sp.path) for sp in workspace.subprojects
2713 )
2714 ]
2715 if leftovers:
2716 exit_codes.append(run_linter(
2717 workspace.root,
2718 list(flags) + [str(p) for p in leftovers],
2719 _dispatch=False,
2720 ))
2721 return max(exit_codes, default=0)
2722
2723 # -- Resolve the candidate set for the remaining cases -------------
2724 if args.changed:
2725 if not is_workspace_root:
2726 return None
2727 candidates = [
2728 path for path in _git_changed_files(workspace.root)
2729 if path.is_file() and path.suffix == ".py"
2730 ]
2731 elif args.targets:
2732 candidates = _expand_lint_targets(
2733 list(args.targets), exclude_dirs, wildcard_exclude,
2734 )
2735 # Case 2 only triggers when a target escapes the invocation root.
2736 if not is_workspace_root and all(
2737 _is_within(path, root_resolved) for path in candidates
2738 ):
2739 return None
2740 else:
2741 return None
2742
2743 if not candidates:
2744 return None
2745
2746 unlensed = [
2747 path for path in candidates
2748 if not _is_within(path, workspace.root)
2749 and _assigned_subproject(path, workspace) is None
2750 ]
2751 if unlensed:
2752 names = ", ".join(str(p) for p in unlensed)
2753 print(
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.",
2759 file=sys.stderr,
2760 )
2761 return 1
2762
2763 groups = group_paths_by_lens(candidates, workspace)
2764 if len(groups) == 1 and root_resolved in groups:
2765 # Everything already belongs to the invocation lens.
2766 return None
2767
2768 exit_codes = []
2769 for group_root, group_files in groups.items():
2770 exit_codes.append(run_linter(
2771 group_root,
2772 list(flags) + [str(p) for p in group_files],
2773 _dispatch=False,
2774 ))
2775 return max(exit_codes, default=0)
2776
2777
2778def _is_within(path: Path, root: Path) -> bool:
2779 """True when *path* resolves inside *root*. Never raises."""
2780 try:
2781 Path(path).resolve().relative_to(Path(root).resolve())
2782 return True
2783 except (OSError, ValueError):
2784 return False
2785
2786
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)
2791
2792
2793def _print_non_compliant_section(results: list[dict]) -> None:
2794 """Print each non-compliant file and the checks that failed it.
2795
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]``.
2802
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.
2806 """
2807 non_compliant = [r for r in results if not r.get("compliant", True)]
2808 if not non_compliant:
2809 return
2810
2811 from oct.linter.rule_registry import SEVERITY_BLOCKING
2812
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", {})
2818 failing = [
2819 (rule, block)
2820 for rule, block in checks.items()
2821 if not block.get("pass", True)
2822 ]
2823 blocking = [
2824 (rule, block) for rule, block in failing
2825 if block.get("severity", SEVERITY_BLOCKING) == SEVERITY_BLOCKING
2826 ]
2827 advisory = [
2828 (rule, block) for rule, block in failing
2829 if block.get("severity", SEVERITY_BLOCKING) != SEVERITY_BLOCKING
2830 ]
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]")
2835
2836
2837def run_linter(
2838 project_root: Path,
2839 argv: list[str] | None = None,
2840 *,
2841 _dispatch: bool = True,
2842) -> int:
2843 """
2844 Run the Option C linter on the given project root.
2845
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.
2849
2850 Parameters
2851 ----------
2852 project_root:
2853 The detected Option C project root directory.
2854 argv:
2855 Optional list of command-line arguments (e.g. ["--fix-headers"]).
2856 If None, ``argparse`` will use ``sys.argv``.
2857
2858 Returns
2859 -------
2860 int
2861 Exit code: 0 if all checks passed, 1 if any failures detected.
2862 """
2863 func = "run_linter"
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(
2871 "--json",
2872 action="store_true",
2873 help="Emit machine-readable JSON to stdout instead of colorized terminal output",
2874 )
2875 parser.add_argument(
2876 "--jobs", "-j",
2877 type=int,
2878 default=1,
2879 help="Parallel workers for file processing (default: 1)",
2880 )
2881 parser.add_argument(
2882 "--changed", action="store_true",
2883 help="Lint only git-modified Python files",
2884 )
2885 parser.add_argument(
2886 "--quiet", action="store_true",
2887 help="Print the summary only; suppress the non-compliant file section",
2888 )
2889 parser.add_argument(
2890 "--profile",
2891 type=str,
2892 default=None,
2893 help=(
2894 "Lint profile: proto, compact, strict. Layers on the project's "
2895 ".octrc.json linter.rules overrides"
2896 ),
2897 )
2898 parser.add_argument(
2899 "--profile-only", action="store_true",
2900 help=(
2901 "With --profile, ignore the project's .octrc.json linter.rules "
2902 "and linter.path_rules overrides and report what the profile "
2903 "alone would say"
2904 ),
2905 )
2906 parser.add_argument(
2907 "--secret-match",
2908 type=str,
2909 choices=("substring", "word"),
2910 default="substring",
2911 help=(
2912 "Secret-name matcher (OI-529): 'substring' (default, legacy) or "
2913 "'word' (segment-aware; avoids false positives like 'compass')."
2914 ),
2915 )
2916 parser.add_argument(
2917 "--code-entropy-threshold",
2918 type=float,
2919 default=None,
2920 dest="entropy_threshold",
2921 help="Shannon entropy threshold for code string literals (default: 4.5)",
2922 )
2923 parser.add_argument(
2924 "--no-entropy",
2925 action="store_true",
2926 help="Disable entropy-based secret detection",
2927 )
2928 parser.add_argument(
2929 "--no-cache",
2930 action="store_true",
2931 help="Bypass lint cache reads (still writes updated cache)",
2932 )
2933 parser.add_argument(
2934 "--baseline",
2935 action="store_true",
2936 help="Save current violations as the baseline",
2937 )
2938 parser.add_argument(
2939 "--against-baseline",
2940 action="store_true",
2941 help="Report only new/resolved violations vs saved baseline",
2942 )
2943 parser.add_argument(
2944 "--explain",
2945 type=str,
2946 default=None,
2947 help="Show documentation for a lint rule (rationale, pattern, spec ref)",
2948 )
2949 parser.add_argument(
2950 "targets", nargs="*", default=None,
2951 help="Files or directories to lint (default: entire project)",
2952 )
2953 args = parser.parse_args(argv)
2954
2955 if args.help:
2956 print("Option C Linter")
2957 print("Usage:")
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]")
2960 print("")
2961 print("Arguments:")
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)")
2979 return 0
2980
2981 # ---- FS-509: --explain handler ----
2982 if args.explain:
2983 from oct.linter.rule_registry import get_rule_info, list_rules
2984 info = get_rule_info(args.explain)
2985 if info is None:
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)
2989 return 1
2990 if args.json:
2991 from dataclasses import asdict
2992 json.dump(asdict(info), sys.stdout, indent=2)
2993 print()
2994 else:
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}")
3000 print("")
3001 print("Rationale:")
3002 print(f" {info.rationale}")
3003 print("")
3004 print("Correct pattern:")
3005 for line in info.correct_pattern.splitlines():
3006 print(f" {line}")
3007 print("")
3008 print("Common mistake:")
3009 print(f" {info.common_mistake}")
3010 return 0
3011
3012 # FS-508: --baseline and --against-baseline are mutually exclusive
3013 if args.baseline and args.against_baseline:
3014 print("Error: --baseline and --against-baseline are mutually exclusive.",
3015 file=sys.stderr)
3016 return 1
3017
3018 # Version handshake with oc_diagnostics (warning-only, always stderr)
3019 from oct.core.compat import check_oc_diagnostics_compat
3020 compat_msg = check_oc_diagnostics_compat()
3021 if compat_msg:
3022 print(compat_msg, file=sys.stderr)
3023
3024 # ---- Plan 213 / D-D: dispatch each file to its owning lens ----
3025 if _dispatch:
3026 dispatched = _maybe_dispatch_by_lens(project_root, args)
3027 if dispatched is not None:
3028 return dispatched
3029
3030 root = project_root.resolve()
3031 _oc_diag = root / "oc_diagnostics"
3032 ctx = LinterContext(
3033 project_root=root,
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,
3039 )
3040
3041 # Plan 151 T6 (G7 deprecation-clearance): warn ONLY when a LEGACY
3042 # ``diagnostics/`` directory actually exists. Previously the warning
3043 # fired whenever ``oc_diagnostics/`` was merely absent (the fallback
3044 # name is always "diagnostics"), so a subproject with NO diagnostics
3045 # directory at all still inherited the deprecation notice under the
3046 # aggregated per-subproject strict lint. Mirrors project_root.py's
3047 # ``has_legacy and not has_oc_diag`` guard.
3048 if ctx.diagnostics_dir.name == "diagnostics" and ctx.diagnostics_dir.is_dir():
3049 print(
3050 "Warning: 'diagnostics/' is deprecated and will be removed in v1.0.0. "
3051 "Rename to 'oc_diagnostics/'.",
3052 file=sys.stderr,
3053 )
3054
3055 # OI-510 / FS-516: build the test discovery index once, up front - the
3056 # per-file tests_exist rule then performs O(1) set lookups instead of
3057 # re-walking tests/ for every linted file.
3058 build_test_index(ctx)
3059
3060 # ---- Per-project overrides from .octrc.json ----
3061 # OI-519: routed through central oct.core.octrc loader; strict mode
3062 # still escalates JSON/schema errors to exit 2.
3063 from oct.core.octrc import load_octrc
3064 active_exclude_dirs = set(EXCLUDE_DIRS)
3065 active_wildcard_exclude = list(WILDCARD_EXCLUDE_DIRS)
3066 # Plan 262: the project's profile name and rule overrides are only
3067 # collected here; the rule set itself is resolved once, below, so the
3068 # octrc and the --profile flag are merged in exactly one place.
3069 octrc_profile_name = "default"
3070 octrc_rule_overrides: dict = {}
3071 octrc, octrc_errors = load_octrc(root)
3072 if octrc_errors:
3073 msg = "; ".join(octrc_errors)
3074 if _strict_config_requested():
3075 print(f"Error: {msg}", file=sys.stderr)
3076 raise SystemExit(2)
3077 print(f"Warning: {msg}; using defaults.", file=sys.stderr)
3078
3079 if octrc:
3080 # OI-411: validate schema and enforce in strict mode
3081 schema_errors = _validate_octrc_schema(octrc)
3082 if schema_errors:
3083 msg = f".octrc.json schema errors: {'; '.join(schema_errors)}"
3084 if _strict_config_requested():
3085 print(f"Error: {msg}", file=sys.stderr)
3086 raise SystemExit(2)
3087 print(f"Warning: {msg}", file=sys.stderr)
3088
3089 linter_cfg = octrc.get("linter", {})
3090 if not isinstance(linter_cfg, dict):
3091 linter_cfg = {}
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)
3109 # Rule profile and per-rule overrides
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 # FS-C4: route profile to severity lookup
3113 # Plan 268: the per-path overrides ride the context, because
3114 # they are resolved per file rather than once per run.
3115 ctx.path_rules = parse_path_rules(linter_cfg)
3116
3117 # FS-507: read entropy thresholds from octrc
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))
3127
3128 # OI-540: load entropy exclusions.
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)
3136 ]
3137
3138 # ---- Plan 262: a CLI --profile LAYERS on the .octrc.json overrides ----
3139 cli_profile: str | None = None
3140 if args.profile:
3141 candidate = args.profile.lower()
3142 if candidate in _PROFILES:
3143 cli_profile = candidate
3144 ctx.profile_name = candidate # FS-C4: route profile to severity lookup
3145 else:
3146 print(
3147 f"Warning: unknown profile '{args.profile}'; "
3148 f"valid: {', '.join(p for p in _PROFILES if not p.startswith('_'))}",
3149 file=sys.stderr,
3150 )
3151
3152 disabled_by_octrc: list[str] = []
3153 if cli_profile is not None and args.profile_only:
3154 # P3 - the explicit escape hatch: what the named profile alone
3155 # would say if the project carried no overrides at all. This is
3156 # the ONLY way to get the pre-262 wholesale replacement, so the
3157 # change of behaviour is visible in every invocation.
3158 active_rules = dict(_PROFILES[cli_profile])
3159 # Plan 268 / P4: "the profile alone" means the path-scoped
3160 # overrides are out too - they are the project's configuration by
3161 # the same token the rule overrides are.
3162 ctx.path_rules = []
3163 else:
3164 active_rules, octrc_disabled = resolve_active_rules(
3165 octrc_profile_name, octrc_rule_overrides, cli_profile
3166 )
3167 # P2 - the note reports what an EXPLICIT profile would otherwise
3168 # have re-enabled. With no flag the overrides are simply the
3169 # project's configuration and there is nothing to announce.
3170 if cli_profile is not None:
3171 disabled_by_octrc = octrc_disabled
3172
3173 # ---- FS-507: CLI entropy flags override octrc ----
3174 if args.no_entropy:
3175 ctx.code_entropy_threshold = 0.0
3176 elif args.entropy_threshold is not None:
3177 ctx.code_entropy_threshold = args.entropy_threshold
3178
3179 # ---- FS-510: initialise lint cache ----
3180 from oct.core.option_c_dir import option_c_dir
3181 from oct import __version__
3182 cache_dir = option_c_dir(root) / "cache"
3183 if not args.fix_headers:
3184 # Plan 265: the store also keys on the linter's own code, so an
3185 # edit to any module that decides a verdict invalidates every
3186 # cached verdict. Computed once per run.
3187 from oct.linter.lint_cache import LintCache, current_linter_identity
3188 ctx.lint_cache = LintCache(
3189 cache_dir / "lint-cache.json",
3190 __version__,
3191 linter_identity=current_linter_identity(),
3192 )
3193 ctx.no_cache_read = args.no_cache
3194
3195 # ---- Open log file (non-fatal if unwritable) ----
3196 try:
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
3207
3208 fix_mode = args.fix_headers
3209 dry_run = args.dry_run
3210 json_mode = args.json
3211
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.",
3215 file=sys.stderr)
3216
3217 if not json_mode:
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)
3221
3222 # Project-level requirements
3223 required_docs = project_required_docs(ctx)
3224
3225 if not json_mode:
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)
3229
3230 # Module-level checks
3231 if not json_mode:
3232 print_header("Module-Level Checks", ctx)
3233
3234 # Force sequential when fixing (parallel writes are unsafe)
3235 jobs = max(1, args.jobs)
3236 if fix_mode and jobs > 1:
3237 jobs = 1
3238 if not json_mode:
3239 print("Note: --jobs forced to 1 when --fix-headers is active.", file=sys.stderr)
3240
3241 if args.changed:
3242 if args.targets:
3243 if not json_mode:
3244 print("Note: --changed overrides explicit PATH targets.", file=sys.stderr)
3245 python_files = _git_changed_files(ctx.project_root)
3246 # Apply exclusion filters - plan 282: to the DIRECTORY parts only,
3247 # the same slice ``find_python_files`` takes, so ``--changed`` and a
3248 # full scan agree on whether a file named like an excluded directory
3249 # is a source file.
3250 python_files = [
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]
3255 )
3256 ]
3257 if not python_files:
3258 if not json_mode:
3259 print("No modified Python files found.", file=sys.stderr)
3260 return 0
3261 elif args.targets:
3262 python_files = []
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)
3267 elif p.is_dir():
3268 python_files.extend(
3269 find_python_files(p, active_exclude_dirs, active_wildcard_exclude)
3270 )
3271 else:
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)
3276 return 0
3277 else:
3278 python_files = list(find_python_files(ctx.project_root, active_exclude_dirs, active_wildcard_exclude))
3279 total_files = len(python_files)
3280
3281 # Dispatch per-file linting (sequential or parallel)
3282 if jobs <= 1:
3283 results = [_lint_single_file(p, ctx, active_rules, fix_mode, dry_run) for p in python_files]
3284 else:
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]
3288
3289 # Aggregate counters from per-file results (plan 260: one per
3290 # SUMMARY_ROWS entry, so adding a rule to the table is the only step
3291 # needed to get it counted, printed and serialised). A rule that is
3292 # inactive under the active profile still gets a row and counts as
3293 # passing, which is how ``tests_exist`` already behaved under proto.
3294 pass_counts = {
3295 rule: sum(1 for r in results if r.get(rule, True))
3296 for rule, _label, _tabs, _json_key in SUMMARY_ROWS
3297 }
3298 fully_compliant = sum(1 for r in results if r["compliant"])
3299 fixed_files = sum(1 for r in results if r["fixed"])
3300
3301 file_results: list[dict] = []
3302 if json_mode:
3303 file_results = [r["json_detail"] for r in results]
3304
3305 if not json_mode:
3306 print_header("Final Result", ctx)
3307 if fully_compliant == total_files:
3308 log("All checks passed. Project conforms to Option C.", ctx)
3309 else:
3310 log("Some checks failed. See above for details.", ctx)
3311
3312 if not json_mode:
3313 print_header("Summary", ctx)
3314
3315 def pct(n: int) -> str:
3316 return f"{(n / total_files * 100):.1f}%" if total_files else "0%"
3317
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)
3324
3325 if fix_mode:
3326 label = "Would fix" if dry_run else "Files fixed"
3327 log(f"[{label}]:\t\t{fixed_files}", ctx)
3328
3329 if ctx.log_handle is not None:
3330 ctx.log_handle.close()
3331 ctx.log_handle = None
3332
3333 # FS-510: save lint cache
3334 if ctx.lint_cache is not None:
3335 try:
3336 ctx.lint_cache.save()
3337 except OSError:
3338 pass
3339
3340 # FS-508: baseline handling
3341 baseline_info: dict | None = None
3342 if args.baseline:
3343 from oct.linter.lint_baseline import save_baseline
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)}
3348 if not json_mode:
3349 print(f"\nBaseline saved: {count} violation(s) -> {baseline_path}",
3350 file=sys.stderr)
3351
3352 if args.against_baseline:
3353 from oct.linter.lint_baseline import (
3354 blocking_violations, diff_against_baseline, load_baseline,
3355 )
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.",
3360 file=sys.stderr)
3361 return 1
3362 new_violations, resolved = diff_against_baseline(results, baseline)
3363 # Only a new BLOCKING failure fails the comparison (operator ruling
3364 # 2026-09-28); new advisory ones are listed, marked as advisory.
3365 new_blocking = blocking_violations(new_violations, results)
3366 baseline_info = {
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),
3374 }
3375 if not json_mode:
3376 if new_violations:
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)
3384 if resolved:
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)
3390
3391 # JSON output (stdout - separate from the log file)
3392 if json_mode:
3393 output = {
3394 "project": ctx.project_name,
3395 "oct_version": __version__,
3396 "timestamp": datetime.now().isoformat(),
3397 "summary": {
3398 "total_files": total_files,
3399 "fully_compliant": fully_compliant,
3400 # Plan 260: the ten pre-existing key names are preserved
3401 # byte-for-byte by SUMMARY_ROWS; the five new counters
3402 # join them in the same table order.
3403 **{
3404 json_key: pass_counts[rule]
3405 for rule, _label, _tabs, json_key in SUMMARY_ROWS
3406 },
3407 },
3408 "files": file_results,
3409 }
3410 # Plan 260 P3: the per-file [FAIL] lines live in the log, so the
3411 # machine-readable document carries the path to reach them.
3412 if ctx.log_path is not None:
3413 output["log_path"] = str(ctx.log_path)
3414 # Plan 262 P2: the same thing the stderr note says, for machines.
3415 # Absent whenever the note would not print, so a document from a
3416 # project with no overrides is what it was before this plan.
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)
3422 print() # trailing newline after JSON block
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
3426
3427 # Plan 262 P2: say once, before the rows it explains, that the named
3428 # profile did not get the last word on these rules. --quiet (plan
3429 # 260) suppresses it along with the rest of the narrative output.
3430 if disabled_by_octrc and not args.quiet:
3431 print(
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)}",
3435 file=sys.stderr,
3436 )
3437
3438 # Terminal summary (colorized)
3439 print(f"{CYAN}Option C Linter Summary{RESET}")
3440 print(f"Files checked: {total_files}")
3441
3442 def color(n: int) -> str:
3443 return GREEN if n == total_files else RED
3444
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) + " "
3448
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)})")
3452 print(
3453 f"{label_field('Compliant files')}"
3454 f"{color(fully_compliant)}{fully_compliant}{RESET} "
3455 f"PASS ({pct(fully_compliant)})"
3456 )
3457
3458 if fix_mode:
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}")
3463
3464 # Plan 260 P2: name the non-compliant files and the checks that
3465 # failed them. The per-file [FAIL] lines only ever reached the log
3466 # file, so the operator saw a summary of green rows and no reason.
3467 # --quiet restores the pre-260 summary-only form for scripts.
3468 if not args.quiet:
3469 _print_non_compliant_section(results)
3470 # P3: the log holds the full per-file detail; say where it is.
3471 if ctx.log_path is not None:
3472 print(f"\nLint log: {ctx.log_path}")
3473
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
3477
3478
3479def main() -> None:
3480 """
3481 Standalone entry point when running this module directly.
3482 """
3483 raise SystemExit(run_linter(Path.cwd(), None))
3484
3485
3486if __name__ == "__main__":
3487 main()