Architecture Cleanup TODO¶
Created: 2025-11-10 Status: Not Started Priority: High - Affects maintainability and bug potential
Overview¶
The codebase has several architectural issues that make it difficult to maintain and prone to bugs:
- State Management Flags - Multiple overlapping execution state flags
- Program Representation Duplication - Text, AST, and serialized forms kept in sync manually
- Encapsulation Violations - UIs directly access and modify interpreter/runtime internals
Issue 1: State Management - Immutable PC Approach¶
Current State (MESSY)¶
Scattered mutable state across multiple objects:
runtime.stopped = True # In runtime.py
runtime.halted = False # Also in runtime.py
pc.halted = True # DUPLICATE in pc.py!
interpreter.stepping = True # In interpreter.py
ui.step_mode = "statement" # In UI files
Historical context: - Originally had ~8 different flags - User asked to reduce them - Some consolidation happened, but core problem remains: mutable state everywhere
Problems¶
- Duplicate state:
haltedexists in both PC and runtime - which is correct? - No clear semantics:
stoppedvshalted- what's the difference? - UIs mutate internals:
- Race conditions: Multiple places can modify same flags
- Hard to reason about: "Is it running?" requires checking 3+ flags
Proposed Solution: IMMUTABLE PC¶
Core principle: PC is the ONLY source of truth. PC is immutable. No flags anywhere else.
@dataclass(frozen=True) # Immutable!
class ProgramCounter:
line: int | None # Where we are (None = not in program)
statement: int # Which statement on the line
stop_reason: str | None # None=running, "STOP"/"END"/"ERROR"/"BREAK"/"USER"
error: ErrorInfo | None # Only if stop_reason=="ERROR"
def is_running(self) -> bool:
"""Ask the PC, not a separate flag"""
return self.stop_reason is None
def is_valid(self, program) -> bool:
"""Can we execute from current position?"""
if self.line is None:
return False
return self.line in program.lines
# Factory methods to create new PCs
@classmethod
def running_at(cls, line: int, statement: int = 0):
"""Create a PC that's running at a position"""
return cls(line=line, statement=statement, stop_reason=None, error=None)
@classmethod
def stopped(cls, line: int, statement: int, reason: str):
"""Create a stopped PC"""
return cls(line=line, statement=statement, stop_reason=reason, error=None)
@classmethod
def error(cls, line: int, statement: int, code: int, message: str, handler: int | None = None):
"""Create a PC stopped on error"""
return cls(
line=line,
statement=statement,
stop_reason="ERROR",
error=ErrorInfo(code=code, message=message, on_error_handler=handler)
)
def advance(self, new_line: int, new_statement: int):
"""Create new PC at advanced position (still running)"""
return ProgramCounter.running_at(new_line, new_statement)
def stop(self, reason: str):
"""Create new PC that's stopped at current position"""
return ProgramCounter.stopped(self.line, self.statement, reason)
def resume(self):
"""Create new PC that's running from current position"""
return ProgramCounter.running_at(self.line, self.statement)
@dataclass(frozen=True)
class ErrorInfo:
"""Error details (no position - PC has that)"""
code: int
message: str
on_error_handler: int | None # Line to jump to if ON ERROR GOTO active
Usage examples:
# Start execution
pc = ProgramCounter.running_at(line=10, statement=0)
# Advance to next statement
pc = pc.advance(line=20, statement=0)
# STOP statement executes
pc = pc.stop(reason="STOP")
# Error happens
pc = ProgramCounter.error(line=150, statement=2, code=11, message="Division by zero")
# CONT command
def cmd_cont(self):
if pc.is_running():
print("?Already running")
return
if not pc.is_valid(program):
print("?Can't continue") # Line deleted or doesn't exist
return
# Create new running PC
pc = pc.resume()
# UIs can't mutate - must create new PC
pc.line = 999 # ❌ AttributeError: can't set attribute
pc = ProgramCounter.running_at(line=999) # ✓ Correct
Benefits¶
- No duplicate state - PC is the only state, no runtime.stopped/halted
- No mutation bugs - Can't modify PC, must create new one
- UIs can't poke internals - frozen dataclass prevents it
- Clear semantics -
is_running()is the only question - Thread-safe - Immutable objects are inherently thread-safe
- Easier debugging - PC is a value, can print/compare/log it
- No "stopped vs halted" confusion - only one concept: stop_reason
Files to Modify¶
Phase 1: Create immutable PC
- [ ] src/pc.py - Rewrite as immutable dataclass with factory methods
- [ ] Add ErrorInfo dataclass
Phase 2: Update interpreter to use immutable PC
- [ ] src/interpreter.py - Replace all pc.line = X with pc = pc.advance(X)
- [ ] Remove all references to runtime.stopped/halted
- [ ] Use pc.is_running() instead of flag checks
Phase 3: Update UIs to create new PCs
- [ ] src/ui/curses_ui.py - Remove 20+ runtime.halted/stopped mutations
- [ ] src/ui/tk_ui.py - Remove state mutations
- [ ] src/ui/web/nicegui_backend.py - Remove state mutations
- [ ] All UIs: Replace mutations with PC creation
Phase 4: Remove old flags
- [ ] src/runtime.py - Delete stopped and halted fields
- [ ] Verify no remaining references
Issue 2: Program Representation Duplication¶
Current State¶
Three representations exist:
- Text (editor_lines) - What the editor displays
- Stored in:
curses_ui.editor_lines,tk_ui.editor_text -
Format: List of strings or Text widget content
-
AST (line_asts) - Parsed abstract syntax tree
- Stored in:
interactive.line_asts -
Format: Dict mapping line number → AST node
-
Serialized (statement_table) - Runtime execution form
- Stored in:
runtime.statement_table - Format: List of executable statements
Problems¶
-
Manual synchronization:
-
Sync failures create bugs:
- Editor shows line 100 but AST doesn't have it
- Runtime executes stale code from old statement_table
-
Line numbers in error messages don't match editor
-
50+ places in curses_ui.py alone juggle these representations
Proposed Solution¶
- Single source of truth: AST should be canonical
- Derived representations:
- Lazy regeneration: Only rebuild when needed
- Hide internals: Editor shouldn't access AST directly
Files to Modify¶
- Create new:
src/program_model.py- Unified program representation src/interactive.py- Replace line_asts with ProgramModelsrc/ui/curses_ui.py- Use ProgramModel API, remove direct AST accesssrc/ui/tk_ui.py- Samesrc/runtime.py- Accept prebuilt statement_table from ProgramModel
Issue 3: Encapsulation Violations¶
Current State¶
UIs directly access interpreter/runtime internals:
# curses_ui.py examples:
self.runtime.halted = False # Line 2068
self.runtime.stopped = True # Line 3694
self.runtime.pc = new_pc # (if exists)
self.program.line_asts[num] = ast # Direct dict modification
Problems¶
- No API contract - UIs can modify anything
- Breaking changes hidden - Renaming a field breaks all UIs
- State invariants violated - PC set without updating NPC, etc.
- Testing nightmare - Can't test interpreter without UI
Proposed Solution¶
-
Define public API for interpreter/runtime:
-
Make fields private (prefix with
_) - Provide accessor methods for legitimate UI needs
- Document the contract in docstrings
Files to Modify¶
src/interpreter.py- Make fields private, add public methodssrc/runtime.py- Samesrc/interactive.py- Same- All UIs - Update to use public API only
Implementation Plan¶
Phase 1: Documentation (1-2 days)¶
- Document current state semantics
- Create state diagrams
- List all encapsulation violations
Phase 2: State Management (3-5 days)¶
- Add state management methods to runtime
- Update all callers to use methods
- Remove direct flag access
- Add tests for state transitions
Phase 3: Program Representation (5-7 days)¶
- Design ProgramModel class
- Migrate interactive.py to use ProgramModel
- Update UIs to use ProgramModel API
- Remove direct AST/line_asts access
Phase 4: Encapsulation (3-5 days)¶
- Define public APIs
- Make internal fields private
- Update all UIs to use public API
- Add API documentation
Phase 5: Testing & Validation (2-3 days)¶
- Write unit tests for new APIs
- Integration tests for UI interactions
- Manual testing of all UIs
- Performance regression testing
Total Estimated Time: 14-22 days
Success Metrics¶
After completion:
- [ ] Zero direct assignments to runtime.stopped/halted outside runtime.py
- [ ] Single source of truth for program representation
- [ ] All interpreter/runtime fields are private (_ prefix)
- [ ] UIs only call public methods, never access internal state
- [ ] State transitions documented and enforced
- [ ] Consistency checker finds fewer "unclear interaction" issues
Notes¶
This refactoring will cause merge conflicts with any in-progress work. Recommend: 1. Complete all pending v20 fixes first 2. Create a feature branch for this refactoring 3. Merge incrementally (phase by phase) to catch regressions early
Related Issues¶
- Consistency checker v1-v20 repeatedly flagged state management confusion
- CONT command bug (can't detect if program was edited) - architectural issue
- PC/NPC synchronization bugs - encapsulation issue
- Editor sync bugs - program representation issue