This document describes a comprehensive 6-phase refactoring of the spacing tool to address critical complexity issues identified in REVIEW.md. The refactoring reduces code complexity, improves performance from O(n²)-O(n³) to O(n), and reduces the codebase from 3+ passes to exactly 2 passes.
From REVIEW.md:
- CRITICAL-002: 6-7 level nesting in rules.py functions with 10 parameters
- MAJOR-001: 692-line monolithic rules.py violates maintainability
- Current architecture: 3+ passes with O(n²)-O(n³) complexity
- Pass 2 contains 4 sub-passes: scope detection, comment detection, recursive rule application, count conversion
The current BlankLineRuleEngine.applyRules() method performs multiple backward scans for each statement:
_applyRulesAtLevel- recursive processing with backward scans_hasCompletedDefinitionBlock- scans backward to find completed blocks_isReturningFromNestedLevel- scans backward to detect scope changes_findPreviousNonBlankAtLevel- scans backward to find previous statements_convertToBlankLineCounts- final conversion pass
This creates O(n²) complexity where n is the number of statements.
Key Innovation: Pre-compute all relational information (prev/next statements, scope info, completed blocks) in ONE forward O(n) pass using a StatementContext dataclass, then apply rules using this cached context with NO backward scanning.
Target Architecture:
- Pass 1: Analyze + Build Context (analyzer.py + context.py) - O(n)
- Pass 2: Apply Rules + Reconstruct (rules/ package + processor.py) - O(n)
Module Structure:
src/spacing/
├── analyzer.py (150 lines) - Parse and analyze
├── context.py (200 lines) - NEW: Build StatementContext in one pass
├── helpers.py (80 lines) - Pure helper functions
├── rules/
│ ├── __init__.py - Export BlankLineRuleEngine
│ ├── engine.py (150 lines) - Coordinator
│ ├── comment.py (100 lines) - Comment-specific rules
│ └── definition.py (100 lines) - PEP 8/257 definition rules
├── types.py - Add StatementContext dataclass
└── processor.py (unchanged)
| Metric | Before | After | Improvement |
|---|---|---|---|
| Max nesting | 6-7 levels | 3 levels | 57-67% |
| rules.py size | 692 lines | <300 lines | 56% |
| Largest function | 125 lines | <50 lines | 60% |
| Max parameters | 10 params | 4 params | 60% |
| Pass count | 3+ passes | 2 passes | 33% |
| Time complexity | O(n²)-O(n³) | O(n) | Quadratic → Linear |
| Module count | 1 monolithic | 5 focused | Better SoC |
Duration: 2 hours | Risk: LOW
Goal: Reduce rules.py from 692 to ~600 lines with zero behavior change
Changes Made:
-
Created
src/spacing/helpers.pywith 3 pure functions:findPreviousNonBlankAtLevel(statements, fromIdx, targetIndent)- Find previous non-blank statement at target indent levelhasBodyBetween(statements, defIdx, endIdx, targetIndent)- Check if definition has indented body between indicesisClassDefinition(statement)- Check if statement is a class definition
-
Modified
src/spacing/rules.py:- Added import:
from .helpers import findPreviousNonBlankAtLevel, hasBodyBetween, isClassDefinition - Replaced 6 instances of
self._methodNamewith direct function calls - Removed 3 method definitions
- Reduced from 692 to 637 lines (55 lines removed)
- Added import:
Validation: All 219 tests pass
Rationale: Extract stateless functions that don't use self to establish foundation for further separation of concerns. These functions are pure (no side effects) and easier to test independently.
Duration: 4 hours | Risk: LOW
Goal: Add context infrastructure without breaking existing code
Changes to Make:
- Add StatementContext to types.py:
@dataclass
class StatementContext:
"""Pre-computed context for a statement to eliminate backward scanning
This dataclass caches all relational information about a statement that
would otherwise require O(n) backward scanning during rule application.
Built once in O(n) time by ContextBuilder, enabling O(1) rule lookups.
"""
index: int
statement: Statement
prevNonBlank: Statement | None = None
prevNonBlankIdx: int = -1
nextNonBlank: Statement | None = None
nextNonBlankIdx: int = -1
startsNewScope: bool = False
returningFromNestedLevel: bool = False
hasCompletedDefBefore: bool = False
hasCompletedControlBefore: bool = False
preserveBlankLines: bool = False- Create context.py with ContextBuilder:
class ContextBuilder:
"""Build StatementContext objects in one O(n) forward pass
This class pre-computes all relational information needed for blank line
rule application, eliminating the need for O(n²) backward scanning during
rule evaluation.
Key methods:
- buildContexts: Main entry point, builds all contexts in O(n)
- _initializeContexts: Create initial context objects
- _computePrevNext: Populate prev/next non-blank references
- _computeScopeInfo: Detect scope boundaries
- _computeCompletedBlocks: Identify completed definition/control blocks
- _computeCommentPreservation: Mark comments that need blank line preservation
"""
def buildContexts(self, statements: list[Statement]) -> list[StatementContext]:
"""Build context in one O(n) forward pass"""
contexts = self._initializeContexts(statements)
self._computePrevNext(statements, contexts)
self._computeScopeInfo(statements, contexts)
self._computeCompletedBlocks(statements, contexts)
self._computeCommentPreservation(statements, contexts)
return contexts
def _initializeContexts(self, statements: list[Statement]) -> list[StatementContext]:
"""Create initial context objects for each statement"""
# Implementation: Create StatementContext for each statement with index
def _computePrevNext(self, statements: list[Statement], contexts: list[StatementContext]):
"""Populate prevNonBlank and nextNonBlank references"""
# Implementation: Single forward pass to link prev/next non-blank statements
def _computeScopeInfo(self, statements: list[Statement], contexts: list[StatementContext]):
"""Detect scope boundaries (startsNewScope, returningFromNestedLevel)"""
# Implementation: Track indent levels to detect scope changes
def _computeCompletedBlocks(self, statements: list[Statement], contexts: list[StatementContext]):
"""Identify completed definition/control blocks"""
# Implementation: Mark statements that follow completed blocks
def _computeCommentPreservation(self, statements: list[Statement], contexts: list[StatementContext]):
"""Mark comments that need blank line preservation"""
# Implementation: Detect comments before module-level definitions- Add comprehensive unit tests:
test/test_context.py- Test _initializeContexts creates correct number of contexts
- Test _computePrevNext links statements correctly
- Test _computeScopeInfo detects scope changes
- Test _computeCompletedBlocks identifies completed blocks
- Test _computeCommentPreservation marks comments correctly
- Test buildContexts integration with real statement lists
Key Innovation: Pre-compute all relational information in ONE pass, eliminating O(n²) backward scanning.
Validation:
pytest test/test_context.py -v(new tests pass)pytest test/ -v(all existing tests still pass)
Rationale: Establish the foundation for context-based rule application. This phase adds infrastructure without modifying existing behavior, making it low-risk.
Duration: 6 hours | Risk: MEDIUM
Goal: Reduce from 91 lines, 6-level nesting, 10 params → 3 focused functions with 3-level max
Changes to Make:
- Create rules/comment.py:
class CommentRuleHandler:
"""Handle blank line rules for comments
Implements PEP 8 and PEP 257 rules for blank lines around comments.
All methods use guard clauses to maintain max 3-level nesting.
"""
def needsBlankAfterComment(self, fromStmt, toStmt, context, config) -> int:
"""Determine blank lines needed after a comment
Uses guard clauses for flat control flow:
1. Early return if not a comment
2. Check for comment before module-level definition
3. Check for comment preservation
4. Default return
"""
if not fromStmt.isComment:
return 0
if self._isCommentBeforeModuleLevelDef(toStmt):
return self._handleCommentBeforeDefinition(toStmt, context, config)
if context.preserveBlankLines:
return 1
return 0
def _isCommentBeforeModuleLevelDef(self, stmt) -> bool:
"""Check if statement is a module-level definition"""
return stmt.indentLevel == 0 and stmt.blockType == BlockType.DEFINITION
def _handleCommentBeforeDefinition(self, toStmt, context, config) -> int:
"""Handle blank lines for comment before definition"""
# Implementation with max 3 levels- Create rules/definition.py:
class DefinitionRuleHandler:
"""Handle blank line rules for definitions
Implements PEP 8 rules for blank lines between module-level definitions
and PEP 257 rules for blank lines after docstrings.
"""
def needsBlankAfterDefinition(self, fromStmt, toStmt, context, config) -> int:
"""Determine blank lines needed after a definition (PEP 8)"""
# Implementation: 2 blank lines between module-level definitions
def needsBlankAfterControl(self, fromStmt, toStmt, context, config) -> int:
"""Determine blank lines needed after a control block"""
# Implementation with max 3 levels
def needsBlankAfterDocstring(self, fromStmt, toStmt, context, config) -> int:
"""Determine blank lines needed after a docstring (PEP 257)"""
# Implementation: 1 blank line after docstring in class- Simplify engine.py:
def _determineBlankLine(self, context: StatementContext, prevStmt: Statement | None) -> int:
"""Determine blank lines needed before a statement
Now has 2 params (down from 10!), max 3 levels (down from 6!)
Uses guard clauses and delegates to specialized handlers:
- CommentRuleHandler for comment-related rules
- DefinitionRuleHandler for definition-related rules
"""
if context.startsNewScope:
return 0
if prevStmt is None:
return 0
if context.hasCompletedDefBefore:
return self.definitionHandler.needsBlankAfterDefinition(prevStmt, context.statement, context, self.config)
if prevStmt.isComment:
return self.commentHandler.needsBlankAfterComment(prevStmt, context.statement, context, self.config)
# ... clear dispatch logic with guard clausesMigration Strategy: Keep old code as _OLD_determineBlankLineForStatement, validate both produce identical results, then remove old code.
Validation:
pytest test/test_commentrules.py -v(new comment handler tests)pytest test/test_definitionrules.py -v(new definition handler tests)pytest test/test_rules.py -v(all existing tests pass)- Compare old vs new output on test corpus to ensure identical behavior
Rationale: This is the biggest complexity reduction. The current _determineBlankLineForStatement has:
- 10 parameters (excessive cognitive load)
- 6-level nesting (very hard to understand)
- Multiple responsibilities mixed together
By splitting into specialized handlers and using guard clauses, we reduce nesting to 3 levels and parameters to 4, making the code much easier to understand and maintain.
Duration: 4 hours | Risk: MEDIUM
Goal: Replace recursive _applyRulesAtLevel with single-pass using contexts
Changes to Make:
New Implementation in engine.py:
def applyRules(self, statements: list[Statement]) -> list[int]:
"""Apply blank line rules to statements
O(n) complexity, no recursion, no backward scans.
Uses pre-computed StatementContext to avoid backward scanning:
1. Build contexts once in O(n)
2. Apply rules in single forward pass using context
3. Return blank line counts directly (no conversion needed)
"""
if not statements:
return []
# Build contexts (O(n))
contexts = ContextBuilder().buildContexts(statements)
# Apply rules (O(n), no scans)
blankLineCounts = [0] * len(statements)
for ctx in contexts:
if ctx.statement.isBlank:
continue
if ctx.statement.skipBlankLineRules:
blankLineCounts[ctx.index] = self._countExistingBlanks(statements, ctx.index)
continue
if ctx.statement.isSecondaryClause:
blankLineCounts[ctx.index] = 0
continue
blankLineCounts[ctx.index] = self._determineBlankLine(ctx, ctx.prevNonBlank)
return blankLineCountsWhat Gets Eliminated:
- Recursive
_applyRulesAtLevel(125 lines, 6 levels nesting) - Multiple backward scans per statement
- Separate scope detection pass
- Separate comment preservation pass
_convertToBlankLineCountsmethod (115 lines) - no longer needed since we return int directly
Migration Strategy: Use feature flag USE_CONTEXT_BASED_RULES for A/B testing during development.
Validation:
pytest test/test_refactoring_validation.py -v(compare old vs new on test corpus)- Run on 50+ real Python files from various projects
- Ensure byte-for-byte identical output
Rationale: The current recursive implementation with backward scanning is the primary source of O(n²) complexity. By pre-computing context, we:
- Eliminate all backward scanning (O(n²) → O(n))
- Remove recursion (simpler control flow)
- Make the code linear and easy to understand
- Enable better testing (context can be mocked/tested independently)
Duration: 1 hour | Risk: LOW
Goal: Remove 115-line conversion step (already solved by Phase 4)
Changes to Make:
- Remove
_convertToBlankLineCountsmethod (lines 539-653 in current rules.py) - Remove old
_applyRulesAtLevelmethod (lines 412-537) - Remove tracking arrays:
shouldHaveBlankLine,doNotAlterExistingNumberOfBlankLines
Note: Phase 4's new applyRules returns list[int] directly, so no conversion is needed.
Validation: pytest test/ -v (all 219 tests pass)
Rationale: The conversion step was needed because the old implementation used boolean arrays. The new implementation directly computes the final result, eliminating this entire step.
Duration: 3 hours | Risk: LOW
Goal: Polish and document the refactored codebase
Tasks:
-
Organize into rules/ package structure:
- Move rules.py logic to rules/engine.py
- Ensure all imports work correctly
- Update init.py to export BlankLineRuleEngine
-
Update DESIGN.md:
- Replace old architecture description with new 2-pass architecture
- Document StatementContext pattern
- Update module descriptions
-
Add comprehensive module docstrings:
- context.py: Explain the caching strategy
- rules/engine.py: Explain the coordination pattern
- rules/comment.py: Explain comment-specific rules
- rules/definition.py: Explain PEP 8/257 rules
- helpers.py: Explain pure function approach
-
Reorganize tests to match new structure:
- test/test_context.py - ContextBuilder tests
- test/test_commentrules.py - CommentRuleHandler tests
- test/test_definitionrules.py - DefinitionRuleHandler tests
- test/test_engine.py - BlankLineRuleEngine integration tests
-
Performance benchmarking:
- Create benchmark suite with files of varying sizes (100, 500, 1000, 5000 lines)
- Compare old vs new performance
- Document results in DESIGN.md
-
Update CHANGELOG.md:
- Add entry for refactoring with performance improvements
Final Module Structure:
src/spacing/
├── analyzer.py (unchanged)
├── context.py (NEW, 200 lines)
├── helpers.py (NEW, 80 lines)
├── rules/
│ ├── __init__.py (10 lines) - Export BlankLineRuleEngine
│ ├── engine.py (150 lines) - Coordinator
│ ├── comment.py (100 lines) - Comment rules
│ └── definition.py (100 lines) - PEP 8/257 rules
├── types.py (modified - add StatementContext)
└── processor.py (unchanged)
Validation:
pytest test/ -v --cov=src/spacing(>90% coverage maintained)spacing src/spacing/(self-lint passes)ruff check src/spacing/(no violations)- Performance benchmark: <100ms for 1000-line file (was ~200ms)
Rationale: Clean up and document the refactored code to ensure maintainability and provide clear guidance for future contributors.
# Before each phase
pytest test/ -v > baseline_phase_N.txt
# After each phase
pytest test/ -v > after_phase_N.txt
diff baseline_phase_N.txt after_phase_N.txt # Must be identical (all 219 tests pass)USE_CONTEXT_BASED_RULES = True # Toggle for A/B testingIf validation fails:
- Analyze failures:
pytest test/ -v --tb=long - Fix if legitimate bugs, add regression tests
- If fundamental issue:
git checkout main(abandon phase, revise plan)
| Phase | Duration | Risk | Cumulative |
|---|---|---|---|
| Phase 1 | 2 hours | LOW | 2h ✓ |
| Phase 2 | 4 hours | LOW | 6h |
| Phase 3 | 6 hours | MEDIUM | 12h |
| Phase 4 | 4 hours | MEDIUM | 16h |
| Phase 5 | 1 hour | LOW | 17h |
| Phase 6 | 3 hours | LOW | 20h |
| Buffer | 10 hours | - | 30h |
- StatementContext Pattern: Pre-compute all relational info once, eliminating O(n²) scanning
- Guard Clauses: Replace deep nesting with early returns
- Strategy Classes: Separate concerns (comment, definition, helpers)
- Incremental Validation: Each phase validates before proceeding
- Feature Flags: A/B test high-risk changes (Phase 4)
- Pure Functions: Extract stateless functions for easier testing
/Users/smethelg/src/spacing/src/spacing/helpers.py✓/Users/smethelg/src/spacing/src/spacing/context.py(Phase 2)/Users/smethelg/src/spacing/src/spacing/rules/engine.py(Phase 3)/Users/smethelg/src/spacing/src/spacing/rules/comment.py(Phase 3)/Users/smethelg/src/spacing/src/spacing/rules/definition.py(Phase 3)
/Users/smethelg/src/spacing/src/spacing/types.py(Phase 2 - add StatementContext)/Users/smethelg/src/spacing/src/spacing/rules.py✓ (Phase 1) → becomes rules/engine.py (Phase 6)
/Users/smethelg/src/spacing/test/test_context.py(Phase 2)/Users/smethelg/src/spacing/test/test_commentrules.py(Phase 3)/Users/smethelg/src/spacing/test/test_definitionrules.py(Phase 3)/Users/smethelg/src/spacing/test/test_refactoring_validation.py(Phase 4)
-
Why StatementContext?
- The current code scans backward for every statement to find previous non-blank, detect scope changes, etc.
- This creates O(n²) complexity
- By pre-computing this info once, we get O(n) total complexity
-
Why NOT just optimize the current code?
- The fundamental architecture (recursive + backward scanning) is the problem
- No amount of micro-optimization will fix O(n²) complexity
- The deep nesting makes the code unmaintainable
- Separation of concerns is needed for future extensibility
-
Why 6 phases instead of all at once?
- Each phase is independently validated
- If something breaks, we know exactly where
- Low-risk phases build foundation for high-risk phases
- Allows for incremental review and adjustment
-
Why keep old code during migration?
- A/B testing ensures identical behavior
- Easier to debug differences
- Provides fallback if issues found
- Remove once confident in new implementation
-
PEP Compliance is Critical:
- PEP 8: 2 blank lines between module-level definitions
- PEP 257: Blank line after docstring in class
- These rules must be preserved exactly
- Tests specifically validate PEP compliance
-
Skip Directives Must Work:
# spacing: skip-blank-lines-beforeand# spacing: skip-blank-lines-after- These preserve existing blank lines
- Must be handled in context building (Phase 2)
-
Phase 1: ✓ COMPLETED
- helpers.py created with 3 pure functions
- rules.py reduced from 692 to 637 lines
- All 219 tests passing
-
Phase 2: IN PROGRESS
- Add StatementContext to types.py
- Create context.py with ContextBuilder
- Add tests in test/test_context.py
-
Phases 3-6: PENDING
Date: 2026-01-20
All Phases Completed Successfully:
- ✓ Phase 1: Extract pure helper functions (692→637 lines)
- ✓ Phase 2: Introduce StatementContext foundation (O(n) pre-computation)
- ✓ Phase 3: Add specialized comment and definition rule handlers
- ✓ Phase 4: Context-based applyRules (eliminate recursion and O(n²) scanning)
- ✓ Phase 5: Remove old implementation (637→248 lines, 64% total reduction)
- ✓ Phase 6: Update documentation (DESIGN.md, CHANGELOG.md)
Final Results:
- All 262 tests passing
- Time complexity: O(n²)-O(n³) → O(n)
- Code size: 692 lines → 248 lines (64% reduction)
- Max nesting: 6-7 levels → 3 levels
- Max parameters: 10 → 4
- Passes: 3+ → exactly 2
- Modules: 1 monolithic → 5 focused
Validation:
- Ran spacing on itself - all files properly formatted
- No behavior changes - byte-for-byte identical output
- All existing tests pass without modification
- New tests added for context building (16 tests)
- New tests added for rule handlers (27 tests)