Private
Public Access
0
0

feat(models,project_manager): add ProjectContext + 5 sub-dataclasses (Phase 2 / VC8)

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.
This commit is contained in:
2026-06-26 05:46:06 -04:00
parent 7d59d3cf97
commit 805a06197b
6 changed files with 361 additions and 30 deletions
+6
View File
@@ -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)
+83 -17
View File
@@ -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`
+1
View File
@@ -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`
+83
View File
@@ -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
+30 -13
View File
@@ -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:
+158
View File
@@ -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