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)¶
- 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. - 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.
- GREEN — Author the reference TCCM, four
_invalid/fixtures, three_floors/fixtures, and the_reference-tccm/README.md. Run the suite. - REFACTOR — Alias
TestInventoryAdapter/TestsExercising/TestIdto dodge pytest'sTest*collector warning (mirrorstests/unit/adapters/test_protocols.py+tests/unit/tccm/test_model.py). No behavior change. - Validate —
ruff 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—_dispatchoverDerivedQuerymirrors the discipline S1-01 / S1-04 already use forIndexFreshness/LoaderReason. Thecase _ as unreachable: assert_never(unreachable)arm + the repo-widemypy --warn-unreachablesetting means a sixth variant cannot land green without a correspondingcasearm. 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'sLoaderReason: 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_RecordingAdaptermixin 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
_dispatchparameters (dep: DepGraphAdapter, etc.) — undermypy --strictthe 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 ofconsumers(self, pkg: str)toconsumers(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 aliasing —
TestInventoryAdapter as InventoryAdapter,TestsExercising as ExerciseTestsQuery,TestId as InventoryTestId. Mirrors established pattern attests/unit/adapters/test_protocols.pyandtests/unit/tccm/test_model.py. Production classes unchanged.
Lessons (carry forward)¶
- L1 —
task_classassignment on a frozen Pydantic model raisesValidationErrorat runtime but is type-clean statically. Pydantic v2 frozen-check happens in__setattr__; mypy sees a normal attribute assignment with a type-compatibleTaskClassIdvalue. A# type: ignore[misc]comment is unused under mypy and gets flagged. Drop the ignore — thewith pytest.raises(ValidationError):block carries the invariant. - L2 — Pytest's
Test*collector watches imported names, not just defined classes. ImportingTestInventoryAdapterorTestsExercisinginto a test module triggersPytestCollectionWarningeven when the imported symbol is a Protocol or BaseModel subclass (because both define__init__). The fix isas-aliasing on import; the production class names stay untouched. - L3 —
mypy --stricton a tests subdirectory alone fails withimport-untypederrors because mypy resolves Python imports without the editable install when called bare. Runmypy --strict src/ tests/integration/tccm/to give mypy the package source as a sibling search root. CI'smake typecheckonly coverssrc/; AC-11b is a local-developer check. - L4 — The dispatcher ratchet fixture intentionally fails
mypy --strict. Including it in a cleanmypy --strictinvocation reports errors — that's the point. The integration test runs mypy in asubprocessand 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.