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).
37- A module is never given an import of a symbol its own package
38 provides, so formatting can never make a package import itself.
42- oct (package version)
43- oct.linter.oct_lint (reused validation checks)
44- oct.core.exclusions (directory and wildcard exclusion lists)
45- oct.core.project_root (project root detection)
46- oct.core.parsing (safe AST parsing helper)
47- oct.core.git (lazy; git changed-file discovery)
48- oct.core.paths (to_project_relative - the identity header path)
49- oct.core.diagnostics (_dbg structured logger)
50- oct.core.octrc (lazy; .octrc.json exclusion and archive overrides)
51- oct.core.workspace (lazy; subproject lookup for the per-subproject fan-out)
58from dataclasses
import dataclass
59from pathlib
import Path
60from datetime
import datetime
62from typing
import Tuple, List, Dict, Any, Optional
64from oct
import __version__
66 validate_header_block,
68 check_diagnostics_profile,
71 find_func_pattern_violations,
72 _extract_module_docstring,
73 _module_docstring_slice,
74 _describe_syntax_error,
78from oct.core.exclusions
import LINTER_EXCLUDE_DIRS, WILDCARD_EXCLUDE_DIRS
79from oct.core.project_root
import find_project_root
80from oct.core.parsing
import safe_parse
81from oct.core.paths
import to_project_relative
82from oct.core.diagnostics
import _dbg
86 """Return .py files modified in the git working tree (staged + unstaged).
88 Thin shim over :func:`oct.core.git.git_changed_files` (Phase 4A).
89 Collapses all ``GitError`` to empty list so the formatter never
90 crashes due to git unavailability.
92 from oct.core
import git
as core_git
94 return core_git.git_changed_files(root, extensions={
".py"})
95 except core_git.GitError:
99SHEBANG =
"#!/usr/bin/env python3"
100ENCODING =
"# -*- coding: utf-8 -*-"
102IMPORT_BLOCK =
"from oc_diagnostics import _dbg\n"
104DOCSTRING_SKELETON =
'''\
108Describe the purpose of this module.
112- List the responsibilities of this module.
119 L3 - semantic details
124- State the contracts and guarantees of this module.
128MISSING_DOCSTRING_SECTIONS = {
132Describe the purpose of this module.""",
133 "Responsibilities":
"""
136- List the responsibilities of this module.""",
143 L3 - semantic details
144 L4 - deep tracing""",
148- State the contracts and guarantees of this module.""",
155_DEFAULT_MAX_BACKUPS_PER_FILE: int = 10
160_DEFAULT_MAX_ARCHIVE_BYTES: int = 5 * 1024 * 1024
165 """Immutable per-run context for the formatter."""
168 diagnostics_dir: Path
175 max_backups_per_file: int = _DEFAULT_MAX_BACKUPS_PER_FILE
178 max_archive_bytes: int = _DEFAULT_MAX_ARCHIVE_BYTES
186 disabled_fixers: frozenset = frozenset()
195 """Render *path* as a project-relative POSIX string.
197 200: delegates to :func:`oct.core.paths.to_project_relative`, the helper
198 OI-533 introduced to standardise path output - and the one the linter's
199 ``expected_path_header`` already uses. This function previously returned
200 ``str(path.relative_to(root))``, which renders with the OS separator, so
201 on Windows the identity header the formatter WROTE
202 (``# pkg\\sub\\mod.py``) was never the one the linter EXPECTED
203 (``# pkg/sub/mod.py``). Every file in a subdirectory therefore had its
204 header rewritten on every run, forever, each pass archiving the file
205 again - a direct breach of the module's own per-fixer idempotence
208 There is one source of truth for "the project-relative path" and both
211 return to_project_relative(path, root)
215 archive_dir: Path, original_name: str, max_backups: int,
217 """Keep only the ``max_backups`` most recent backups of ``original_name``.
219 Matches files shaped like ``{original_name}.*.bak`` and sorts them
220 by mtime (newest first). Older entries beyond the cap are removed.
221 Best-effort: any OS error is swallowed so a failed prune can never
222 block ``oct format`` (OI-424).
224 Returns the number of files removed. ``max_backups <= 0`` disables
225 pruning entirely (useful when a project deliberately wants unbounded
228 func =
"_prune_archive"
233 archive_dir.glob(f
"{original_name}.*.bak"),
234 key=
lambda p: p.stat().st_mtime,
240 for stale
in candidates[max_backups:]:
250 archive_dir: Path, max_total_bytes: int,
252 """OI-516: evict oldest ``.bak`` files until total size fits under ``max_total_bytes``.
254 Runs *after* per-file count pruning so the archive floor is always
255 correct under the two coexisting policies. Any OS error on size or
256 unlink is swallowed - a rotation failure must never block formatting.
257 Returns the number of files removed.
259 func =
"_prune_archive_by_size"
260 if max_total_bytes <= 0:
263 entries = [(p, p.stat().st_mtime, p.stat().st_size)
264 for p
in archive_dir.glob(
"*.bak")
if p.is_file()]
267 total = sum(size
for _, _, size
in entries)
268 if total <= max_total_bytes:
271 entries.sort(key=
lambda row: row[1])
273 for path, _, size
in entries:
274 if total <= max_total_bytes:
287 max_backups: int = _DEFAULT_MAX_BACKUPS_PER_FILE,
288 max_archive_bytes: int = _DEFAULT_MAX_ARCHIVE_BYTES,
290 """Archive original file to ``.formatter_archive/`` with timestamp.
292 After writing the new backup, prune in two passes:
294 1. Per-file count cap (OI-424): keep only the N newest backups per
296 2. Per-directory size cap (OI-516): enforce aggregate ``max_archive_bytes``
297 across *all* backups in the archive, evicting oldest first.
299 func =
"archive_file"
300 archive_dir = path.parent /
".formatter_archive"
301 archive_dir.mkdir(exist_ok=
True)
302 timestamp = datetime.now().strftime(
"%Y%m%d_%H%M%S")
303 backup_path = archive_dir / f
"{path.name}.{timestamp}.bak"
304 backup_path.write_bytes(path.read_bytes())
310 """Return the 0-based index just past the leading comment/blank block.
312 Scans from the top while every line is blank or a comment, however long
313 the block runs, so an inserted construct never lands inside it. Shared
314 by the import and docstring fixers so both follow the same rule (186,
318 for i, line
in enumerate(lines):
319 stripped = line.strip()
320 if stripped
and not stripped.startswith(
"#"):
327 """Return the indentation prefix of ``line``, tabs and spaces alike."""
328 return line[:len(line) - len(line.lstrip())]
332 """Return the 0-based index of the first source line owned by ``stmt``.
334 A decorated statement's own ``lineno`` points at its ``def``/``class``
335 keyword, not at its first decorator. Inserting a line at that index
336 would separate a decorator from the statement it decorates, which does
337 not parse - so decorators are folded in here (198).
340 for decorator
in getattr(stmt,
"decorator_list", []):
341 lineno = min(lineno, decorator.lineno)
346 """Return True when nothing but whitespace precedes ``stmt`` on its line.
348 Distinguishes a body that occupies its own lines from one written inline
349 with its signature - ``def f(): return x``, or a multi-line signature
350 closed as ``) -> int: return x``. An inline body has no line of its own
351 to insert before (198).
353 index = stmt.lineno - 1
354 if index >= len(lines):
356 return not lines[index][:stmt.col_offset].strip()
360 """Locate the module docstring, returning ``(docstring, close_index)``.
362 ``close_index`` is the offset in *text* of the docstring's CLOSING
363 triple quote, which is where :func:`fix_docstring` appends the Option C
364 sections it finds missing.
366 200: the docstring itself comes from the linter's shared
367 :func:`~oct.linter.oct_lint._extract_module_docstring` - the helper this
368 module already uses at :func:`_detect_missing_docstring_sections` and at
369 the top of :func:`fix_docstring`. Only the POSITION is computed here.
370 That is the whole point: ``fix_docstring`` used to find its insertion
371 point with a raw scan for the first triple-double-quote in the file,
372 which is a second, independent opinion about which string is the module
373 docstring. With a single-quoted module docstring the raw scan found a
374 FUNCTION's docstring and the module sections were appended there. The
375 output still parsed, so nothing caught it.
377 204: the position is no longer recovered by searching the source for the
378 shared helper's answer - the helper now reports the span it found, so the
379 formatter and the linter agree on WHERE the docstring is structurally,
380 not by locating the same substring twice.
382 Returns ``None`` when there is no module docstring, or when its delimiter
383 is not a triple quote: the Option C sections are appended as new lines,
384 which a one-line ``'x'`` literal cannot carry without breaking the parse.
385 Callers must treat ``None`` as "make no change": misplacing (or
386 duplicating) Option C sections is worse than declining to add them.
388 func =
"_module_docstring_span"
390 located = _module_docstring_slice(text)
391 if located
is None or len(located.quote) != 3:
394 return located.text, located.close_index
398 """Detect which docstring sections are missing."""
399 func =
"_detect_missing_docstring_sections"
400 docstring = _extract_module_docstring(text)
402 return [
"Purpose",
"Responsibilities",
"Diagnostics",
"Contracts"]
405 lines = docstring.split(
'\n')
406 for section
in [
"Purpose",
"Responsibilities",
"Diagnostics",
"Contracts"]:
407 found = any(line.strip().startswith(section)
for line
in lines)
409 missing.append(section)
418def fix_header_block(path: Path, text: str, ctx: FormatterContext) -> Tuple[bool, str, List[str]]:
420 Ensure file has correct 4-line header block.
422 Returns (changed, new_text, messages). Caller is responsible for
423 archiving and writing to disk.
425 func =
"fix_header_block"
428 header_lines = text.split(
'\n')[:4]
if len(text.split(
'\n')) >= 4
else text.split(
'\n')
429 ok, errors = validate_header_block(header_lines, path, ctx)
432 return False, text, []
436 new_shebang = f
"{SHEBANG}"
437 new_encoding = f
"{ENCODING}"
438 new_identity = f
"# {relative_path}"
441 new_header = new_shebang +
"\n" + new_encoding +
"\n" + new_identity +
"\n" + new_blank +
"\n"
446 from oct.core.parsing
import header_boundary
447 lines = text.split(
'\n')
448 relative_identity = f
"# {relative_path}"
449 content_start = header_boundary(
450 lines, path, expected_identity=relative_identity,
454 if content_start < len(lines):
455 remaining_content =
'\n'.join(lines[content_start:])
457 remaining_content =
""
459 new_text = new_header + remaining_content
461 messages = [
"Fixed header block (shebang, encoding, file identity, blank line)"]
462 return True, new_text, messages
465def fix_docstring(path: Path, text: str, ctx: FormatterContext) -> Tuple[bool, str, List[str]]:
467 Ensure module has complete docstring with all required sections.
469 If no docstring: insert skeleton.
470 If incomplete: add missing sections.
471 If complete: no change.
473 func =
"fix_docstring"
475 docstring = _extract_module_docstring(text)
487 tree, _ = safe_parse(text)
488 if tree
is None or ast.get_docstring(tree)
is not None:
489 return False, text, []
498 lines = text.split(
'\n')
501 new_text =
'\n'.join(lines[:insert_pos]) +
'\n' + DOCSTRING_SKELETON +
'\n'.join(lines[insert_pos:])
503 return True, new_text, [
"Added complete module docstring"]
508 return False, text, []
511 sections_to_add =
"\n".join(MISSING_DOCSTRING_SECTIONS[section]
for section
in missing)
520 return False, text, []
521 _docstring, docstring_end = span
523 new_text = (text[:docstring_end] +
"\n" + sections_to_add +
'\n' + text[docstring_end:])
525 messages = [f
"Added missing docstring sections: {', '.join(missing)}"]
526 return True, new_text, messages
531 Return the 0-based line index at which the canonical ``_dbg`` import belongs.
533 The placement is derived from the module structure, never from a fixed
534 line number (184-D7). The Option C canonical order is: header comments,
535 module docstring, ``from __future__`` imports, then ordinary imports.
537 - With a module docstring, start at the line after its closing quotes,
538 then move past any ``from __future__`` imports - the language requires
539 those to be the first statement after the docstring.
540 - Without one, start after the whole leading comment/blank block, however
541 long it runs, so the import never lands inside it.
543 In both cases trailing blank lines are skipped, so the import joins the
544 top of the existing import block instead of hugging the docstring or the
551 if (body
and isinstance(body[0], ast.Expr)
552 and isinstance(body[0].value, ast.Constant)
553 and isinstance(body[0].value.value, str)):
556 index = body[0].end_lineno
561 for node
in body[consumed:]:
562 if isinstance(node, ast.ImportFrom)
and node.module ==
"__future__":
563 index = max(index, node.end_lineno)
567 while index < len(lines)
and not lines[index].strip():
570 return min(index, len(lines))
575 Return the top-level package named by :data:`IMPORT_BLOCK`.
577 Derived by parsing the canonical import rather than hardcoded, so the
578 self-import guard keeps following ``IMPORT_BLOCK`` if the package that
579 provides the diagnostics symbol is ever renamed. Returns ``None`` if
580 the constant is not a parseable ``from <package> import ...``.
582 tree, _ = safe_parse(IMPORT_BLOCK)
586 for node
in ast.iter_child_nodes(tree):
587 if isinstance(node, ast.ImportFrom)
and node.module:
588 return node.module.split(
".")[0]
595 Return ``(module, symbol)`` named by :data:`IMPORT_BLOCK`.
597 Derived by parsing the canonical import rather than hardcoded, so the
598 removal pass keeps following ``IMPORT_BLOCK`` if either the module or
599 the symbol it provides is ever renamed. Returns ``(None, None)`` if the
600 constant is not a parseable ``from <module> import <symbol>``.
602 tree, _ = safe_parse(IMPORT_BLOCK)
606 for node
in ast.iter_child_nodes(tree):
607 if isinstance(node, ast.ImportFrom)
and node.module
and node.names:
608 return node.module, node.names[0].name
614 tree: ast.Module, module: str, symbol: str,
617 Return ``(line_indices, statement_count)`` for imports that conflict.
619 A statement conflicts only when it binds the BARE name ``symbol``, since
620 that is the sole binding the canonical import can collide with. This is
621 a structural test over module-level import nodes, replacing a substring
622 scan that treated any line containing ``"import <symbol>"`` as an import
623 (198). That scan deleted prose inside docstrings, string literals that
624 merely spelled an import, and - because ``import _dbg_shim`` contains
625 ``import _dbg`` - the sole body of a ``try:`` block, which does not
628 Two conservative limits keep removal from causing the very breakage this
629 fixer exists to avoid:
631 - Aliased imports are never removed. They bind a different name, so they
632 cannot conflict, and deleting one unbinds a name the module still uses.
633 - A statement that binds anything else is never removed, so a
634 co-imported symbol can never be taken down with it.
636 Only module-level statements are considered, matching the scope of
637 :func:`oct.linter.oct_lint.check_dbg_import`.
642 for node
in ast.iter_child_nodes(tree):
643 if not isinstance(node, (ast.Import, ast.ImportFrom)):
647 alias.asname
is None and alias.name.split(
".")[0] == symbol
648 for alias
in node.names
653 if isinstance(node, ast.ImportFrom)
and node.module == module:
656 if len(node.names) > 1:
659 end_lineno = node.end_lineno
or node.lineno
660 doomed.update(range(node.lineno - 1, end_lineno))
663 return doomed, statements
668 Return the name of the top-level package ``path`` belongs to.
670 Walks up from the file's directory while each level is a real package
671 (contains an ``__init__.py``); the highest such directory names the
672 top-level package. A nested submodule therefore reports the package at
673 the top of its chain, not its immediate parent.
675 Returns ``None`` when the file is not inside a package at all, which
676 keeps the test structural: a directory that merely *shares* a
677 package's name is not that package.
680 directory = path.parent
682 while (directory /
"__init__.py").exists():
683 package = directory.name
684 parent = directory.parent
685 if parent == directory:
692def fix_dbg_import(path: Path, text: str, ctx: FormatterContext) -> Tuple[bool, str, List[str]]:
694 Ensure canonical _dbg import exists at module level.
696 Skips trivial files (< 20 lines), and skips any module belonging to the
697 package that *provides* the symbol (195) - see the guard below.
699 func =
"fix_dbg_import"
702 if len(text.splitlines()) < 20:
703 return False, text, []
716 return False, text, [
717 f
"Skipped canonical import: this module belongs to "
718 f
"'{owning_package}', the package that provides it"
722 ok, msg = check_dbg_import(text)
724 return False, text, []
727 tree, _ = safe_parse(text)
729 return False, text, []
731 lines = text.split(
'\n')
740 if canonical_module
is None or canonical_symbol
is None:
741 return False, text, []
744 tree, canonical_module, canonical_symbol,
747 new_lines = [line
for i, line
in enumerate(lines)
if i
not in doomed]
748 removed_before_insert = sum(1
for i
in doomed
if i < insert_pos)
752 insert_pos = min(insert_pos - removed_before_insert, len(new_lines))
753 new_lines.insert(insert_pos, IMPORT_BLOCK.rstrip())
754 new_text =
'\n'.join(new_lines)
756 msg =
"Added canonical _dbg import"
757 if removed_count > 0:
758 msg += f
" (removed {removed_count} incorrect import(s))"
760 return True, new_text, [msg]
764 node: Any, lines: List[str],
765) -> Optional[Tuple[int, str]]:
767 Return ``(line_index, indent)`` for a function's ``func = "..."`` line.
769 The position is derived from the function's AST body, never by counting
770 lines from the ``def`` (198). A multi-line signature puts a PARAMETER on
771 the line after the ``def``, so "the next line" is not the body - taking
772 it as the insertion point injects the assignment into the parameter list
773 and the file stops parsing.
775 Docstring-first ordering is preserved: when the body opens with a
776 docstring the assignment lands after it, so the string remains the
777 function's docstring instead of being demoted to a plain expression.
779 Returns ``None`` when there is no line to insert before - a body written
780 inline with its signature (``def f(): return x``). Leaving such a
781 function unfixed is the correct outcome: the fixer's contract is that it
782 never corrupts a file, not that it always succeeds.
793 if (isinstance(first, ast.Expr)
794 and isinstance(first.value, ast.Constant)
795 and isinstance(first.value.value, str)):
802 index = min(first.end_lineno, len(lines))
806 if index >= len(lines):
811def fix_func_pattern(path: Path, text: str, ctx: FormatterContext) -> Tuple[bool, str, List[str]]:
813 Ensure every function with _dbg() calls has func = "function_name" as first statement.
815 Delegates violation detection to :func:`oct.linter.oct_lint.find_func_pattern_violations`
816 so the linter remains the single source of truth for what constitutes a
817 func-pattern violation. This function only handles the fix (inserting
818 the ``func = "..."`` line), and takes every insertion point from the AST
819 rather than from line arithmetic (198).
821 Skips trivial files (< 20 lines).
823 func =
"fix_func_pattern"
825 tree, _ = safe_parse(text)
827 return False, text, []
829 functions_to_fix = find_func_pattern_violations(text, tree)
830 if not functions_to_fix:
831 return False, text, []
833 lines = text.split(
'\n')
836 (node.name, node.lineno): node
837 for node
in ast.walk(tree)
838 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
844 insertions: List[Tuple[int, str, str]] = []
845 skipped: List[str] = []
847 for func_name, func_lineno
in functions_to_fix:
848 node = nodes.get((func_name, func_lineno))
853 skipped.append(func_name)
855 insert_idx, indent = point
856 insertions.append((insert_idx, func_name, indent))
862 "Skipped func pattern (body shares a line with its "
863 f
"signature): {', '.join(skipped)}"
865 return False, text, messages
868 for insert_idx, func_name, indent
in sorted(insertions, reverse=
True):
869 lines.insert(insert_idx, f
'{indent}func = "{func_name}"')
871 new_text =
'\n'.join(lines)
873 fixed_names = [name
for _idx, name, _indent
in insertions]
874 messages = [f
"Added func pattern to: {', '.join(fixed_names)}"]
877 "Skipped func pattern (body shares a line with its "
878 f
"signature): {', '.join(skipped)}"
880 return True, new_text, messages
888def format_file(path: Path, ctx: FormatterContext) -> Dict[str, Any]:
889 """Process a single file through all formatters.
891 Plan 277: a fixer named in ``ctx.disabled_fixers`` does not run. Its
892 entry in ``fixes`` still appears, reporting ``changed`` False and
893 carrying an extra ``"skipped": "disabled"`` key, so the result stays
894 a superset of the shape every existing reader expects. With the
895 default empty set - ``oct format`` run directly, and every caller
896 that builds its own context - nothing about this function changes.
901 text = path.read_text(encoding=
"utf-8")
902 except Exception
as e:
907 "header": {
"changed":
False,
"messages": []},
908 "docstring": {
"changed":
False,
"messages": []},
909 "dbg_import": {
"changed":
False,
"messages": []},
910 "func_pattern": {
"changed":
False,
"messages": []},
912 "errors": [f
"Could not read file: {str(e)}"],
930 tree, _ = safe_parse(text)
932 result[
"errors"].append(f
"Syntax error: {_describe_syntax_error(text)}")
934 fix_name: {
"changed":
False,
"messages": []}
935 for fix_name
in (
"header",
"docstring",
"dbg_import",
"func_pattern")
941 (
"header", fix_header_block),
942 (
"docstring", fix_docstring),
943 (
"dbg_import", fix_dbg_import),
944 (
"func_pattern", fix_func_pattern),
949 for fix_name, fixer
in fixers:
954 if fix_name
in ctx.disabled_fixers:
955 result[
"fixes"][fix_name] = {
956 "changed":
False,
"messages": [],
"skipped":
"disabled",
958 _dbg(
"OCT.FORMATTER", func, f
"{fix_name} disabled for {path.name}", 4)
961 changed, text, messages = fixer(path, text, ctx)
962 result[
"fixes"][fix_name] = {
"changed": changed,
"messages": messages}
968 outcome = (
"would change" if not ctx.fix_mode
else "changed")
if changed
else "clean"
969 _dbg(
"OCT.FORMATTER", func,
970 f
"fixer {fix_name} {outcome} ({len(messages)} messages) {result['path']}", 3)
971 except Exception
as e:
972 result[
"errors"].append(f
"{fix_name}: {str(e)}")
973 _dbg(
"OCT.FORMATTER", func,
974 f
"fixer {fix_name} raised {type(e).__name__} {result['path']}", 3)
984 verified, _ = safe_parse(text)
986 result[
"errors"].append(
987 "Discarded formatter output: the result does not parse "
988 f
"({_describe_syntax_error(text)}). The file was left "
992 fix_name: {
"changed":
False,
"messages": []}
993 for fix_name, _fixer
in fixers
995 result[
"changed"] =
False
999 if any_changed
and ctx.fix_mode
and not ctx.dry_run_mode:
1002 max_backups=ctx.max_backups_per_file,
1003 max_archive_bytes=ctx.max_archive_bytes,
1005 path.write_text(text, encoding=
"utf-8")
1007 result[
"changed"] = any_changed
1012 """Aggregate results into summary."""
1013 func =
"compute_summary"
1014 _dbg(
"OCT.FORMATTER", func, f
"summarising {len(file_results)} formatted files", 3)
1017 "total_files": len(file_results),
1018 "total_changed": sum(1
for r
in file_results
if r.get(
"changed")),
1027 for result
in file_results:
1028 for fix_type
in summary[
"fixes"]:
1029 if result[
"fixes"].get(fix_type, {}).get(
"changed"):
1030 summary[
"fixes"][fix_type] += 1
1041 verbose: bool, dry_run: bool) ->
None:
1042 """Print human-readable terminal report."""
1043 func =
"_print_terminal_report"
1045 mode =
"DRY-RUN" if dry_run
else "FIXED"
1047 print(f
"\n{'=' * 70}")
1048 print(f
"OCT FORMATTER REPORT - {mode}")
1049 print(f
"{'=' * 70}\n")
1051 print(f
"Files processed: {summary['total_files']}")
1052 print(f
"Files with changes: {summary['total_changed']}")
1054 print(
"Fixes applied:")
1055 print(f
" Header blocks: {summary['fixes']['header']}")
1056 print(f
" Docstrings: {summary['fixes']['docstring']}")
1057 print(f
" _dbg imports: {summary['fixes']['dbg_import']}")
1058 print(f
" func patterns: {summary['fixes']['func_pattern']}")
1061 print(f
"\n{'-' * 70}")
1062 print(
"Detailed results:")
1063 print(f
"{'-' * 70}\n")
1065 for result
in file_results:
1066 if result[
"changed"]
or result[
"errors"]:
1067 print(f
"{result['path']}")
1069 for fix_type, fix_result
in result[
"fixes"].items():
1070 if fix_result[
"changed"]:
1071 print(f
" [OK] {fix_type}: {', '.join(fix_result['messages'])}")
1073 for error
in result[
"errors"]:
1074 print(f
" [ERROR] {error}")
1078 print(f
"{'=' * 70}\n")
1082 ctx: FormatterContext) ->
None:
1083 """Print JSON output."""
1084 func =
"_print_json_report"
1087 "project": ctx.project_name,
1088 "oct_version": __version__,
1089 "timestamp": datetime.now().isoformat(),
1090 "dry_run": ctx.dry_run_mode,
1092 "files": file_results,
1095 print(json.dumps(output, indent=2))
1104 """Rebuild the formatter's argv from parsed *args*.
1106 Plan 213 / D-D. ``--changed`` is deliberately NOT reproduced: the
1107 dispatcher has already resolved the changed set at the workspace root
1108 and hands each lens group its own explicit file list.
1110 flags: List[str] = []
1112 flags.append(
"--fix")
1114 flags.append(
"--dry-run")
1116 flags.append(
"--json")
1118 flags.append(
"--verbose")
1123 """Fan ``oct format`` out to one engine run per owning subproject.
1125 Plan 213 / D-D, the formatter half. Returns True when the invocation
1126 fanned out (the caller then returns immediately), False to let the
1127 ordinary single-root pass run.
1129 This matters more here than for the linter: ``run_formatter`` has no
1130 path targets at all, so before 213 a workspace-root invocation walked
1131 the entire tree under ONE ``FormatterContext`` and judged every
1132 subproject's file against the workspace-root lens. In ``--fix`` mode
1133 that did not merely misreport - it REWROTE correct
1134 subproject-relative identity headers into workspace-relative ones.
1136 Only a workspace-root invocation fans out. From inside a subproject,
1137 project-root discovery already resolves to that subproject, so the
1138 lens is correct and behaviour is byte-identical to before.
1141 from oct.core.workspace
import (
1142 assign_to_subproject,
1143 find_workspace_root,
1149 ws_path = find_workspace_root(project_root)
1150 if ws_path
is None or ws_path.resolve() != project_root.resolve():
1153 workspace = load_workspace(ws_path)
1156 if not workspace.subprojects:
1159 exclude_dirs = set(LINTER_EXCLUDE_DIRS)
1160 wildcard_exclude = list(WILDCARD_EXCLUDE_DIRS)
1163 def _owned(path: Path) -> bool:
1164 return assign_to_subproject(path, workspace)
is not None
1169 if p.is_file()
and p.suffix ==
".py"
1173 print(
"No modified Python files found.", file=sys.stderr)
1175 groups: dict[Path, List[Path]] = {}
1176 for path
in changed:
1177 subproject = assign_to_subproject(path, workspace)
1179 subproject.path
if subproject
is not None else workspace.root
1181 groups.setdefault(root, []).append(path)
1182 for group_root, group_files
in groups.items():
1184 group_root, list(flags),
1185 _files=group_files, _dispatch=
False,
1191 for subproject
in workspace.subprojects:
1192 if not subproject.path.is_dir():
1194 run_formatter(subproject.path, list(flags), _dispatch=
False)
1198 for path
in find_python_files(
1199 workspace.root, exclude_dirs, wildcard_exclude,
1205 workspace.root, list(flags),
1206 _files=leftovers, _dispatch=
False,
1213 argv: Optional[List[str]] =
None,
1215 _files: Optional[List[Path]] =
None,
1216 _dispatch: bool =
True,
1219 Run the Option C formatter on the given project root.
1224 The detected Option C project root directory.
1225 argv : list[str] | None
1226 Optional list of command-line arguments.
1227 If None, argparse will use sys.argv[1:].
1228 _files : list[Path] | None
1229 Plan 213 internal. An explicit file list that overrides scope
1230 discovery, used by the workspace lens dispatcher to hand a lens
1231 group exactly the files it owns.
1233 Plan 213 internal re-entrancy guard. The lens dispatcher re-invokes
1234 this function once per owning subproject with ``_dispatch=False``
1235 so the per-group runs cannot fan out again.
1237 func =
"run_formatter"
1238 _dbg(
"OCT.FORMATTER", func, f
"format run starting at {project_root}", 3)
1241 parser = argparse.ArgumentParser(
1242 description=
"Auto-fix Option C compliance violations",
1245 parser.add_argument(
1247 action=
"store_true",
1248 help=
"Apply formatting changes. Without this, only reports what would change.",
1250 parser.add_argument(
1252 action=
"store_true",
1253 help=
"Report what would change without modifying files (default behavior).",
1255 parser.add_argument(
1257 action=
"store_true",
1258 help=
"Output machine-readable JSON instead of colorized terminal report.",
1260 parser.add_argument(
1262 action=
"store_true",
1263 help=
"Show per-file details in addition to summary.",
1265 parser.add_argument(
1267 action=
"store_true",
1268 help=
"Format only git-modified Python files.",
1271 args = parser.parse_args(argv)
1280 diagnostics_dir = project_root /
"oc_diagnostics"
1281 tests_dir = project_root /
"tests"
1282 docs_dir = project_root /
"docs"
1285 exclude_dirs = set(LINTER_EXCLUDE_DIRS)
1286 wildcard_exclude = list(WILDCARD_EXCLUDE_DIRS)
1290 max_backups_per_file = _DEFAULT_MAX_BACKUPS_PER_FILE
1291 from oct.core.octrc
import load_octrc_safe
1292 octrc = load_octrc_safe(project_root)
1293 linter_cfg = octrc.get(
"linter", {})
1294 if isinstance(linter_cfg, dict):
1295 for d
in linter_cfg.get(
"exclude_dirs_add", []):
1297 for d
in linter_cfg.get(
"exclude_dirs_remove", []):
1298 exclude_dirs.discard(d)
1299 for d
in linter_cfg.get(
"wildcard_exclude_add", []):
1300 if d
not in wildcard_exclude:
1301 wildcard_exclude.append(d)
1305 max_archive_bytes = _DEFAULT_MAX_ARCHIVE_BYTES
1306 formatter_cfg = octrc.get(
"formatter", {})
1307 if isinstance(formatter_cfg, dict):
1308 raw = formatter_cfg.get(
"max_backups_per_file")
1309 if isinstance(raw, int)
and raw >= 0:
1310 max_backups_per_file = raw
1311 raw_mib = formatter_cfg.get(
"max_archive_mib")
1312 if isinstance(raw_mib, (int, float))
and raw_mib >= 0:
1313 max_archive_bytes = int(raw_mib * 1024 * 1024)
1316 project_root=project_root,
1317 project_name=project_root.name,
1318 diagnostics_dir=diagnostics_dir,
1319 tests_dir=tests_dir,
1322 dry_run_mode=
not args.fix,
1323 json_mode=args.json,
1324 verbose=args.verbose,
1325 max_backups_per_file=max_backups_per_file,
1326 max_archive_bytes=max_archive_bytes,
1329 if _files
is not None:
1331 python_files = list(_files)
1340 p
for p
in python_files
1341 if p.is_file()
and not any(
1342 is_excluded_dir(Path(part), exclude_dirs, wildcard_exclude)
1343 for part
in p.relative_to(project_root).parts[:-1]
1346 if not python_files:
1348 print(
"No modified Python files found.", file=sys.stderr)
1351 python_files = find_python_files(project_root, exclude_dirs, wildcard_exclude)
1354 for py_file
in python_files:
1356 file_results.append(result)
1368if __name__ ==
"__main__":
1369 project_root = find_project_root(Path.cwd())