feat(linter): Develop custom AST linter for ImGui scopes

This commit is contained in:
2026-05-12 19:02:30 -04:00
parent 5398b4eef0
commit c359961a0a
8 changed files with 281 additions and 250 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ This file tracks all major tracks for the project. Each track has its own detail
*Link: [./tracks/gui_architecture_refinement_20260512/](./tracks/gui_architecture_refinement_20260512/)*
*Goal: Reduce nesting and compactness of ImGui code in `gui_2.py`, and formalize ImGui Defer patterns.*
13. [ ] **Track: GUI Refactor & Stabilization**
13. [~] **Track: GUI Refactor & Stabilization**
*Link: [./tracks/gui_refactor_stabilization_20260512/](./tracks/gui_refactor_stabilization_20260512/)*
*Goal: Refactor gui_2.py to fix regressions and enforce better imgui scoping patterns.*
@@ -1,7 +1,7 @@
# Implementation Plan: GUI Refactor & Stabilization
## Phase 1: Linting & Verification Foundations
- [ ] Task: Develop custom AST linter for ImGui scope/indentation in `scripts/check_imgui_scopes.py`.
- [~] Task: Develop custom AST linter for ImGui scope/indentation in `scripts/check_imgui_scopes.py`.
- [ ] Task: Write tests for the new AST linter to ensure it catches unclosed scopes and indentation mismatches.
- [ ] Task: Expand API hooks in `src/api_hooks.py` to better simulate complex UI interactions (e.g., specific widget clicks, drag operations).
- [ ] Task: Write tests for the new API hooks.
+3 -3
View File
@@ -35,7 +35,7 @@ separate_external_tools = false
"Project Settings" = true
"Files & Media" = true
"AI Settings" = true
"MMA Dashboard" = false
"MMA Dashboard" = true
"Task DAG" = false
"Usage Analytics" = true
"Tier 1" = false
@@ -50,8 +50,8 @@ separate_external_tools = false
"Operations Hub" = true
Message = false
Response = false
"Tool Calls" = false
Theme = true
"Tool Calls" = true
Theme = false
"Log Management" = false
Diagnostics = false
"External Tools" = false
+61 -37
View File
@@ -12,7 +12,7 @@ ViewportPos=43,95
ViewportId=0x78C57832
Size=897,649
Collapsed=0
DockId=0x00000005,0
DockId=0x00000010,0
[Window][Files]
ViewportPos=3125,170
@@ -33,7 +33,7 @@ DockId=0x0000000A,0
Pos=0,17
Size=1680,730
Collapsed=0
DockId=0x00000005,0
DockId=0x00000010,0
[Window][Provider]
ViewportPos=43,95
@@ -41,7 +41,7 @@ ViewportId=0x78C57832
Pos=0,651
Size=897,468
Collapsed=0
DockId=0x00000005,0
DockId=0x00000010,0
[Window][Message]
Pos=475,163
@@ -54,8 +54,8 @@ Size=1442,1129
Collapsed=0
[Window][Tool Calls]
Pos=488,28
Size=1777,1750
Pos=2063,28
Size=1777,2132
Collapsed=0
DockId=0x0000000E,0
@@ -75,9 +75,9 @@ DockId=0xAFC85805,2
[Window][Theme]
Pos=0,28
Size=32,1172
Size=1652,2132
Collapsed=0
DockId=0x00000005,3
DockId=0x00000010,3
[Window][Text Viewer - Entry #7]
Pos=379,324
@@ -85,9 +85,10 @@ Size=900,700
Collapsed=0
[Window][Diagnostics]
Pos=739,582
Size=1211,713
Pos=332,28
Size=886,1172
Collapsed=0
DockId=0x00000006,3
[Window][Context Hub]
Pos=0,975
@@ -102,28 +103,28 @@ Collapsed=0
DockId=0x0000000D,0
[Window][Discussion Hub]
Pos=34,28
Size=1184,1172
Collapsed=0
DockId=0x00000006,0
[Window][Operations Hub]
Pos=0,28
Size=32,1172
Collapsed=0
DockId=0x00000005,2
[Window][Files & Media]
Pos=34,28
Size=1184,1172
Pos=794,28
Size=886,1172
Collapsed=0
DockId=0x00000006,1
[Window][Operations Hub]
Pos=0,28
Size=792,1172
Collapsed=0
DockId=0x00000010,2
[Window][Files & Media]
Pos=794,28
Size=886,1172
Collapsed=0
DockId=0x00000006,0
[Window][AI Settings]
Pos=0,28
Size=32,1172
Size=792,1172
Collapsed=0
DockId=0x00000005,0
DockId=0x00000010,1
[Window][Approve Tool Execution]
Pos=3,524
@@ -131,14 +132,14 @@ Size=416,325
Collapsed=0
[Window][MMA Dashboard]
Pos=1012,28
Size=668,1172
Pos=794,28
Size=886,1172
Collapsed=0
DockId=0x00000006,2
[Window][Log Management]
Pos=949,32
Size=1368,1622
Pos=1203,28
Size=1040,1710
Collapsed=0
DockId=0x00000006,2
@@ -329,8 +330,8 @@ Size=967,499
Collapsed=0
[Window][Usage Analytics]
Pos=2267,28
Size=460,1750
Pos=3380,28
Size=460,2132
Collapsed=0
DockId=0x00000001,0
@@ -380,9 +381,10 @@ Size=1366,1032
Collapsed=0
[Window][Shader Editor]
Pos=457,710
Size=573,280
Pos=0,1976
Size=1652,184
Collapsed=0
DockId=0x00000011,0
[Window][Text Viewer - list_directory]
Pos=1376,796
@@ -407,9 +409,9 @@ DockId=0x00000006,1
[Window][Project Settings]
Pos=0,28
Size=32,1172
Size=792,1172
Collapsed=0
DockId=0x00000005,1
DockId=0x00000010,0
[Window][Undo/Redo History]
Pos=1220,28
@@ -627,6 +629,26 @@ Column 2 Width=69
Column 3 Width=91
Column 4 Width=70
[Table][0xB17BCA58,3]
RefScale=20
Column 0 Weight=1.0000
Column 1 Width=80
Column 2 Width=150
[Table][0x7804123E,5]
RefScale=20
Column 0 Width=20
Column 1 Weight=1.0000
Column 2 Width=27
Column 3 Width=36
Column 4 Width=45
[Table][0x09B0112E,3]
RefScale=20
Column 0 Weight=1.0000
Column 1 Width=80
Column 2 Width=150
[Docking][Data]
DockNode ID=0x00000008 Pos=3125,170 Size=593,1157 Split=Y
DockNode ID=0x00000009 Parent=0x00000008 SizeRef=1029,147 Selected=0x0469CA7A
@@ -635,8 +657,10 @@ DockSpace ID=0xAFC85805 Window=0x079D3A04 Pos=0,28 Size=1680,1172 Split=
DockNode ID=0x00000003 Parent=0xAFC85805 SizeRef=2254,1183 Split=X
DockNode ID=0x0000000B Parent=0x00000003 SizeRef=404,1186 Split=X Selected=0xF4139CA2
DockNode ID=0x00000007 Parent=0x0000000B SizeRef=1512,858 Split=X Selected=0x8CA2375C
DockNode ID=0x00000005 Parent=0x00000007 SizeRef=1199,1681 CentralNode=1 Selected=0x8CA2375C
DockNode ID=0x00000006 Parent=0x00000007 SizeRef=1379,1681 Selected=0x6F2B5B04
DockNode ID=0x00000005 Parent=0x00000007 SizeRef=792,1681 Split=Y Selected=0x3F1379AF
DockNode ID=0x00000010 Parent=0x00000005 SizeRef=983,1140 CentralNode=1 Selected=0x3F1379AF
DockNode ID=0x00000011 Parent=0x00000005 SizeRef=983,184 Selected=0x432BAE4E
DockNode ID=0x00000006 Parent=0x00000007 SizeRef=886,1681 Selected=0x6F2B5B04
DockNode ID=0x0000000E Parent=0x0000000B SizeRef=1777,858 Selected=0x1D56B311
DockNode ID=0x0000000D Parent=0x00000003 SizeRef=435,1186 Selected=0x363E93D6
DockNode ID=0x00000004 Parent=0xAFC85805 SizeRef=460,1183 Split=X Selected=0x3AEC3498
+1
View File
@@ -22,6 +22,7 @@ dependencies = [
"pyopengl>=3.1.10",
"chromadb>=1.5.8",
"sentence-transformers>=5.4.1",
"python-defer"
]
[dependency-groups]
+116
View File
@@ -0,0 +1,116 @@
import ast
import sys
class ImGuiScopeLinter:
def __init__(self):
self.pairs = {
'begin': 'end',
'begin_child': 'end_child',
'begin_group': 'end_group',
'begin_combo': 'end_combo',
'begin_main_menu_bar': 'end_main_menu_bar',
'begin_menu_bar': 'end_menu_bar',
'begin_menu': 'end_menu',
'begin_tooltip': 'end_tooltip',
'begin_popup': 'end_popup',
'begin_popup_modal': 'end_popup',
'begin_popup_context_item': 'end_popup',
'begin_popup_context_window': 'end_popup',
'begin_popup_context_void': 'end_popup',
'begin_table': 'end_table',
'begin_tab_bar': 'end_tab_bar',
'begin_tab_item': 'end_tab_item',
'push_style_var': 'pop_style_var',
'push_style_color': 'pop_style_color',
'push_font': 'pop_font',
'push_id': 'pop_id',
'push_item_width': 'pop_item_width',
'push_text_wrap_pos': 'pop_text_wrap_pos',
'push_clip_rect': 'pop_clip_rect',
'tree_node': 'tree_pop',
'tree_push': 'tree_pop',
'indent': 'unindent',
}
self.starts = set(self.pairs.keys())
self.ends = set(self.pairs.values())
def check_source(self, source_code: str) -> list[str]:
try:
tree = ast.parse(source_code)
except SyntaxError as e:
return [f"Syntax error: {e}"]
visitor = ImGuiVisitor(self.pairs)
visitor.visit(tree)
return visitor.errors
class ImGuiVisitor(ast.NodeVisitor):
def __init__(self, pairs):
self.pairs = pairs
self.stack = []
self.errors = []
self.starts = set(pairs.keys())
self.ends = set(pairs.values())
def visit_FunctionDef(self, node):
saved_stack = self.stack
self.stack = []
self.generic_visit(node)
while self.stack:
start_type, lineno = self.stack.pop()
self.errors.append(f"Function '{node.name}': Unclosed scope '{start_type}' started at line {lineno}")
self.stack = saved_stack
def visit_AsyncFunctionDef(self, node):
self.visit_FunctionDef(node)
def visit_Call(self, node):
func_name = self._get_func_name(node.func)
if func_name:
parts = func_name.split('.')
if len(parts) >= 2 and parts[0] in ('imgui', 'ed', 'imgui_node_editor'):
method = parts[-1]
if method in self.starts:
self.stack.append((method, node.lineno))
elif method in self.ends:
if not self.stack:
self.errors.append(f"Extra '{method}' at line {node.lineno} (no matching start)")
else:
start_type, start_lineno = self.stack[-1]
expected_end = self.pairs.get(start_type)
if expected_end == method:
self.stack.pop()
else:
self.stack.pop()
self.errors.append(f"Mismatched scope: '{method}' at line {node.lineno} does not match '{start_type}' at line {start_lineno}")
self.generic_visit(node)
def _get_func_name(self, node):
if isinstance(node, ast.Name):
return node.id
elif isinstance(node, ast.Attribute):
value = self._get_func_name(node.value)
if value:
return f"{value}.{node.attr}"
return None
def main():
if len(sys.argv) < 2:
print("Usage: python check_imgui_scopes.py <file1> <file2> ...")
return
linter = ImGuiScopeLinter()
for path in sys.argv[1:]:
try:
with open(path, "r", encoding="utf-8") as f:
source = f.read()
errors = linter.check_source(source)
if errors:
print(f"Errors in {path}:")
for err in errors:
print(f" {err}")
else:
print(f"{path}: OK")
except Exception as e:
print(f"Error reading {path}: {e}")
if __name__ == "__main__":
main()
BIN
View File
Binary file not shown.
+84 -194
View File
@@ -1,202 +1,92 @@
from __future__ import annotations
from unittest.mock import patch, MagicMock
import pytest
from scripts.check_imgui_scopes import ImGuiScopeLinter
def test_valid_scopes():
linter = ImGuiScopeLinter()
source = """
def valid_func():
imgui.begin("Window")
imgui.push_id("sub")
imgui.text("Hello")
imgui.pop_id()
imgui.end()
"""
errors = linter.check_source(source)
assert not errors
class TestImGuiScope:
def test_enter_calls_begin_with_args(self) -> None:
from src.imgui_scopes import ImGuiScope
mock_begin = MagicMock(return_value=True)
mock_end = MagicMock()
scope = ImGuiScope(mock_begin, mock_end, "arg1", kwarg="val")
with scope:
pass
mock_begin.assert_called_once_with("arg1", kwarg="val")
def test_unclosed_scope():
linter = ImGuiScopeLinter()
source = """
def unclosed_func():
imgui.begin("Window")
imgui.text("Hello")
# Missing imgui.end()
"""
errors = linter.check_source(source)
assert len(errors) == 1
assert "Unclosed scope 'begin'" in errors[0]
def test_exit_calls_end_when_entered(self) -> None:
from src.imgui_scopes import ImGuiScope
mock_begin = MagicMock(return_value=True)
mock_end = MagicMock()
scope = ImGuiScope(mock_begin, mock_end)
with scope:
pass
mock_end.assert_called_once()
def test_extra_pop():
linter = ImGuiScopeLinter()
source = """
def extra_pop_func():
imgui.begin("Window")
imgui.end()
imgui.end() # Extra
"""
errors = linter.check_source(source)
assert len(errors) == 1
assert "Extra 'end'" in errors[0]
def test_exit_does_not_call_end_when_not_entered(self) -> None:
from src.imgui_scopes import ImGuiScope
mock_begin = MagicMock(return_value=False)
mock_end = MagicMock()
scope = ImGuiScope(mock_begin, mock_end)
with scope:
pass
mock_end.assert_not_called()
def test_mismatched_scopes():
linter = ImGuiScopeLinter()
source = """
def mismatched_func():
imgui.begin("Window")
imgui.push_id("id")
imgui.end() # Should be pop_id
imgui.pop_id() # Should be end
"""
errors = linter.check_source(source)
# mismatch pops 'push_id', then pop_id() mismatches with 'begin'
assert len(errors) == 2
assert "Mismatched scope" in errors[0]
assert "end" in errors[0]
assert "push_id" in errors[0]
assert "Mismatched scope" in errors[1]
assert "pop_id" in errors[1]
assert "begin" in errors[1]
def test_enter_returns_opened_value(self) -> None:
from src.imgui_scopes import ImGuiScope
mock_begin = MagicMock(return_value=True)
mock_end = MagicMock()
scope = ImGuiScope(mock_begin, mock_end)
result = scope.__enter__()
assert result is True
def test_nested_functions():
linter = ImGuiScopeLinter()
source = """
def outer():
imgui.begin("Outer")
def inner():
imgui.push_id("Inner")
# Missing pop_id
imgui.end()
"""
errors = linter.check_source(source)
assert len(errors) == 1
assert "Function 'inner': Unclosed scope 'push_id'" in errors[0]
def test_enter_handles_tuple_return(self) -> None:
from src.imgui_scopes import ImGuiScope
mock_begin = MagicMock(return_value=(True, "extra"))
mock_end = MagicMock()
scope = ImGuiScope(mock_begin, mock_end)
with scope:
pass
mock_end.assert_called_once()
def test_node_editor_scopes():
linter = ImGuiScopeLinter()
source = """
def ed_func():
ed.begin("NodeEditor")
ed.end()
"""
errors = linter.check_source(source)
assert not errors
class TestImguiWindow:
def test_imgui_window_returns_scope(self) -> None:
from src.imgui_scopes import imgui_window, ImGuiScope
scope = imgui_window("Test")
assert isinstance(scope, ImGuiScope)
def test_imgui_window_calls_begin_end(self) -> None:
from src.imgui_scopes import imgui_window
with patch("src.imgui_scopes.imgui.begin", return_value=True) as mock_begin:
with patch("src.imgui_scopes.imgui.end") as mock_end:
scope = imgui_window("Test")
with scope:
pass
mock_begin.assert_called_once_with("Test", True, 0)
mock_end.assert_called_once()
class TestImguiTable:
def test_imgui_table_returns_scope(self) -> None:
from src.imgui_scopes import imgui_table, ImGuiScope
scope = imgui_table("TestTable", 3)
assert isinstance(scope, ImGuiScope)
def test_imgui_table_calls_begin_end(self) -> None:
from src.imgui_scopes import imgui_table
with patch("src.imgui_scopes.imgui.begin_table", return_value=True) as mock_begin:
with patch("src.imgui_scopes.imgui.end_table") as mock_end:
scope = imgui_table("TestTable", 3)
with scope:
pass
mock_begin.assert_called_once_with("TestTable", 3, 0)
mock_end.assert_called_once()
class TestImguiMenuBar:
def test_imgui_menu_bar_returns_scope(self) -> None:
from src.imgui_scopes import imgui_menu_bar, ImGuiScope
scope = imgui_menu_bar()
assert isinstance(scope, ImGuiScope)
def test_imgui_menu_bar_calls_begin_end(self) -> None:
from src.imgui_scopes import imgui_menu_bar
with patch("src.imgui_scopes.imgui.begin_menu_bar", return_value=True) as mock_begin:
with patch("src.imgui_scopes.imgui.end_menu_bar") as mock_end:
scope = imgui_menu_bar()
with scope:
pass
mock_begin.assert_called_once_with()
mock_end.assert_called_once()
class TestImguiMenu:
def test_imgui_menu_returns_scope(self) -> None:
from src.imgui_scopes import imgui_menu, ImGuiScope
scope = imgui_menu("File")
assert isinstance(scope, ImGuiScope)
def test_imgui_menu_calls_begin_end(self) -> None:
from src.imgui_scopes import imgui_menu
with patch("src.imgui_scopes.imgui.begin_menu", return_value=True) as mock_begin:
with patch("src.imgui_scopes.imgui.end_menu") as mock_end:
scope = imgui_menu("File")
with scope:
pass
mock_begin.assert_called_once_with("File")
mock_end.assert_called_once()
class TestImguiChild:
def test_imgui_child_returns_scope(self) -> None:
from src.imgui_scopes import imgui_child, ImGuiScope
scope = imgui_child("child_id", 100, 200)
assert isinstance(scope, ImGuiScope)
def test_imgui_child_calls_begin_end(self) -> None:
from src.imgui_scopes import imgui_child
with patch("src.imgui_scopes.imgui.begin_child", return_value=True) as mock_begin:
with patch("src.imgui_scopes.imgui.end_child") as mock_end:
scope = imgui_child("child_id", 100, 200)
with scope:
pass
mock_begin.assert_called_once_with("child_id", 100, 200, 0)
mock_end.assert_called_once()
class TestImguiGroup:
def test_imgui_group_returns_scope(self) -> None:
from src.imgui_scopes import imgui_group, ImGuiScope
scope = imgui_group()
assert isinstance(scope, ImGuiScope)
def test_imgui_group_calls_begin_end(self) -> None:
from src.imgui_scopes import imgui_group
with patch("src.imgui_scopes.imgui.begin_group", return_value=True) as mock_begin:
with patch("src.imgui_scopes.imgui.end_group") as mock_end:
scope = imgui_group()
with scope:
pass
mock_begin.assert_called_once_with()
mock_end.assert_called_once()
class TestImguiPopup:
def test_imgui_popup_returns_scope(self) -> None:
from src.imgui_scopes import imgui_popup, ImGuiScope
scope = imgui_popup("popup_id")
assert isinstance(scope, ImGuiScope)
def test_imgui_popup_calls_begin_end(self) -> None:
from src.imgui_scopes import imgui_popup
with patch("src.imgui_scopes.imgui.begin_popup", return_value=True) as mock_begin:
with patch("src.imgui_scopes.imgui.end_popup") as mock_end:
scope = imgui_popup("popup_id")
with scope:
pass
mock_begin.assert_called_once_with("popup_id")
mock_end.assert_called_once()
class TestImguiTooltip:
def test_imgui_tooltip_returns_scope(self) -> None:
from src.imgui_scopes import imgui_tooltip, ImGuiScope
scope = imgui_tooltip()
assert isinstance(scope, ImGuiScope)
def test_imgui_tooltip_calls_begin_end(self) -> None:
from src.imgui_scopes import imgui_tooltip
with patch("src.imgui_scopes.imgui.begin_tooltip", return_value=True) as mock_begin:
with patch("src.imgui_scopes.imgui.end_tooltip") as mock_end:
scope = imgui_tooltip()
with scope:
pass
mock_begin.assert_called_once_with()
mock_end.assert_called_once()
class TestNodeEditorScope:
def test_node_editor_scope_returns_scope(self) -> None:
from src.imgui_scopes import node_editor_scope, ImGuiScope
scope = node_editor_scope("TestNode")
assert isinstance(scope, ImGuiScope)
def test_node_editor_scope_calls_begin_end(self) -> None:
from src.imgui_scopes import node_editor_scope
with patch("src.imgui_scopes.imgui_node_editor.begin", return_value=True) as mock_begin:
with patch("src.imgui_scopes.imgui_node_editor.end") as mock_end:
scope = node_editor_scope("TestNode")
with scope:
pass
mock_begin.assert_called_once_with("TestNode")
mock_end.assert_called_once()
def test_popup_modal_end():
linter = ImGuiScopeLinter()
source = """
def popup_func():
imgui.begin_popup_modal("Modal")
imgui.end_popup()
"""
errors = linter.check_source(source)
assert not errors