Skip to content

S2-03 — Reference TCCM + roundtrip integration — Attempt log

Story: ../S2-03-reference-tccm-roundtrip.md Status: ✅ Done (2026-05-16) TDD cycle: RED → GREEN → REFACTOR (no behavioral changes during refactor)

Attempt 1 — 2026-05-16

Plan (ReAct)

  1. Verify source-of-truth shapes before touching the YAML — the hardened story explicitly warns that S1-04 owns the variant field names and discriminator literals. Read src/codegenie/tccm/queries.py, src/codegenie/tccm/model.py, src/codegenie/tccm/loader.py, src/codegenie/result.py, src/codegenie/errors.py, src/codegenie/adapters/protocols.py, src/codegenie/adapters/confidence.py.
  2. RED — Write the test file + ratchet fixture first; assert 15+ failures from missing YAML fixtures, with the imposter-dispatch test and the ratchet test passing because they don't need on-disk fixtures.
  3. GREEN — Author the reference TCCM, four _invalid/ fixtures, three _floors/ fixtures, and the _reference-tccm/README.md. Run the suite.
  4. REFACTOR — Alias TestInventoryAdapter / TestsExercising / TestId to dodge pytest's Test* collector warning (mirrors tests/unit/adapters/test_protocols.py + tests/unit/tccm/test_model.py). No behavior change.
  5. Validateruff check, ruff format --check, mypy --strict, pytest tests/integration/tccm/.

Source-of-truth confirmation

Surface Live shape Story prescription Match?
ConsumersOf compute: Literal["consumers_of"], pkg: str; frozen=True, extra="forbid" Same
ProducersOf compute: Literal["producers_of"], pkg: str Same
ReverseLookup compute: Literal["reverse_lookup"], module: str Same
RefsTo compute: Literal["refs_to"], symbol: str Same
TestsExercising compute: Literal["tests_exercising"], symbol: str Same
TCCM five fields exactly; schema_version: Literal["1"]; frozen=True, extra="forbid" Same
TCCMLoadError marker, no .reason — reason carried as args[0] prefix Same
LoaderReason Literal["parse", "schema", "unknown_query_primitive"] Same
Result Ok / Err top-level exports; .is_ok(), .is_err(), .unwrap(), .unwrap_err() Same
AdapterConfidence Trusted() / Degraded(reason=...) / Unavailable(reason=...) Same

All four prescribed Pydantic constructors (ConsumersOf(pkg=...), ProducersOf(pkg=...), ReverseLookup(module=...), RefsTo(symbol=...), TestsExercising(symbol=...)) constructed cleanly with only the discriminator-form compute + one payload field, no name, no max_files, no extras. Validator hardening notes 1–4 (block-tier corrections) were already applied to the story; my implementation honored them.

RED — failing tests landed first

tests/integration/tccm/__init__.py (empty) and tests/integration/tccm/test_reference_tccm_roundtrips.py landed before any YAML fixture. First run: 15 failed (missing fixture file), 2 passed (imposter-dispatch and ratchet — neither reads the YAML).

GREEN — fixtures land, all 17 tests pass

YAML fixtures authored verbatim per Implementation Outline §1 / §2. README authored per §6. Second run:

collected 17 items
tests/integration/tccm/test_dispatcher_coverage_ratchet.py .             [  5%]
tests/integration/tccm/test_reference_tccm_roundtrips.py ............... [ 94%]
.                                                                        [100%]
17 passed in 1.44s

REFACTOR — pytest collection warnings silenced

The first GREEN run emitted two PytestCollectionWarning lines for TestInventoryAdapter and TestsExercising (pytest scans Test* classes for collection; importing those names into a test module triggers the collector). Aliased the imports — InventoryAdapter, ExerciseTestsQuery, InventoryTestId — to match the established pattern at tests/unit/adapters/test_protocols.py and tests/unit/tccm/test_model.py. No behavior change.

Validation

Tool Scope Result
ruff check repo-wide All checks passed
ruff format --check repo-wide 249 files already formatted
mypy --strict src/ tests/integration/tccm/ Success: no issues found in 81 source files
pytest tests/integration/tccm/ new directory 17 passed in 1.44s

The fixture tests/fixtures/mypy_warn_unreachable/incomplete_derived_query_dispatch.py is intentionally rejected by mypy --strict — that is what test_dispatcher_match_arms_are_under_mypy_warn_unreachable_ratchet verifies via subprocess (AC-13).

Acceptance criteria — runtime evidence map

AC Test
AC-1 (canonical path) test_reference_tccm_lives_under_docs_not_under_plugins
AC-2 (Counter multiset) test_reference_tccm_exercises_every_derived_query_variant
AC-3 (round-trip equality) test_reference_tccm_loads_and_equals_expected_pydantic_instance
AC-3b (every confidence_floor variant) test_confidence_floor_round_trips_for_every_variant (3-way parametrized)
AC-3c (frozen + extra=forbid) test_loaded_tccm_is_frozen_and_forbids_extras
AC-3d (JSON round-trip per variant) test_every_derived_query_round_trips_through_json
AC-4 (every Protocol method invoked) test_mock_dispatcher_invokes_every_protocol_method_at_least_once
AC-4b (Protocol-typed params) _dispatch signature uses Protocols, mypy --strict clean
AC-5 (structural Protocol conformance) isinstance(...) block inside AC-4 test
AC-6 (assert_never on imposter) test_dispatcher_match_is_exhaustive_assert_never_fires_on_smuggled_variant
AC-7 (unknown_query_primitive prefix) test_unknown_compute_primitive_returns_typed_err_prefix
AC-7b (LoaderReason taxonomy) test_invalid_fixtures_cover_loader_reason_taxonomy (4-way parametrized)
AC-8 (location discipline) test_reference_tccm_lives_under_docs_not_under_plugins (plugins walk)
AC-9 (safe_yaml parseable) test_reference_tccm_is_safe_yaml_parseable
AC-10 (README under _reference-tccm/) File _reference-tccm/README.md shipped
AC-11 (toolchain) ruff, mypy --strict, pytest all clean (above)
AC-11b (mypy --strict on test dir) mypy --strict src/ tests/integration/tccm/ → success
AC-12 (TDD discipline) This log — RED → GREEN → REFACTOR commits
AC-13 (coverage ratchet) test_dispatcher_match_arms_are_under_mypy_warn_unreachable_ratchet

Design-pattern decisions recorded

  • Sum type + exhaustive match + assert_never_dispatch over DerivedQuery mirrors the discipline S1-01 / S1-04 already use for IndexFreshness / LoaderReason. The case _ as unreachable: assert_never(unreachable) arm + the repo-wide mypy --warn-unreachable setting means a sixth variant cannot land green without a corresponding case arm. Production ADR-0030 §Consequences ratchet enforced.
  • _ProtocolMethod(StrEnum) is the typed surface. Recorder and assertion both consume the same enum — typo discipline. Adding a Protocol method adds one enum value; the assertion auto-iterates over the new surface. Mirror of S1-04's LoaderReason: TypeAlias = Literal[...] discipline; not a registry.
  • Mock-class duplication is intentional. The four _Mock* classes share an __init__ + confidence() shape that violates three-strikes on its face. Rule 3 (Surgical Changes) wins — these mocks are deleted when Phase 8's Bundle Builder ships real adapters; a _RecordingAdapter mixin would couple four one-shot test fixtures to a micro-abstraction with one consumer, then have to be ripped out at Phase 8.
  • Protocol-typed _dispatch parameters (dep: DepGraphAdapter, etc.) — under mypy --strict the call sites are checked against the Protocol signatures, not the duck-typed mocks. This is the actual Gap-1 closer for signature drift — a future change of consumers(self, pkg: str) to consumers(self, pkg: PackageId, *, transitively: bool = False) would break this file at type-check time, surfacing the drift in the in-Phase-2 anchor.
  • Pytest-collector aliasingTestInventoryAdapter as InventoryAdapter, TestsExercising as ExerciseTestsQuery, TestId as InventoryTestId. Mirrors established pattern at tests/unit/adapters/test_protocols.py and tests/unit/tccm/test_model.py. Production classes unchanged.

Lessons (carry forward)

  • L1 — task_class assignment on a frozen Pydantic model raises ValidationError at runtime but is type-clean statically. Pydantic v2 frozen-check happens in __setattr__; mypy sees a normal attribute assignment with a type-compatible TaskClassId value. A # type: ignore[misc] comment is unused under mypy and gets flagged. Drop the ignore — the with pytest.raises(ValidationError): block carries the invariant.
  • L2 — Pytest's Test* collector watches imported names, not just defined classes. Importing TestInventoryAdapter or TestsExercising into a test module triggers PytestCollectionWarning even when the imported symbol is a Protocol or BaseModel subclass (because both define __init__). The fix is as-aliasing on import; the production class names stay untouched.
  • L3 — mypy --strict on a tests subdirectory alone fails with import-untyped errors because mypy resolves Python imports without the editable install when called bare. Run mypy --strict src/ tests/integration/tccm/ to give mypy the package source as a sibling search root. CI's make typecheck only covers src/; AC-11b is a local-developer check.
  • L4 — The dispatcher ratchet fixture intentionally fails mypy --strict. Including it in a clean mypy --strict invocation reports errors — that's the point. The integration test runs mypy in a subprocess and asserts exit-non-zero + an unreachable-naming needle. Do not include the fixture in the developer's "is mypy clean?" loop; it is a test target, not a typecheck target.