From 805a06197bf13878231410c5a4b3a92f094dafca Mon Sep 17 00:00:00 2001 From: Ed_ Date: Fri, 26 Jun 2026 05:46:06 -0400 Subject: [PATCH] feat(models,project_manager): add ProjectContext + 5 sub-dataclasses (Phase 2 / VC8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2: Fix flat_config to return typed ProjectContext (FR8 / VC8) Before: def flat_config(...) -> Metadata (returned dict[str, Any]) After: def flat_config(...) -> ProjectContext (typed fat struct) Delta: -1 anonymous dict return type; +6 new dataclasses Per SPEC_CORRECTION_phase_2.md, this is Option A (incremental): - Add 6 sub-dataclasses: ProjectMeta, ProjectOutput, ProjectFiles, ProjectScreenshots, ProjectDiscussion, ProjectContext - Each matches the nested dict shape of flat_config()'s actual return - ProjectContext has dict-compat methods (__getitem__ + get) so consumers using .get() / [] continue to work unchanged - ProjectContext.to_dict() returns the legacy dict shape for migration - EMPTY_PROJECT_CONTEXT sentinel exported File locations per spec: - src/models.py: 6 new dataclasses + EMPTY_PROJECT_CONTEXT sentinel - src/project_manager.py: flat_config body rewritten to construct ProjectContext from the proj dict (typed return type) - tests/test_project_context_20260627.py: NEW regression-guard test file with 10 tests covering: imports, return type, zero defaults, full input, dict-compat __getitem__/get, to_dict round-trip, sentinel, output_dir required field, consumer patterns unchanged Verification: - audit_weak_types --strict: OK (96 <= 112 baseline; down from 107) - generate_type_registry: 23 files regenerated - 10 test_project_context_20260627 tests PASS - All existing consumer tests pass (test_context_composition_decoupled: 2, test_orchestrator_pm: 3, test_orchestration_logic: 8, test_orchestrator_pm_history + test_context_preview_button: 7, test_project_manager_tracks: 4, test_track_state_persistence: 1) VC8 (corrected) verification: - flat_config returns ProjectContext (typed) ✓ - All 6 sub-dataclasses exist + importable ✓ - Dict-compat methods (ctx["key"], ctx.get("key")) work ✓ - output_dir REQUIRED field defaults to "" (empty, but valid) ✓ - Consumer patterns (ctx.get("output", {}).get("namespace", "project")) work unchanged via dict-compat ✓ Phase 2 IS COMPLETE. --- docs/type_registry/index.md | 6 + docs/type_registry/src_models.md | 100 +++++++++++++--- docs/type_registry/src_rag_engine.md | 1 + src/models.py | 83 +++++++++++++ src/project_manager.py | 43 +++++-- tests/test_project_context_20260627.py | 158 +++++++++++++++++++++++++ 6 files changed, 361 insertions(+), 30 deletions(-) create mode 100644 tests/test_project_context_20260627.py diff --git a/docs/type_registry/index.md b/docs/type_registry/index.md index 6b145a56..7a8521a4 100644 --- a/docs/type_registry/index.md +++ b/docs/type_registry/index.md @@ -65,6 +65,12 @@ Generated by `scripts/generate_type_registry.py`. Re-run the script (or invoke ` - `MCPConfiguration` (dataclass) - [`src\models.py`](src\models.md#src\models.py::MCPConfiguration) - `VectorStoreConfig` (dataclass) - [`src\models.py`](src\models.md#src\models.py::VectorStoreConfig) - `RAGConfig` (dataclass) - [`src\models.py`](src\models.md#src\models.py::RAGConfig) +- `ProjectMeta` (dataclass) - [`src\models.py`](src\models.md#src\models.py::ProjectMeta) +- `ProjectOutput` (dataclass) - [`src\models.py`](src\models.md#src\models.py::ProjectOutput) +- `ProjectFiles` (dataclass) - [`src\models.py`](src\models.md#src\models.py::ProjectFiles) +- `ProjectScreenshots` (dataclass) - [`src\models.py`](src\models.md#src\models.py::ProjectScreenshots) +- `ProjectDiscussion` (dataclass) - [`src\models.py`](src\models.md#src\models.py::ProjectDiscussion) +- `ProjectContext` (dataclass) - [`src\models.py`](src\models.md#src\models.py::ProjectContext) - `ToolCallFunction` (dataclass) - [`src\openai_schemas.py`](src\openai_schemas.md#src\openai_schemas.py::ToolCallFunction) - `ToolCall` (dataclass) - [`src\openai_schemas.py`](src\openai_schemas.md#src\openai_schemas.py::ToolCall) - `ChatMessage` (dataclass) - [`src\openai_schemas.py`](src\openai_schemas.md#src\openai_schemas.py::ChatMessage) diff --git a/docs/type_registry/src_models.md b/docs/type_registry/src_models.md index 56b3d78b..039c2121 100644 --- a/docs/type_registry/src_models.md +++ b/docs/type_registry/src_models.md @@ -1,11 +1,11 @@ # Module: `src\models.py` -Auto-generated from source. 22 struct(s) defined in this module. +Auto-generated from source. 28 struct(s) defined in this module. ## `src\models.py::BiasProfile` **Kind:** `dataclass` -**Defined at:** line 662 +**Defined at:** line 666 **Fields:** - `name: str` @@ -16,7 +16,7 @@ Auto-generated from source. 22 struct(s) defined in this module. ## `src\models.py::ContextFileEntry` **Kind:** `dataclass` -**Defined at:** line 873 +**Defined at:** line 881 **Fields:** - `path: str` @@ -30,7 +30,7 @@ Auto-generated from source. 22 struct(s) defined in this module. ## `src\models.py::ContextPreset` **Kind:** `dataclass` -**Defined at:** line 927 +**Defined at:** line 935 **Fields:** - `name: str` @@ -42,7 +42,7 @@ Auto-generated from source. 22 struct(s) defined in this module. ## `src\models.py::ExternalEditorConfig` **Kind:** `dataclass` -**Defined at:** line 718 +**Defined at:** line 722 **Fields:** - `editors: Dict[str, TextEditorConfig]` @@ -52,7 +52,7 @@ Auto-generated from source. 22 struct(s) defined in this module. ## `src\models.py::FileItem` **Kind:** `dataclass` -**Defined at:** line 528 +**Defined at:** line 532 **Fields:** - `path: str` @@ -70,7 +70,7 @@ Auto-generated from source. 22 struct(s) defined in this module. ## `src\models.py::MCPConfiguration` **Kind:** `dataclass` -**Defined at:** line 992 +**Defined at:** line 1000 **Fields:** - `mcpServers: Dict[str, MCPServerConfig]` @@ -79,7 +79,7 @@ Auto-generated from source. 22 struct(s) defined in this module. ## `src\models.py::MCPServerConfig` **Kind:** `dataclass` -**Defined at:** line 959 +**Defined at:** line 967 **Fields:** - `name: str` @@ -105,7 +105,7 @@ Auto-generated from source. 22 struct(s) defined in this module. ## `src\models.py::NamedViewPreset` **Kind:** `dataclass` -**Defined at:** line 902 +**Defined at:** line 910 **Fields:** - `name: str` @@ -117,7 +117,7 @@ Auto-generated from source. 22 struct(s) defined in this module. ## `src\models.py::Persona` **Kind:** `dataclass` -**Defined at:** line 755 +**Defined at:** line 763 **Fields:** - `name: str` @@ -132,17 +132,83 @@ Auto-generated from source. 22 struct(s) defined in this module. ## `src\models.py::Preset` **Kind:** `dataclass` -**Defined at:** line 587 +**Defined at:** line 591 **Fields:** - `name: str` - `system_prompt: str` +## `src\models.py::ProjectContext` + +**Kind:** `dataclass` +**Defined at:** line 1137 +**Summary:** Typed return type for project_manager.flat_config(). + +**Fields:** +- `project: ProjectMeta` +- `output: ProjectOutput` +- `files: ProjectFiles` +- `screenshots: ProjectScreenshots` +- `context_presets: Metadata` +- `discussion: ProjectDiscussion` + + +## `src\models.py::ProjectDiscussion` + +**Kind:** `dataclass` +**Defined at:** line 1131 + +**Fields:** +- `roles: tuple[str, ...]` +- `history: tuple[str, ...]` + + +## `src\models.py::ProjectFiles` + +**Kind:** `dataclass` +**Defined at:** line 1119 + +**Fields:** +- `base_dir: str` +- `paths: tuple[str, ...]` + + +## `src\models.py::ProjectMeta` + +**Kind:** `dataclass` +**Defined at:** line 1106 + +**Fields:** +- `name: str` +- `summary_only: bool` +- `execution_mode: str` + + +## `src\models.py::ProjectOutput` + +**Kind:** `dataclass` +**Defined at:** line 1113 + +**Fields:** +- `namespace: str` +- `output_dir: str` + + +## `src\models.py::ProjectScreenshots` + +**Kind:** `dataclass` +**Defined at:** line 1125 + +**Fields:** +- `base_dir: str` +- `paths: tuple[str, ...]` + + ## `src\models.py::RAGConfig` **Kind:** `dataclass` -**Defined at:** line 1047 +**Defined at:** line 1055 **Fields:** - `enabled: bool` @@ -155,7 +221,7 @@ Auto-generated from source. 22 struct(s) defined in this module. ## `src\models.py::TextEditorConfig` **Kind:** `dataclass` -**Defined at:** line 691 +**Defined at:** line 695 **Fields:** - `name: str` @@ -199,7 +265,7 @@ Auto-generated from source. 22 struct(s) defined in this module. ## `src\models.py::Tool` **Kind:** `dataclass` -**Defined at:** line 607 +**Defined at:** line 611 **Fields:** - `name: str` @@ -211,7 +277,7 @@ Auto-generated from source. 22 struct(s) defined in this module. ## `src\models.py::ToolPreset` **Kind:** `dataclass` -**Defined at:** line 637 +**Defined at:** line 641 **Fields:** - `name: str` @@ -243,7 +309,7 @@ Auto-generated from source. 22 struct(s) defined in this module. ## `src\models.py::VectorStoreConfig` **Kind:** `dataclass` -**Defined at:** line 1011 +**Defined at:** line 1019 **Fields:** - `provider: str` @@ -270,7 +336,7 @@ Auto-generated from source. 22 struct(s) defined in this module. ## `src\models.py::WorkspaceProfile` **Kind:** `dataclass` -**Defined at:** line 844 +**Defined at:** line 852 **Fields:** - `name: str` diff --git a/docs/type_registry/src_rag_engine.md b/docs/type_registry/src_rag_engine.md index aed7578c..ac39943b 100644 --- a/docs/type_registry/src_rag_engine.md +++ b/docs/type_registry/src_rag_engine.md @@ -8,6 +8,7 @@ Auto-generated from source. 1 struct(s) defined in this module. **Defined at:** line 20 **Fields:** +- `id: str` - `document: str` - `path: str` - `score: float` diff --git a/src/models.py b/src/models.py index e2b1e8a6..c49b93bb 100644 --- a/src/models.py +++ b/src/models.py @@ -1099,3 +1099,86 @@ def load_mcp_config(path: str) -> MCPConfiguration: return MCPConfiguration() #endregion: MCP Config +#region: Project Context (Phase 2 dataclasses for cruft_elimination_20260627) + + +@dataclass(frozen=True, slots=True) +class ProjectMeta: + name: str = "" + summary_only: bool = False + execution_mode: str = "standard" + + +@dataclass(frozen=True, slots=True) +class ProjectOutput: + namespace: str = "project" + output_dir: str = "" + + +@dataclass(frozen=True, slots=True) +class ProjectFiles: + base_dir: str = "" + paths: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class ProjectScreenshots: + base_dir: str = "." + paths: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class ProjectDiscussion: + roles: tuple[str, ...] = () + history: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class ProjectContext: + """Typed return type for project_manager.flat_config(). + Replaces the dict[str, Any] that flat_config() returned. + Per conductor/tracks/cruft_elimination_20260627/SPEC_CORRECTION_phase_2.md.""" + project: ProjectMeta = field(default_factory=ProjectMeta) + output: ProjectOutput = field(default_factory=ProjectOutput) + files: ProjectFiles = field(default_factory=ProjectFiles) + screenshots: ProjectScreenshots = field(default_factory=ProjectScreenshots) + context_presets: Metadata = field(default_factory=dict) + discussion: ProjectDiscussion = field(default_factory=ProjectDiscussion) + + def to_dict(self) -> Metadata: + return { + "project": { + "name": self.project.name, + "summary_only": self.project.summary_only, + "execution_mode": self.project.execution_mode, + }, + "output": { + "namespace": self.output.namespace, + "output_dir": self.output.output_dir, + }, + "files": { + "base_dir": self.files.base_dir, + "paths": list(self.files.paths), + }, + "screenshots": { + "base_dir": self.screenshots.base_dir, + "paths": list(self.screenshots.paths), + }, + "context_presets": dict(self.context_presets), + "discussion": { + "roles": list(self.discussion.roles), + "history": list(self.discussion.history), + }, + } + + def __getitem__(self, key: str) -> Any: + return self.to_dict()[key] + + def get(self, key: str, default: Any = None) -> Any: + return self.to_dict().get(key, default) + + +EMPTY_PROJECT_CONTEXT: ProjectContext = ProjectContext() + + +#endregion: Project Context diff --git a/src/project_manager.py b/src/project_manager.py index b2b7e70d..c59f198d 100644 --- a/src/project_manager.py +++ b/src/project_manager.py @@ -265,8 +265,12 @@ def migrate_from_legacy_config(cfg: Metadata) -> Metadata: return proj # ── flat config for aggregate.run() ───────────────────────────────────────── -def flat_config(proj: Metadata, disc_name: Optional[str] = None, track_id: Optional[str] = None) -> Metadata: - """Return a flat config dict compatible with aggregate.run().""" +def flat_config(proj: Metadata, disc_name: Optional[str] = None, track_id: Optional[str] = None) -> "ProjectContext": + """Return a typed ProjectContext compatible with aggregate.run(). + The returned dataclass supports dict-compat (__getitem__ / get) so + existing consumers using .get() and [] continue to work unchanged + (Phase 2 Option A per SPEC_CORRECTION_phase_2.md).""" + from src.models import ProjectContext, ProjectMeta, ProjectOutput, ProjectFiles, ProjectScreenshots, ProjectDiscussion disc_sec = proj.get("discussion", {}) if track_id: history = load_track_history(track_id, proj.get("files", {}).get("base_dir", ".")) @@ -274,17 +278,30 @@ def flat_config(proj: Metadata, disc_name: Optional[str] = None, track_id: Optio name = disc_name or disc_sec.get("active", "main") disc_data = disc_sec.get("discussions", {}).get(name, {}) history = disc_data.get("history", []) - return { - "project": proj.get("project", {}), - "output": proj.get("output", {}), - "files": proj.get("files", {}), - "screenshots": proj.get("screenshots", {}), - "context_presets": proj.get("context_presets", {}), - "discussion": { - "roles": disc_sec.get("roles", []), - "history": history, - }, - } + return ProjectContext( + project=ProjectMeta( + name=proj.get("project", {}).get("name", ""), + summary_only=proj.get("project", {}).get("summary_only", False), + execution_mode=proj.get("project", {}).get("execution_mode", "standard"), + ), + output=ProjectOutput( + namespace=proj.get("output", {}).get("namespace", "project"), + output_dir=proj.get("output", {}).get("output_dir", ""), + ), + files=ProjectFiles( + base_dir=proj.get("files", {}).get("base_dir", ""), + paths=tuple(proj.get("files", {}).get("paths", [])), + ), + screenshots=ProjectScreenshots( + base_dir=proj.get("screenshots", {}).get("base_dir", "."), + paths=tuple(proj.get("screenshots", {}).get("paths", [])), + ), + context_presets=dict(proj.get("context_presets", {})), + discussion=ProjectDiscussion( + roles=tuple(disc_sec.get("roles", [])), + history=tuple(history), + ), + ) # ── track state persistence ───────────────────────────────────────────────── def save_track_state(track_id: str, state: 'TrackState', base_dir: Union[str, Path] = ".") -> None: diff --git a/tests/test_project_context_20260627.py b/tests/test_project_context_20260627.py new file mode 100644 index 00000000..2a1e4bfc --- /dev/null +++ b/tests/test_project_context_20260627.py @@ -0,0 +1,158 @@ +"""Phase 2 regression-guard tests for cruft_elimination_20260627. + +Per SPEC_CORRECTION_phase_2.md acceptance criteria: +- VC8 (corrected): flat_config returns typed ProjectContext +- VC8 (corrected): All 6 sub-dataclasses exist +- VC8 (corrected): Consumers unchanged (Option A) - existing tests pass +- VC8 (corrected): Dict-compat works (ctx.get() / ctx[]) +- VC8 (corrected): output_dir REQUIRED field works (zero default is OK) +""" +from __future__ import annotations +import pytest + +from src.project_manager import flat_config +from src.models import ( + ProjectContext, ProjectMeta, ProjectOutput, ProjectFiles, + ProjectScreenshots, ProjectDiscussion, EMPTY_PROJECT_CONTEXT, +) + + +def test_all_six_sub_dataclasses_importable() -> None: + """All 6 sub-dataclasses are importable from src.models.""" + assert isinstance(ProjectMeta, type) + assert isinstance(ProjectOutput, type) + assert isinstance(ProjectFiles, type) + assert isinstance(ProjectScreenshots, type) + assert isinstance(ProjectDiscussion, type) + assert isinstance(ProjectContext, type) + assert isinstance(EMPTY_PROJECT_CONTEXT, ProjectContext) + + +def test_flat_config_returns_project_context() -> None: + """VC8 (corrected): flat_config returns ProjectContext (typed).""" + ctx = flat_config({}) + assert isinstance(ctx, ProjectContext) + assert isinstance(ctx.project, ProjectMeta) + assert isinstance(ctx.output, ProjectOutput) + assert isinstance(ctx.files, ProjectFiles) + assert isinstance(ctx.screenshots, ProjectScreenshots) + assert isinstance(ctx.discussion, ProjectDiscussion) + + +def test_flat_config_empty_dict_yields_zero_defaults() -> None: + """Empty dict input -> all sub-dataclass fields are zero-initialized.""" + ctx = flat_config({}) + assert ctx.project.name == "" + assert ctx.project.summary_only is False + assert ctx.project.execution_mode == "standard" + assert ctx.output.namespace == "project" + assert ctx.output.output_dir == "" # REQUIRED field, empty by default + assert ctx.files.base_dir == "" + assert ctx.files.paths == () + assert ctx.screenshots.base_dir == "." + assert ctx.screenshots.paths == () + assert ctx.discussion.roles == () + assert ctx.discussion.history == () + + +def test_flat_config_full_dict_input() -> None: + """Full dict input -> correct dataclass fields populated.""" + proj = { + "project": {"name": "test", "summary_only": True, "execution_mode": "fast"}, + "output": {"namespace": "ns1", "output_dir": "/tmp/out"}, + "files": {"base_dir": "/src", "paths": ["a.py", "b.py"]}, + "screenshots":{"base_dir": "/scr", "paths": ["s1.png"]}, + "discussion":{ + "active": "main", + "roles": ["User", "AI"], + "discussions": {"main": {"history": ["msg1", "msg2"]}}, + }, + } + ctx = flat_config(proj, disc_name="main") + assert ctx.project.name == "test" + assert ctx.project.summary_only is True + assert ctx.project.execution_mode == "fast" + assert ctx.output.namespace == "ns1" + assert ctx.output.output_dir == "/tmp/out" + assert ctx.files.base_dir == "/src" + assert ctx.files.paths == ("a.py", "b.py") + assert ctx.screenshots.base_dir == "/scr" + assert ctx.screenshots.paths == ("s1.png",) + assert ctx.discussion.roles == ("User", "AI") + assert ctx.discussion.history == ("msg1", "msg2") + + +def test_flat_config_dict_compat_getitem() -> None: + """ctx[\"key\"] returns the same shape as the legacy dict.""" + proj = {"output": {"output_dir": "/out", "namespace": "ns1"}} + ctx = flat_config(proj) + assert ctx["output"] == {"namespace": "ns1", "output_dir": "/out"} + assert ctx["files"] == {"base_dir": "", "paths": []} + assert ctx["project"] == {"name": "", "summary_only": False, "execution_mode": "standard"} + + +def test_flat_config_dict_compat_get() -> None: + """ctx.get(\"key\", default) returns dict value or default.""" + ctx = flat_config({}) + assert ctx.get("output") == {"namespace": "project", "output_dir": ""} + assert ctx.get("missing_key") is None + assert ctx.get("missing_key", "fallback") == "fallback" + + +def test_flat_config_to_dict_round_trip() -> None: + """to_dict() returns the same shape as the legacy flat_config() dict.""" + proj = { + "project": {"name": "p", "summary_only": False, "execution_mode": "std"}, + "output": {"namespace": "ns", "output_dir": "/o"}, + "files": {"base_dir": "/s", "paths": ["x"]}, + "screenshots":{"base_dir": "/sc", "paths": ["i"]}, + "context_presets": {"preset_a": {"name": "preset_a"}}, + "discussion": { + "roles": ["User"], + "active": "main", + "discussions": {"main": {"history": ["h1"]}}, + }, + } + ctx = flat_config(proj, disc_name="main") + d = ctx.to_dict() + assert d["project"] == {"name": "p", "summary_only": False, "execution_mode": "std"} + assert d["output"] == {"namespace": "ns", "output_dir": "/o"} + assert d["files"] == {"base_dir": "/s", "paths": ["x"]} + assert d["screenshots"] == {"base_dir": "/sc", "paths": ["i"]} + assert d["context_presets"] == {"preset_a": {"name": "preset_a"}} + assert d["discussion"] == {"roles": ["User"], "history": ["h1"]} + + +def test_empty_project_context_sentinel() -> None: + """EMPTY_PROJECT_CONTEXT is a zero-init ProjectContext.""" + assert isinstance(EMPTY_PROJECT_CONTEXT, ProjectContext) + assert EMPTY_PROJECT_CONTEXT.project.name == "" + assert EMPTY_PROJECT_CONTEXT.output.output_dir == "" + assert EMPTY_PROJECT_CONTEXT.files.paths == () + assert EMPTY_PROJECT_CONTEXT.screenshots.base_dir == "." + assert EMPTY_PROJECT_CONTEXT.discussion.roles == () + + +def test_output_dir_required_field_zero_default() -> None: + """output_dir is a REQUIRED field per spec; zero default (empty str) is + acceptable. aggregate.run would fail with a clear error when output_dir + is empty (existing behavior, not a regression).""" + ctx = flat_config({}) + assert ctx.output.output_dir == "" + # Verify it's still a valid string field (not None) + assert isinstance(ctx.output.output_dir, str) + + +def test_flat_config_consumers_unchanged() -> None: + """VC8 Option A: existing consumer code patterns continue to work via + dict-compat methods (ctx.get(\"key\"), ctx[\"key\"]).""" + ctx = flat_config({}) + # Mimic consumer patterns from src/aggregate.py:484-525: + assert ctx.get("output", {}).get("namespace", "project") == "project" + assert ctx.get("files", {}).get("paths", []) == [] # list, not tuple (legacy compat) + assert ctx.get("discussion", {}).get("history", []) == [] # list, not tuple (legacy compat) + # Mimic src/app_controller.py:4026 pattern: flat[\"files\"] returns dict + flat_files = ctx["files"] + assert isinstance(flat_files, dict) + assert "base_dir" in flat_files + assert "paths" in flat_files \ No newline at end of file