6Option C auto-formatter implementation.
10Detect and fix structural Option C compliance violations automatically.
11Complements the linter by fixing violations while preserving code semantics.
15- Detect formatting violations using reused linter checks.
16- Fix violations: headers, docstrings, imports, function patterns.
17- Support report-only (dry-run) and fix modes.
18- Archive originals before modification.
19- Provide terminal and JSON output.
20- Respect project exclusions and configuration.
26 L2 — formatter lifecycle and command routing
27 L3 — file discovery and processing, fix application decisions
28 L4 — detailed fixer implementations, archival operations, output formatting
32- Files are only modified if fix_mode is True.
33- Originals are archived before any modification.
34- All code below headers is preserved exactly.
35- Docstrings are not replaced; missing sections are appended.
36- Each fixer is idempotent (running twice is same as once).
40- oct (package version)
41- oct.linter.oct_lint (reused validation checks)
42- oct.core.exclusions (directory and wildcard exclusion lists)
43- oct.core.project_root (project root detection)
44- oct.core.parsing (safe AST parsing helper)
45- oct.core.git (git changed-file discovery)
52from dataclasses
import dataclass
53from pathlib
import Path
54from datetime
import datetime
56from typing
import Tuple, List, Dict, Any, Optional
58from oct
import __version__
60 validate_header_block,
62 check_diagnostics_profile,
65 find_func_pattern_violations,
66 _extract_module_docstring,
70from oct.core.exclusions
import LINTER_EXCLUDE_DIRS, WILDCARD_EXCLUDE_DIRS
71from oct.core.project_root
import find_project_root
72from oct.core.parsing
import safe_parse
76 """Return .py files modified in the git working tree (staged + unstaged).
78 Thin shim over :func:`oct.core.git.git_changed_files` (Phase 4A).
79 Collapses all ``GitError`` to empty list so the formatter never
80 crashes due to git unavailability.
82 from oct.core
import git
as core_git
84 return core_git.git_changed_files(root, extensions={
".py"})
85 except core_git.GitError:
89SHEBANG =
"#!/usr/bin/env python3"
90ENCODING =
"# -*- coding: utf-8 -*-"
92IMPORT_BLOCK =
"from oc_diagnostics import _dbg\n"
94DOCSTRING_SKELETON =
'''\
98Describe the purpose of this module.
102- List the responsibilities of this module.
109 L3 — semantic details
114- State the contracts and guarantees of this module.
118MISSING_DOCSTRING_SECTIONS = {
122Describe the purpose of this module.""",
123 "Responsibilities":
"""
126- List the responsibilities of this module.""",
133 L3 — semantic details
134 L4 — deep tracing""",
138- State the contracts and guarantees of this module.""",
145_DEFAULT_MAX_BACKUPS_PER_FILE: int = 10
150_DEFAULT_MAX_ARCHIVE_BYTES: int = 5 * 1024 * 1024
155 """Immutable per-run context for the formatter."""
158 diagnostics_dir: Path
165 max_backups_per_file: int = _DEFAULT_MAX_BACKUPS_PER_FILE
168 max_archive_bytes: int = _DEFAULT_MAX_ARCHIVE_BYTES
177 """Get relative path from root to path."""
179 return str(path.relative_to(root))
185 archive_dir: Path, original_name: str, max_backups: int,
187 """Keep only the ``max_backups`` most recent backups of ``original_name``.
189 Matches files shaped like ``{original_name}.*.bak`` and sorts them
190 by mtime (newest first). Older entries beyond the cap are removed.
191 Best-effort: any OS error is swallowed so a failed prune can never
192 block ``oct format`` (OI-424).
194 Returns the number of files removed. ``max_backups <= 0`` disables
195 pruning entirely (useful when a project deliberately wants unbounded
198 func =
"_prune_archive"
203 archive_dir.glob(f
"{original_name}.*.bak"),
204 key=
lambda p: p.stat().st_mtime,
210 for stale
in candidates[max_backups:]:
220 archive_dir: Path, max_total_bytes: int,
222 """OI-516: evict oldest ``.bak`` files until total size fits under ``max_total_bytes``.
224 Runs *after* per-file count pruning so the archive floor is always
225 correct under the two coexisting policies. Any OS error on size or
226 unlink is swallowed — a rotation failure must never block formatting.
227 Returns the number of files removed.
229 func =
"_prune_archive_by_size"
230 if max_total_bytes <= 0:
233 entries = [(p, p.stat().st_mtime, p.stat().st_size)
234 for p
in archive_dir.glob(
"*.bak")
if p.is_file()]
237 total = sum(size
for _, _, size
in entries)
238 if total <= max_total_bytes:
241 entries.sort(key=
lambda row: row[1])
243 for path, _, size
in entries:
244 if total <= max_total_bytes:
257 max_backups: int = _DEFAULT_MAX_BACKUPS_PER_FILE,
258 max_archive_bytes: int = _DEFAULT_MAX_ARCHIVE_BYTES,
260 """Archive original file to ``.formatter_archive/`` with timestamp.
262 After writing the new backup, prune in two passes:
264 1. Per-file count cap (OI-424): keep only the N newest backups per
266 2. Per-directory size cap (OI-516): enforce aggregate ``max_archive_bytes``
267 across *all* backups in the archive, evicting oldest first.
269 func =
"archive_file"
270 archive_dir = path.parent /
".formatter_archive"
271 archive_dir.mkdir(exist_ok=
True)
272 timestamp = datetime.now().strftime(
"%Y%m%d_%H%M%S")
273 backup_path = archive_dir / f
"{path.name}.{timestamp}.bak"
274 backup_path.write_bytes(path.read_bytes())
280 """Return the dominant body indentation unit used in the file.
282 Returns ``"\\t"`` if the file uses tabs for body indentation more
283 often than spaces, otherwise ``" "`` (four spaces). Only
284 considers lines with a non-whitespace character so blank lines do
285 not skew the result (OI-425).
287 func =
"_detect_indent_style"
291 if not line
or not line.strip():
293 if line.startswith(
"\t"):
295 elif line.startswith(
" "):
297 return "\t" if tabs > spaces
else " "
301 lines: List[str], line_num: int, fallback: str =
" ",
303 """Return the indentation string of a given line.
305 If the requested line is out of range or whitespace-only, return
306 ``fallback``. The default fallback preserves the historical four-
307 space behaviour, so existing callers remain unchanged (OI-425).
309 if line_num >= len(lines):
311 line = lines[line_num]
312 stripped = line.lstrip()
314 return line[:len(line) - len(stripped)]
319 """Detect which docstring sections are missing."""
320 func =
"_detect_missing_docstring_sections"
321 docstring = _extract_module_docstring(text)
323 return [
"Purpose",
"Responsibilities",
"Diagnostics",
"Contracts"]
326 lines = docstring.split(
'\n')
327 for section
in [
"Purpose",
"Responsibilities",
"Diagnostics",
"Contracts"]:
328 found = any(line.strip().startswith(section)
for line
in lines)
330 missing.append(section)
339def fix_header_block(path: Path, text: str, ctx: FormatterContext) -> Tuple[bool, str, List[str]]:
341 Ensure file has correct 4-line header block.
343 Returns (changed, new_text, messages). Caller is responsible for
344 archiving and writing to disk.
346 func =
"fix_header_block"
349 header_lines = text.split(
'\n')[:4]
if len(text.split(
'\n')) >= 4
else text.split(
'\n')
350 ok, errors = validate_header_block(header_lines, path, ctx)
353 return False, text, []
357 new_shebang = f
"{SHEBANG}"
358 new_encoding = f
"{ENCODING}"
359 new_identity = f
"# {relative_path}"
362 new_header = new_shebang +
"\n" + new_encoding +
"\n" + new_identity +
"\n" + new_blank +
"\n"
367 from oct.core.parsing
import header_boundary
368 lines = text.split(
'\n')
369 relative_identity = f
"# {relative_path}"
370 content_start = header_boundary(
371 lines, path, expected_identity=relative_identity,
375 if content_start < len(lines):
376 remaining_content =
'\n'.join(lines[content_start:])
378 remaining_content =
""
380 new_text = new_header + remaining_content
382 messages = [
"Fixed header block (shebang, encoding, file identity, blank line)"]
383 return True, new_text, messages
386def fix_docstring(path: Path, text: str, ctx: FormatterContext) -> Tuple[bool, str, List[str]]:
388 Ensure module has complete docstring with all required sections.
390 If no docstring: insert skeleton.
391 If incomplete: add missing sections.
392 If complete: no change.
394 func =
"fix_docstring"
396 docstring = _extract_module_docstring(text)
400 lines = text.split(
'\n')
403 new_text =
'\n'.join(lines[:insert_pos]) +
'\n' + DOCSTRING_SKELETON +
'\n'.join(lines[insert_pos:])
405 return True, new_text, [
"Added complete module docstring"]
410 return False, text, []
413 sections_to_add =
"\n".join(MISSING_DOCSTRING_SECTIONS[section]
for section
in missing)
416 docstring_start = text.find(
'"""')
417 docstring_end = text.find(
'"""', docstring_start + 3)
419 if docstring_end == -1:
420 return False, text, []
422 new_text = (text[:docstring_end] +
"\n" + sections_to_add +
'\n' + text[docstring_end:])
424 messages = [f
"Added missing docstring sections: {', '.join(missing)}"]
425 return True, new_text, messages
428def fix_dbg_import(path: Path, text: str, ctx: FormatterContext) -> Tuple[bool, str, List[str]]:
430 Ensure canonical _dbg import exists at module level.
432 Skips trivial files (< 20 lines).
434 func =
"fix_dbg_import"
437 if len(text.splitlines()) < 20:
438 return False, text, []
441 ok, msg = check_dbg_import(text)
443 return False, text, []
446 tree, _ = safe_parse(text)
448 return False, text, []
451 lines = text.split(
'\n')
457 for i, line
in enumerate(lines):
460 if (
"from oc_diagnostics import" in line
and "_dbg" in line
and "import _dbg" not in line)
or \
461 (
"import _dbg" in line
and "from oc_diagnostics" not in line):
465 new_lines.append(line)
468 insert_pos = min(5, len(new_lines))
469 new_lines.insert(insert_pos, IMPORT_BLOCK.rstrip())
470 new_text =
'\n'.join(new_lines)
472 msg =
"Added canonical _dbg import"
473 if removed_count > 0:
474 msg += f
" (removed {removed_count} incorrect import(s))"
476 return True, new_text, [msg]
479def fix_func_pattern(path: Path, text: str, ctx: FormatterContext) -> Tuple[bool, str, List[str]]:
481 Ensure every function with _dbg() calls has func = "function_name" as first statement.
483 Delegates violation detection to :func:`oct.linter.oct_lint.find_func_pattern_violations`
484 so the linter remains the single source of truth for what constitutes a
485 func-pattern violation. This function only handles the fix (inserting
486 the ``func = "..."`` line).
488 Skips trivial files (< 20 lines).
490 func =
"fix_func_pattern"
492 functions_to_fix = find_func_pattern_violations(text)
493 if not functions_to_fix:
494 return False, text, []
496 lines = text.split(
'\n')
505 for func_name, func_lineno
in sorted(functions_to_fix, key=
lambda x: x[1], reverse=
True):
507 func_line_idx = func_lineno - 1
510 if func_line_idx >= len(lines)
or "def " not in lines[func_line_idx]:
514 insert_idx = func_line_idx + 1
515 while insert_idx < len(lines):
516 line_content = lines[insert_idx].strip()
518 if not line_content
or line_content.startswith(
"#"):
520 elif line_content.startswith(
'"""')
or line_content.startswith(
"'''"):
522 quote =
'"""' if '"""' in line_content
else "'''"
523 if line_content.count(quote) == 1:
526 while insert_idx < len(lines)
and quote
not in lines[insert_idx]:
538 lines, func_line_idx, fallback=
"",
540 body_fallback = def_indent + file_indent_unit
542 lines, insert_idx, fallback=body_fallback,
545 pattern = f
'{indent}func = "{func_name}"'
546 lines.insert(insert_idx, pattern)
548 new_text =
'\n'.join(lines)
550 fixed_names = [name
for name, _
in functions_to_fix]
551 messages = [f
"Added func pattern to: {', '.join(fixed_names)}"]
552 return True, new_text, messages
560def format_file(path: Path, ctx: FormatterContext) -> Dict[str, Any]:
561 """Process a single file through all formatters."""
565 text = path.read_text(encoding=
"utf-8")
566 except Exception
as e:
571 "header": {
"changed":
False,
"messages": []},
572 "docstring": {
"changed":
False,
"messages": []},
573 "dbg_import": {
"changed":
False,
"messages": []},
574 "func_pattern": {
"changed":
False,
"messages": []},
576 "errors": [f
"Could not read file: {str(e)}"],
588 (
"header", fix_header_block),
589 (
"docstring", fix_docstring),
590 (
"dbg_import", fix_dbg_import),
591 (
"func_pattern", fix_func_pattern),
596 for fix_name, fixer
in fixers:
598 changed, text, messages = fixer(path, text, ctx)
599 result[
"fixes"][fix_name] = {
"changed": changed,
"messages": messages}
602 except Exception
as e:
603 result[
"errors"].append(f
"{fix_name}: {str(e)}")
606 if any_changed
and ctx.fix_mode
and not ctx.dry_run_mode:
609 max_backups=ctx.max_backups_per_file,
610 max_archive_bytes=ctx.max_archive_bytes,
612 path.write_text(text, encoding=
"utf-8")
614 result[
"changed"] = any_changed
619 """Aggregate results into summary."""
620 func =
"compute_summary"
623 "total_files": len(file_results),
624 "total_changed": sum(1
for r
in file_results
if r.get(
"changed")),
633 for result
in file_results:
634 for fix_type
in summary[
"fixes"]:
635 if result[
"fixes"].get(fix_type, {}).get(
"changed"):
636 summary[
"fixes"][fix_type] += 1
647 verbose: bool, dry_run: bool) ->
None:
648 """Print human-readable terminal report."""
649 func =
"_print_terminal_report"
651 mode =
"DRY-RUN" if dry_run
else "FIXED"
653 print(f
"\n{'=' * 70}")
654 print(f
"OCT FORMATTER REPORT — {mode}")
655 print(f
"{'=' * 70}\n")
657 print(f
"Files processed: {summary['total_files']}")
658 print(f
"Files with changes: {summary['total_changed']}")
660 print(
"Fixes applied:")
661 print(f
" Header blocks: {summary['fixes']['header']}")
662 print(f
" Docstrings: {summary['fixes']['docstring']}")
663 print(f
" _dbg imports: {summary['fixes']['dbg_import']}")
664 print(f
" func patterns: {summary['fixes']['func_pattern']}")
667 print(f
"\n{'-' * 70}")
668 print(
"Detailed results:")
669 print(f
"{'-' * 70}\n")
671 for result
in file_results:
672 if result[
"changed"]
or result[
"errors"]:
673 print(f
"{result['path']}")
675 for fix_type, fix_result
in result[
"fixes"].items():
676 if fix_result[
"changed"]:
677 print(f
" [OK] {fix_type}: {', '.join(fix_result['messages'])}")
679 for error
in result[
"errors"]:
680 print(f
" [ERROR] {error}")
684 print(f
"{'=' * 70}\n")
688 ctx: FormatterContext) ->
None:
689 """Print JSON output."""
690 func =
"_print_json_report"
693 "project": ctx.project_name,
694 "oct_version": __version__,
695 "timestamp": datetime.now().isoformat(),
696 "dry_run": ctx.dry_run_mode,
698 "files": file_results,
701 print(json.dumps(output, indent=2))
709def run_formatter(project_root: Path, argv: Optional[List[str]] =
None) ->
None:
711 Run the Option C formatter on the given project root.
716 The detected Option C project root directory.
717 argv : list[str] | None
718 Optional list of command-line arguments.
719 If None, argparse will use sys.argv[1:].
721 func =
"run_formatter"
724 parser = argparse.ArgumentParser(
725 description=
"Auto-fix Option C compliance violations",
731 help=
"Apply formatting changes. Without this, only reports what would change.",
736 help=
"Report what would change without modifying files (default behavior).",
741 help=
"Output machine-readable JSON instead of colorized terminal report.",
746 help=
"Show per-file details in addition to summary.",
751 help=
"Format only git-modified Python files.",
754 args = parser.parse_args(argv)
757 diagnostics_dir = project_root /
"oc_diagnostics"
758 tests_dir = project_root /
"tests"
759 docs_dir = project_root /
"docs"
762 exclude_dirs = set(LINTER_EXCLUDE_DIRS)
763 wildcard_exclude = list(WILDCARD_EXCLUDE_DIRS)
767 max_backups_per_file = _DEFAULT_MAX_BACKUPS_PER_FILE
768 from oct.core.octrc
import load_octrc_safe
769 octrc = load_octrc_safe(project_root)
770 linter_cfg = octrc.get(
"linter", {})
771 if isinstance(linter_cfg, dict):
772 for d
in linter_cfg.get(
"exclude_dirs_add", []):
774 for d
in linter_cfg.get(
"exclude_dirs_remove", []):
775 exclude_dirs.discard(d)
776 for d
in linter_cfg.get(
"wildcard_exclude_add", []):
777 if d
not in wildcard_exclude:
778 wildcard_exclude.append(d)
782 max_archive_bytes = _DEFAULT_MAX_ARCHIVE_BYTES
783 formatter_cfg = octrc.get(
"formatter", {})
784 if isinstance(formatter_cfg, dict):
785 raw = formatter_cfg.get(
"max_backups_per_file")
786 if isinstance(raw, int)
and raw >= 0:
787 max_backups_per_file = raw
788 raw_mib = formatter_cfg.get(
"max_archive_mib")
789 if isinstance(raw_mib, (int, float))
and raw_mib >= 0:
790 max_archive_bytes = int(raw_mib * 1024 * 1024)
793 project_root=project_root,
794 project_name=project_root.name,
795 diagnostics_dir=diagnostics_dir,
799 dry_run_mode=
not args.fix,
801 verbose=args.verbose,
802 max_backups_per_file=max_backups_per_file,
803 max_archive_bytes=max_archive_bytes,
810 p
for p
in python_files
811 if p.is_file()
and not any(
812 is_excluded_dir(Path(part), exclude_dirs, wildcard_exclude)
813 for part
in p.relative_to(project_root).parts
818 print(
"No modified Python files found.", file=sys.stderr)
821 python_files = find_python_files(project_root, exclude_dirs, wildcard_exclude)
824 for py_file
in python_files:
826 file_results.append(result)
838if __name__ ==
"__main__":
839 project_root = find_project_root(Path.cwd())