Private
Public Access
0
0

docs(styleguide): add canonical reference for type aliases convention

This commit is contained in:
2026-06-21 12:59:41 -04:00
parent f8990dae11
commit 7a52fca588
+319
View File
@@ -0,0 +1,319 @@
# Type Aliases Convention
> **Status:** Active convention as of 2026-06-06. Established by the `data_structure_strengthening_20260606` track.
>
> Canonical reference for all Python type-alias decisions in this codebase. Companion to `error_handling.md` (the Result convention) and `data_oriented_design.md` (the canonical DOD).
This styleguide codifies the "names for shapes" pattern: every `dict[str, Any]`, `list[dict[...]]`, or anonymous tuple return should use a named `TypeAlias` from `src/type_aliases.py`. The 10 aliases cover the 86% of common patterns.
Reference: the audit script `scripts/audit_weak_types.py` is the ground truth. The track replaced 416 weak sites across 6 high-traffic files; the audit `--strict` mode (with baseline `scripts/audit_weak_types.baseline.json`) enforces the convention going forward.
---
## The 10 Aliases (the canonical set)
`src/type_aliases.py` defines 10 `TypeAlias`es + 1 `NamedTuple`:
| Alias | Resolves to | Semantic role |
|---|---|---|
| `Metadata` | `dict[str, Any]` | The root alias; any key-value record |
| `CommsLogEntry` | `Metadata` | A single entry in the AI comms log |
| `CommsLog` | `list[CommsLogEntry]` | The comms log ring buffer |
| `HistoryMessage` | `Metadata` | A single message in the AI provider history (UI-layer) |
| `History` | `list[HistoryMessage]` | The conversation history |
| `FileItem` | `Metadata` | A single file in the context (path, content, view_mode, etc.) |
| `FileItems` | `list[FileItem]` | The most common weak pattern in the codebase |
| `ToolDefinition` | `Metadata` | A single tool definition (name, description, parameters schema) |
| `ToolCall` | `Metadata` | A single tool call from the model (id, type, function) |
| `CommsLogCallback` | `Callable[[CommsLogEntry], None]` | The callback signature for comms log updates |
Plus the NamedTuple:
| NamedTuple | Fields | Semantic role |
|---|---|---|
| `FileItemsDiff` | `refreshed: FileItems`, `changed: FileItems` | Return of `_reread_file_items_result` |
---
## The 5 Decision Patterns
### 1. Use `Metadata` for any dict-shaped record
```python
def parse_metadata(raw: str) -> Metadata:
return json.loads(raw)
def save_metadata(name: str, data: Metadata) -> None:
...
```
The alias is `dict[str, Any]` at runtime; the name documents the semantic role.
### 2. Use the more specific alias when the role is known
If the dict is specifically a comms log entry, call it `CommsLogEntry` not `Metadata`. The LLM reader (and the human reviewer) sees the role at the type level.
```python
def append_comms(entry: CommsLogEntry) -> None: ...
def get_history() -> History: ...
```
The underlying type is still `dict[str, Any]`; the alias name is the documentation.
### 3. Use `FileItems` for any list of file items
`FileItems = list[FileItem]`. The most common weak pattern in the codebase. Replace `list[dict[str, Any]]` with `FileItems` whenever the list is "files in scope for the current context".
```python
def build_aggregate(file_items: FileItems) -> str: ...
@dataclass
class Context:
files: FileItems = field(default_factory=list)
```
### 4. Use `FileItemsDiff` NamedTuple for the dual-list return pattern
When a function returns two parallel lists that mean different things, use a NamedTuple with semantic field names.
```python
class FileItemsDiff(NamedTuple):
refreshed: FileItems
changed: FileItems
def _reread_file_items_result(file_items: FileItems) -> Result[FileItemsDiff]: ...
```
Callers can unpack by position (`refreshed, changed = _reread_file_items_result(...).data`) or by name (`result.refreshed`).
### 5. Use `Optional[Alias]` for nullable fields (NOT `Optional[dict[str, Any]]`)
```python
last_error: Optional[Metadata] = None
file_items: Optional[FileItems] = None
```
The `Optional[X]` return-type ban from `error_handling.md` applies to the 3 refactored files (`mcp_client`, `ai_client`, `rag_engine`); argument types that may be `None` (caller choice) remain allowed.
---
## Decision Tree
```
Q: Is this a `dict[str, Any]` shape?
+-- yes:
| Q: What is its semantic role?
| +-- generic key-value record -> Metadata
| +-- comms log entry -> CommsLogEntry
| +-- file in the context -> FileItem
| +-- tool definition -> ToolDefinition
| +-- tool call from the model -> ToolCall
| +-- provider history message -> HistoryMessage (UI layer)
|
+-- no, it's `list[dict[...]]`:
| Q: What is the list?
| +-- comms log entries -> CommsLog
| +-- file items -> FileItems
| +-- provider history messages -> History
| +-- generic -> list[Metadata]
|
+-- no, it's a tuple return:
| Q: Are the elements semantically distinct?
| +-- yes (e.g., refreshed vs. changed) -> NamedTuple
| +-- no (positional coordinates, etc.) -> leave as tuple (rare)
|
+-- no, it's `Callable[[...], None]` for the comms log -> CommsLogCallback
```
---
## The Audit Enforcement
`scripts/audit_weak_types.py` is the ground truth for "weak types in the codebase."
**Default mode (informational):**
```bash
uv run python scripts/audit_weak_types.py
# Prints the full report. Exits 0 regardless of findings.
```
**JSON mode (for tooling):**
```bash
uv run python scripts/audit_weak_types.py --json
# Outputs the full report as JSON.
```
**Strict mode (CI gate):**
```bash
uv run python scripts/audit_weak_types.py --strict
# Exits 1 if the current count exceeds `scripts/audit_weak_types.baseline.json`.
# Wire this into CI to fail any PR that introduces new weak types.
```
**Regenerating the baseline:**
The baseline file records the post-refactor count. Regenerate it ONLY when a new track intentionally reduces the count:
```bash
uv run python scripts/audit_weak_types.py --json | \
python -c "import json, sys; d = json.load(sys.stdin); print(json.dumps({'total_weak': d['total_weak'], 'files_with_findings': d['files_with_findings'], 'by_category': d['by_category'], 'by_severity': d['by_severity']}, indent=2))" \
> scripts/audit_weak_types.baseline.json
```
---
## The Type Registry (Auto-Generated Docs)
The aliases' field information lives in `docs/type_registry/` — auto-generated by `scripts/generate_type_registry.py`. The script:
- Scans `src/` for `@dataclass`, `NamedTuple`, `TypeAlias`, and `TypedDict` definitions.
- Writes one `.md` per source file (e.g., `docs/type_registry/src_ai_client.md`).
- Writes a top-level `index.md` with the table of contents and cross-module index.
**Usage:**
```bash
# Generate / regenerate (default)
uv run python scripts/generate_type_registry.py
# CI mode; exit 1 if the registry would change
uv run python scripts/generate_type_registry.py --check
# Dry run; print what would change without writing
uv run python scripts/generate_type_registry.py --diff
```
**When the LLM needs the fields of a type:**
```bash
cat docs/type_registry/src_models.md # for src/models.py types
cat docs/type_registry/type_aliases.md # for the 10 TypeAliases
```
**The "delete to turn off" pattern** (per `feature_flags.md`): `rm -rf docs/type_registry/` disables the registry. Re-enable by running `python scripts/generate_type_registry.py`.
---
## How to Extend (Adding a New Alias)
When a new semantic role emerges (e.g., `RequestPayload`, `ResponsePayload`):
1. **Add the alias to `src/type_aliases.py`**:
```python
RequestPayload: TypeAlias = dict[str, Any]
ResponsePayload: TypeAlias = dict[str, Any]
```
2. **Add tests to `tests/test_type_aliases.py`**:
```python
def test_request_payload_alias_resolves_to_metadata() -> None:
assert type_aliases.RequestPayload == dict[str, Any]
```
3. **Import and use** in the affected files:
```python
from src.type_aliases import RequestPayload
def parse_request(raw: str) -> RequestPayload: ...
```
4. **Re-run the audit** to confirm the new alias covers the sites:
```bash
uv run python scripts/audit_weak_types.py --strict
```
5. **Re-run the type registry** to update `docs/type_registry/`:
```bash
uv run python scripts/generate_type_registry.py
```
6. **Update the audit baseline** if the count dropped:
```bash
# Regenerate the baseline (see command above)
```
---
## Anti-Patterns
**DON'T do these things:**
1. **DON'T** use `dict[str, Any]` in production code. Use `Metadata` (or a more specific alias). The audit script catches new instances.
2. **DON'T** invent ad-hoc aliases (e.g., `RequestData`, `ResponseBody`). Add them to `src/type_aliases.py` instead — that's the canonical source.
3. **DON'T** use `list[dict[str, Any]]` for file items. Use `FileItems`.
4. **DON'T** use `list[dict[str, Any]]` for comms log. Use `CommsLog`.
5. **DON'T** use `list[dict[str, Any]]` for history. Use `History`.
6. **DON'T** return anonymous tuples. Use a NamedTuple with semantic field names.
7. **DON'T** write `Optional[dict[str, Any]]`. Use `Optional[Metadata]`.
8. **DON'T** disable the audit `--strict` mode in CI. The convention is the audit.
9. **DON'T** regenerate the baseline to mask a regression. The baseline documents an achieved count; a regression means new code violated the convention.
---
## Examples (the 6 refactored files as worked examples)
**`src/ai_client.py`** (192 sites replaced):
- 6 `*_history: list[dict[str, Any]]` -> `*_history: History`
- `_comms_log: deque[dict[str, Any]]` -> `deque[CommsLogEntry]`
- `comms_log_callback: Optional[Callable[[dict[str, Any]], None]]` -> `Optional[CommsLogCallback]`
- `_reread_file_items_result(...) -> Result[FileItemsDiff]` (NamedTuple return)
- `_build_file_context_text(file_items: FileItems) -> str`
- 79 `dict[str, Any]` -> `Metadata`
- 56 `list[dict[str, Any]]` -> `list[ToolDefinition]` / `list[Metadata]`
**`src/app_controller.py`**: 62 `dict[str, Any]` -> `Metadata`; 20 `list[dict[str, Any]]` -> `list[Metadata]`; 4 `Optional[dict[str, Any]]` -> `Optional[Metadata]`.
**`src/models.py`**: 48 dataclass field types converted to `Optional[Metadata]` / `list[Metadata]`.
**`src/api_hook_client.py`**: HTTP request/response payloads use `Metadata` (the canonical "API payload" shape).
**`src/project_manager.py`**: TOML config dicts use `Metadata`; discussion entry lists use `list[Metadata]`.
**`src/aggregate.py`**: Aggregation result dicts use `Metadata`; `FileItems` for the file item lists.
---
## Coexistence with `Result[T]`
The new aliases are VALUE-LEVEL (the data inside a container). The `Result[T]` from `data_oriented_error_handling_20260606` is CONTROL-LEVEL (the success-or-failure wrapper). They compose:
```python
Result[CommsLogEntry] # a Result wrapping a single comms log entry
Result[History] # a Result wrapping a list of history messages
Result[FileItems] # a Result wrapping a list of file items
Result[FileItemsDiff] # a Result wrapping a NamedTuple
```
The aliases name the `T` in `Result[T]`; `Result` wraps the control flow. Both conventions are complementary.
---
## Why Per-Source-File Docs (vs one giant registry file)
A per-source-file layout matches the project's per-source-file guide structure (`docs/guide_ai_client.md`, `docs/guide_mcp_client.md`, etc.). The coding agent reads `docs/type_registry/src_ai_client.md` when working in `src/ai_client.py` — locality of reference. The `index.md` provides the cross-cutting view.
**The token cost per LLM query is bounded:** a typical source file's registry is 200-500 lines of markdown. The LLM reads it once and caches the schema in context. Subsequent references to the same types don't re-fetch.
---
## Cross-References
- `src/type_aliases.py` — the 10 TypeAliases + FileItemsDiff NamedTuple
- `scripts/audit_weak_types.py` — the audit script (default + `--strict` + `--json` modes)
- `scripts/audit_weak_types.baseline.json` — the post-Phase-1 baseline count
- `scripts/generate_type_registry.py` — the auto-generated docs generator
- `docs/type_registry/` — the auto-generated registry (one .md per source file + `index.md` + `type_aliases.md`)
- `conductor/code_styleguides/error_handling.md` — the `Result[T]` convention (complementary)
- `conductor/code_styleguides/data_oriented_design.md` — the canonical DOD reference
- `conductor/tracks/data_structure_strengthening_20260606/` — the track that established this convention
- `docs/guide_state_lifecycle.md``App.__getattr__`/`__setattr__` state delegation (the runtime contract the aliases preserve)