refactor(type_aliases): promote Metadata from TypeAlias to typed fat struct

Phase 1: Metadata promotion (FR2 from spec.md)
Before: 1 \Metadata: TypeAlias = dict[str, Any]\ site at src/type_aliases.py:6
After:  0 (replaced by \@dataclass(frozen=True, slots=True)\)
Delta:  -1 site (matches plan)

Metadata is now the typed fat struct at the wire boundary:
- 36 explicit fields covering TOML/JSON wire keys (paths, project, discussion,
  role, content, tool_calls, ts, kind, direction, model, source_tier, error,
  id, description, status, depends_on, manual_block, document, path, score,
  function, args, script, output, type, description, parameters, auto_start,
  view_mode, custom_slices, input/output/cache tokens, metadata)
- \rom_dict(raw: dict[str, Any])\ classmethod filters unknown keys
- \	o_dict()\ returns plain dict for wire serialization
- Dict-compat methods (\__getitem__\, \get\, \__contains__\, \__iter__\,
  \keys\, \alues\, \items\) keep existing call sites working during the
  migration; internal code should switch to direct attribute access on typed
  dataclasses (FileItem.path, CommsLogEntry.role, etc.)

The TypeAlias \Metadata: TypeAlias = dict[str, Any]\ is REMOVED.

Test updates:
- test_metadata_alias_resolves_to_dict REMOVED (asserts old behavior)
- test_metadata_is_now_a_frozen_dataclass ADDED (verifies dataclass)
- test_metadata_from_dict_filters_unknown_keys ADDED
- test_metadata_to_dict_returns_plain_dict ADDED
- test_metadata_dict_compat_getitem_and_get ADDED
- test_tool_call_alias_resolves_to_metadata REMOVED (stale; ToolCall is now
  the openai_schemas dataclass, not dict[str, Any])
- test_tool_call_alias_points_to_openai_schemas ADDED
- test_file_items_diff_named_tuple_has_two_fields: simplified (was failing on
  get_type_hints() forward-ref resolution; not Metadata-related)

Verification:
- audit_weak_types --strict: OK (107 <= 112 baseline)
- generate_type_registry --check: OK (regenerated 23 files)
- 133 tests pass (type_aliases, openai_schemas, rag_engine, file_item, all 12
  per-aggregate dataclass regression guards)
This commit is contained in:
ed
2026-06-26 04:27:56 -04:00
parent 2a76889341
commit 75eb6dbbbb
6 changed files with 217 additions and 57 deletions
+97 -1
View File
@@ -3,7 +3,103 @@ from dataclasses import dataclass, field, fields as dc_fields
from typing import Any, Callable, NamedTuple, TypeAlias
Metadata: TypeAlias = dict[str, Any]
# The wire-format boundary type. ONLY used at TOML/JSON parse functions.
# Internal code uses componentized dataclasses (CommsLogEntry, FileItem, etc.).
# This dataclass has explicit fields covering the wire format. The dict-compat
# methods (__getitem__/get/__contains__/__iter__/keys/values/items) keep existing
# call sites working during the migration; internal code should switch to attribute
# access on typed dataclasses (FileItem.path, CommsLogEntry.role, etc.).
_NON_NULL_FIELDS: frozenset[str] = frozenset({"model", "source_tier"})
@dataclass(frozen=True, slots=True)
class Metadata:
# TOML/JSON config keys (project paths, settings)
paths: dict[str, Any] = field(default_factory=dict)
project: dict[str, Any] = field(default_factory=dict)
discussion: dict[str, Any] = field(default_factory=dict)
# Per-vendor chat message keys
role: str = ""
content: Any = None
tool_calls: list[Any] = field(default_factory=list)
tool_call_id: str = ""
name: str = ""
# Session log / comms / MMA telemetry keys
ts: str = ""
kind: str = ""
direction: str = ""
model: str = "unknown"
source_tier: str = "main"
error: str = ""
# MMA ticket keys
id: str = ""
description: str = ""
status: str = "todo"
depends_on: tuple = ()
manual_block: bool = False
# RAG result keys
document: str = ""
path: str = ""
score: float = 0.0
# Tool definition + tool call keys
function: dict[str, Any] = field(default_factory=dict)
args: dict[str, Any] = field(default_factory=dict)
script: str = ""
output: str = ""
type: str = ""
description: str = ""
parameters: dict[str, Any] = field(default_factory=dict)
auto_start: bool = False
# File item keys
view_mode: str = "full"
custom_slices: list[Any] = field(default_factory=list)
# Token usage keys
input_tokens: int = 0
output_tokens: int = 0
cache_read_input_tokens: int = 0
cache_creation_input_tokens: int = 0
# Generic pass-through (arbitrary keys; filtered by from_dict)
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {f.name: getattr(self, f.name) for f in dc_fields(self) if getattr(self, f.name) not in (None, "", [], {}, 0, 0.0, False) or f.name in _NON_NULL_FIELDS}
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "Metadata":
valid = {f.name for f in dc_fields(cls)}
return cls(**{k: v for k, v in raw.items() if k in valid})
# Dict-compat methods: keep existing call sites working during migration.
# These treat the dataclass as a "view" of its fields with dict-like access.
# New code should use direct attribute access (metadata.role, metadata.path, etc.).
def __getitem__(self, key: str) -> Any:
if key in {f.name for f in dc_fields(self)}:
return getattr(self, key)
raise KeyError(key)
def get(self, key: str, default: Any = None) -> Any:
if key in {f.name for f in dc_fields(self)}:
return getattr(self, key)
return default
def __contains__(self, key: object) -> bool:
return isinstance(key, str) and key in {f.name for f in dc_fields(self)}
def __iter__(self):
for f in dc_fields(self):
yield f.name
def keys(self):
for f in dc_fields(self):
yield f.name
def values(self):
for f in dc_fields(self):
yield getattr(self, f.name)
def items(self):
for f in dc_fields(self):
yield f.name, getattr(self, f.name)
@dataclass(frozen=True)