Story S1-05 — ADR-0033 identifier newtypes — IndexId, SkillId, TaskClassId, IndexName, ProbeId¶
Step: Step 1 — Plant new domain primitives, kernel contracts, and the nine new ADRs
Status: Done (GREEN 2026-05-15, all 9 original ACs) — HARDENED 2026-05-15 (validator); AC-1b/3a/3b/4a/5b/6a/7a/8a/9a remediation required — see "Hardening remediation" below; attempt log: _attempts/S1-05.md
Effort: S (+XS remediation)
Depends on: —
Hard requirement of: S1-04 (which imports ProbeId from codegenie.types.identifiers)
ADRs honored: production ADR-0033 (typed identifiers across module boundaries)
Validation notes (2026-05-15)¶
Hardened by phase-story-validator (scheduled task: story-validation-corrector). Full audit at _validation/S1-05-identifiers-newtypes.md. Verdict: HARDENED with remediation queue.
Findings the original draft AND the GREEN merge both missed (all routed to remediation ACs below — none retroactively invalidate the 9 original ACs):
- Block-tier —
ProbeIdis missing. S1-04 (hardened on 2026-05-15) importsProbeIdfromcodegenie.types.identifiers(S1-04 lines 51, 78, 656).grep -rn "ProbeId" src/returns zero hits at validation time. When S1-04 runs,ImportError. The Out-of-scope section of the original story falsely claimedProbeIdalready existed in Phase 0 — it does not. AddProbeId = NewType("ProbeId", str)toidentifiers.py, the package__init__.py__all__, and the test suite (AC-1b below). - Block-tier — AC-6 mypy nominal-discrimination is unverified by automation. The original test file (
test_identifiers_typecheck.py) keeps the swap lines commented out. No CI step uncomments them. A future contributor who edits the comment in (or out) gets no feedback. The single load-bearing property of the whole story — that mypy--strictrejects cross-NewType assignment — is asserted by prose, not by code. Add a subprocess meta-test (AC-6a below). - Harden —
__all__checked with ⊇ not ==. The GREEN tests useset(t.__all__) >= {...}— stowaway exports slip past. Sibling stories S1-01 / S1-03 / S1-04 all closed with==. Family symmetric discipline (AC-3a/3b below). - Harden —
try/except ImportErrormasks layout drift. AC-5's test allows bothcodegenie.probes.node_build_systemandcodegenie.probes.layer_a.node_build_system. A half-done Phase 1 reorg (both files present, one stale) passes silently. Commit to the verified-current flat path; future move is a Phase 1 ADR-0013 amendment. (AC-5b below.) - Harden — Source-scan uses regex, not AST. Multi-line
from x import (\n PackageManager,\n)style breaks the single-line regex; annotation-form rebindings (PackageManager: TypeAlias = Literal[...]) slip past the assignment regex's lookahead. AST is durable; regex is fragile. (AC-4a below.) - Harden — Module-purity invariant missing. Sibling stories S1-01 / S1-03 / S1-04 each carry one. The kernel-tier
identifiers.pyis the single most-imported module in Phase 2; any future contributor addingfrom codegenie.probes.registry import _REGISTRYto "see what probes are registered" silently pulls the probe registry into kernel-import order. (AC-9a below.) - Harden —
isinstance(x, IndexId)runtime-error undocumented.NewTypeis not a class;isinstanceraisesTypeError. The footgun is named in Notes but not enforced by a test; a future reader who writesisinstance(x, IndexId)for "validation" gets a runtime crash, not a silent always-true. Add apytest.raises(TypeError)test. (AC-8a below.) - Harden —
type(val) is strstricter thanisinstance(val, str). A wrong implIndexId = lambda s: FancyStr(s)(FancyStr(str)) passesisinstance(val, str)but breakstype(val) is str. (AC-7a below.) - Harden —
__name__of each NewType unpinned. A typoNewType("Indeex_id", str)assigned toIndexIdstill type-checks correctly (mypy uses the variable binding), but every mypy error message lies ("Indeex_id" vs ...). Pinnt.__name__ == name. (AC-1b below.) - Harden — Pairwise distinctness unasserted.
IndexId = SkillId = NewType("Id", str)(one NewType, four names) passes the GREEN test set; mypy would catch swaps between them only if they're distinct objects. (AC-1b below.) - Harden — Identity passthrough through
__init__unasserted for the four NewTypes. AC-5'sischeck coversPackageManageronly; a typo in__init__.py(from .identifiers import IndexId as IndexName) is undetectable. (AC-5b below.) - Harden — AC-9 conflates four tools into one. Failure attribution is impossible. Split per-tool (AC-9 → 9a/9b/9c/9d).
- Nit — Production ADR-0033 §1 names
TaskClass(noIdsuffix). Phase 2 settled onTaskClassIdfor naming symmetry withProbeId/SkillId/IndexId/IndexName. Deviation is deliberate; documented in Notes (no AC change). - Nit —
as X+__all__complementarity clarified in Notes. Both are needed (mypy strict ⇒ implicit_reexport=false ⇒as Xfor the type-checker contract;__all__for the runtimefrom x import *contract).
The 9 original ACs all PASS as merged. The 9 remediation ACs (1b, 3a, 3b, 4a, 5b, 6a, 7a, 8a, 9a/9b/9c/9d) are additive — they tighten the bar to family discipline without overturning anything that landed.
Hardening remediation (queued for a follow-up surgical commit)¶
These ACs are queued because the original ACs all GREEN'd; the gaps named above did not block merge. Treat the remediation as a single XS follow-up:
- [ ] AC-1b.
identifiers.pydeclares five NewTypes (addProbeId = NewType("ProbeId", str)). For each NewTypeNT:NT.__supertype__ is str,NT.__name__ == "<var name>", and the five are pairwise distinct objects (id(IndexId) != id(SkillId)etc., tested over all 10 pairs). - [ ] AC-3a.
set(codegenie.types.identifiers.__all__) == {"IndexId", "IndexName", "PackageManager", "ProbeId", "SkillId", "TaskClassId"}— exact equality, no stowaways. - [ ] AC-3b.
set(codegenie.types.__all__) == {"IndexId", "IndexName", "PackageManager", "ProbeId", "SkillId", "TaskClassId"}— exact equality. - [ ] AC-4a. Replace the regex source-scan with an AST walk (
ast.parse+ast.walk): zeroast.ClassDefnamedPackageManager; zeroast.Assign/ast.AnnAssigntargetingPackageManager; exactly oneast.ImportFromcarrying aPackageManageralias, and that alias hasasname == "PackageManager"(the redundant-asidiom mypy strict requires). - [ ] AC-5b. Remove the
try/except ImportErrorfallback fromtest_package_manager_reexported_from_phase1_adr_0013_location; commit to the single verified-current path (codegenie.probes.node_build_system). Plus: identity passthrough through__init__— for every name in{IndexId, IndexName, PackageManager, ProbeId, SkillId, TaskClassId},getattr(codegenie.types, name) is getattr(codegenie.types.identifiers, name). - [ ] AC-6a. New test file
tests/unit/types/test_identifiers_mypy_negative.pywrites a temp file with the cross-type swap (_accepts_index(s)wheres: SkillId) executable, not commented, subprocess-invokespython -m mypy --strict <tmpfile>, assertsreturncode != 0, and asserts the combined stdout/stderr contains substringsincompatible type "SkillId"ANDexpected "IndexId". This is the load-bearing CI gate for the whole story; without it AC-6 is decorative. - [ ] AC-7a.
test_newtypes_runtime_identity_to_strassertstype(NT("scip")) is str(strict — catches str subclasses) in addition toisinstance(val, str). Run over all five NewTypes. - [ ] AC-8a. A test asserts
with pytest.raises(TypeError): isinstance("scip", ids.IndexId)— pins the documented footgun (NewTypeis not a class) as enforced behavior. - [ ] AC-9a.
ruff check src/codegenie/types/ tests/unit/types/exits 0. - [ ] AC-9b.
ruff format --check src/codegenie/types/ tests/unit/types/exits 0. - [ ] AC-9c.
mypy --strict src/codegenie/types/ tests/unit/types/exits 0 on the positive-path code. - [ ] AC-9d.
pytest tests/unit/types/passes (now includes AC-6a's subprocess negative-mypy meta-test + AC-1b/4a/5b/7a/8a/9a additions).
The new test file is tests/unit/types/test_identifiers_mypy_negative.py; the existing test_identifiers.py and test_identifiers_typecheck.py are edited surgically (no rename).
Evidence (S1-05 — original 9 ACs)¶
- AC-1, AC-7 —
tests/unit/types/test_identifiers.py::test_newtypes_exist_and_are_distinct+::test_newtypes_runtime_identity_to_str(NewType objects expose__supertype__ is str;IndexId("scip") == "scip"). - AC-1 (distinct identities) —
tests/unit/types/test_identifiers.py::test_newtype_objects_are_distinct_identities. - AC-2 — Re-export at
src/codegenie/types/identifiers.py:18(from codegenie.probes.node_build_system import PackageManager as PackageManager). - AC-3 —
__all__insrc/codegenie/types/__init__.pyandidentifiers.py; pinned bytest_all_exports_include_five_namesandtest_identifiers_module_all_lists_five_names. - AC-4 — Source-scan guard
test_no_package_manager_redefinition_in_types_module(noclass PackageManager, no reassignment, exactly one import line). Hardening note: regex-based; remediation AC-4a moves to AST. - AC-5 —
test_package_manager_reexported_from_phase1_adr_0013_locationassertsids.PackageManager is P1. Hardening note: usestry/except ImportError; remediation AC-5b removes the fallback. - AC-6 —
tests/unit/types/test_identifiers_typecheck.py—mypy --strictPASS over file; commented lines pin nominal-type discrimination as prose, not as automation. Remediation AC-6a adds the subprocess meta-test that is the actual CI gate. - AC-8 — RED commit ran with
ModuleNotFoundError: No module named 'codegenie.types.identifiers'; GREEN commit makes 7 tests pass. - AC-9 —
ruff check src/ tests/clean,ruff format --checkclean,mypy --strict src/codegenie/types/ tests/unit/types/clean,pytest tests/unit/types/→ 7 passed; full suite 1605 passed. Hardening note: four tools conflated; remediation AC-9a/9b/9c/9d splits per-tool.
Precondition note (S1-05)¶
PackageManager did not previously exist as a typed alias in codegenie.probes.node_build_system; the field was typed str | None. AC-2/AC-5 require importing it. Surgical minimal fix: a PackageManager: TypeAlias = Literal["bun","pnpm","yarn-classic","yarn-berry","npm"] constant was added to src/codegenie/probes/node_build_system.py (matches the schema enum at src/codegenie/schema/probes/node_build_system.schema.json line 29). Existing signatures (str | None) were left untouched — Literal | None is a structural subtype of str | None, so no downstream signature drift. Consistent with Phase 1 ADR-0013's enumeration of variants and the story's Notes §"Phase 1 ADR-0013 location matters" allowance.
Context¶
Production ADR-0033 §3 names primitive-obsession on domain identifiers as a review-blocker pattern. Phase 2 introduces five new kernel-tier identifier families (IndexId, SkillId, TaskClassId, IndexName, ProbeId) used by every other Step 1 story (TCCM, freshness registry, depgraph registry, probe registry). PackageManager already exists from Phase 1 ADR-0013 — this story re-imports the location, never redefines it, and unit tests guard that location at the module-import boundary.
References — where to look¶
- Architecture:
../phase-arch-design.md §"Anti-patterns avoided"row "Stringly-typed identifiers" —ProbeId,TaskClassId,SkillId,PackageManagerare typed;ecosystem: PackageManageris the registry key, notecosystem: str.../phase-arch-design.md §"Data model"—TCCM.task_class: TaskClassId,required_skills: list[SkillId],required_probes: list[ProbeId],Skill.id: SkillId;IndexNameis the freshness-registry key.../phase-arch-design.md §"Component design" #11 — DepGraphProbe + strategy registry—PackageManageris imported from Phase 1 ADR-0013, not redefined.- Phase ADRs (rules this story must honor):
../ADRs/0006-index-freshness-sum-type-location.md— 02-ADR-0006 §Decisions noted —IndexNameis the registry key for@register_index_freshness_check.- Production ADRs:
../../../production/adrs/0033-domain-modeling-discipline.md— production ADR-0033 §1 — typed identifiers at module boundaries; the binding discipline. Naming note: production ADR-0033 §1 namesTaskClass(noIdsuffix). Phase 2'sTaskClassIdis a deliberate naming-symmetry choice withProbeId/SkillId/IndexId; see Notes-for-implementer.- Sibling-story cross-dependencies:
S1-04-tccm-model-loader.md §References+ lines 51 / 78 / 656 — S1-04 importsProbeId, SkillId, TaskClassIdfromcodegenie.types.identifiers. S1-04 hard-routedProbeIdthrough this story. IfProbeIdis missing fromidentifiers.py, S1-04ImportErrors at green-stage time. Remediation AC-1b closes this.- Source design:
../final-design.md §"Synthesis ledger" — ADR-0033 newtypes— the deliberate scope.- Existing code (verified at validation time 2026-05-15):
src/codegenie/probes/node_build_system.py:115— Phase 1 ADR-0013 shipsPackageManager = Literal["bun", "pnpm", "yarn-classic", "yarn-berry", "npm"](flat path; nolayer_a/subpackage exists). The story commits to this import path and treats any future move toprobes/layer_a/as a Phase 1 ADR-0013 amendment, not an in-story branch.src/codegenie/probes/base.py,src/codegenie/probes/registry.py—ProbeIddoes not exist anywhere undersrc/codegenie/(grep verified 2026-05-15). S1-04's routing comment treats S1-05 as the home; remediation AC-1b honors that.pyproject.toml [tool.mypy]—strict = true,warn_unreachable = true.strictimpliesimplicit_reexport = false, which is why the redundantas Xre-export idiom is required.- External docs (only if directly relevant):
- https://docs.python.org/3/library/typing.html#typing.NewType —
NewTypesemantics (type-checker-only; zero runtime cost;__supertype__exposes the runtime supertype;__name__is the nominal-type name mypy prints in errors).
Goal¶
Implement src/codegenie/types/__init__.py and src/codegenie/types/identifiers.py declaring IndexId, SkillId, TaskClassId, IndexName, ProbeId as NewTypes over str, re-exporting PackageManager from its Phase 1 ADR-0013 source location by import — and assert via unit tests that (a) the import location has not silently changed, (b) the five NewTypes are pairwise distinct, (c) mypy --strict rejects cross-type assignment (executed in CI, not asserted in prose), and (d) the identifiers.py module is import-pure (no I/O, no logger, no probe-registry pull-in).
Acceptance criteria (original — all GREEN as merged)¶
- [x] AC-1.
src/codegenie/types/identifiers.pydeclaresIndexId = NewType("IndexId", str),SkillId = NewType("SkillId", str),TaskClassId = NewType("TaskClassId", str),IndexName = NewType("IndexName", str). Each is a distinctNewType(mypy treats them as non-interchangeable at type-check time). - [x] AC-2.
src/codegenie/types/identifiers.pyre-exportsPackageManagerviafrom codegenie.probes.node_build_system import PackageManager as PackageManager(the explicitasre-export makes mypy--strictadmit the public re-export). - [x] AC-3.
src/codegenie/types/__init__.pyre-exports all five names (IndexId,SkillId,TaskClassId,IndexName,PackageManager) via__all__(⊇; remediation AC-3a/3b tightens to ==). - [x] AC-4.
PackageManageris not redefined anywhere undersrc/codegenie/types/; a unit test scansidentifiers.pysource via regex (remediation AC-4a moves to AST). - [x] AC-5.
codegenie.types.identifiers.PackageManager is codegenie.probes.node_build_system.PackageManager— same object identity, no re-wrapping (withtry/except ImportErrorfallback; remediation AC-5b removes the fallback). - [x] AC-6. A mypy-only test (
tests/unit/types/test_identifiers_typecheck.py) keeps commented-out swap lines as prose-documentation (remediation AC-6a adds the subprocess meta-test that is the actual CI gate). - [x] AC-7. Runtime:
IndexId("scip") == "scip"(becauseNewTypeis identity at runtime); the test documents this and is intentional (remediation AC-7a tightens totype(val) is str). - [x] AC-8. The TDD plan's red test exists, was committed, and is green.
- [x] AC-9.
ruff check,ruff format --check,mypy --strict, andpytest tests/unit/types/all pass on the touched files (remediation AC-9a/9b/9c/9d splits per-tool).
(See "Hardening remediation" section above for the additive AC-1b / 3a / 3b / 4a / 5b / 6a / 7a / 8a / 9a / 9b / 9c / 9d that bring the story to family-discipline parity with S1-01 / S1-03 / S1-04.)
Implementation outline¶
- Locate the Phase 1 ADR-0013
PackageManagersource — at validation time it ships atsrc/codegenie/probes/node_build_system.py(flat). Use that path. A future move toprobes/layer_a/is a Phase 1 ADR-0013 amendment that updates the single import line inidentifiers.py. - Create
src/codegenie/types/__init__.py+src/codegenie/types/identifiers.pywith the fiveNewTypes (IndexId,SkillId,TaskClassId,IndexName,ProbeId) and the explicit-asPackageManagerre-export. - Write red tests; confirm
ImportErroronProbeIdand on the new module path. - Implement; confirm green.
- Refactor — module docstrings cite production ADR-0033; the no-redefinition invariant is restated in a one-line comment above the
import PackageManagerline. - Remediation pass (post-GREEN, surgical follow-up): add
ProbeId, swap regex→AST source-scan, swap ⊇→== for__all__, removetry/exceptfrom the AC-5 test, add the subprocess negative-mypy meta-test (new filetest_identifiers_mypy_negative.py), addtype(val) is str, addpytest.raises(TypeError)forisinstance, add module-purity AST scan, split AC-9 per-tool.
TDD plan — red / green / refactor¶
(Original TDD plan retained for historical context; the remediation TDD plan below is what AC-1b/3a/3b/4a/5b/6a/7a/8a/9a–d require.)
Red — write the failing test first (original)¶
Test file path: tests/unit/types/test_identifiers.py
from __future__ import annotations
import inspect
import pathlib
import re
from typing import get_type_hints
import codegenie.types.identifiers as ids
def test_newtypes_exist_and_are_distinct() -> None:
# AC-1 — four NewType aliases over str
for name in ("IndexId", "SkillId", "TaskClassId", "IndexName"):
assert hasattr(ids, name), f"missing {name}"
nt = getattr(ids, name)
# NewType objects expose .__supertype__ == str
assert nt.__supertype__ is str, f"{name} must be NewType over str"
def test_newtypes_runtime_identity_to_str() -> None:
# AC-7 — at runtime, NewType is identity. Document the intentional shape.
val = ids.IndexId("scip")
assert val == "scip" # equality — identity-as-str at runtime
assert isinstance(val, str)
def test_package_manager_reexported_from_phase1_adr_0013_location() -> None:
# AC-5 — same object identity, no re-wrapping
try:
from codegenie.probes.node_build_system import PackageManager as P1
except ImportError:
from codegenie.probes.layer_a.node_build_system import PackageManager as P1 # type: ignore[no-redef]
assert ids.PackageManager is P1
def test_no_package_manager_redefinition_in_types_module() -> None:
# AC-4 — guard against silent shadowing
src = pathlib.Path(inspect.getsourcefile(ids)).read_text()
assert "class PackageManager" not in src
assert not re.search(r"^PackageManager\s*=\s*(?!.*import)", src, flags=re.MULTILINE)
matches = re.findall(r"^\s*from .* import .*PackageManager", src, flags=re.MULTILINE)
assert len(matches) == 1
def test_all_exports_include_five_names() -> None:
from codegenie import types as t
assert set(t.__all__) >= {"IndexId", "SkillId", "TaskClassId", "IndexName", "PackageManager"}
Remediation tests (AC-1b / 3a / 3b / 4a / 5b / 7a / 8a / 9a)¶
Edit tests/unit/types/test_identifiers.py to add these (and tighten the existing) — preserves the existing pass-set and adds the family-discipline closure:
from __future__ import annotations
import ast
import itertools
import pathlib
import pytest
import codegenie.types as types_pkg
import codegenie.types.identifiers as ids
_FIVE_NEWTYPES = ("IndexId", "SkillId", "TaskClassId", "IndexName", "ProbeId")
_EXPECTED_ALL = {*_FIVE_NEWTYPES, "PackageManager"}
def test_five_newtypes_supertype_and_name_pinned() -> None:
# AC-1b — five (incl. ProbeId); __name__ pinned to variable name.
for name in _FIVE_NEWTYPES:
assert hasattr(ids, name), f"missing {name}"
nt = getattr(ids, name)
assert nt.__supertype__ is str
assert nt.__name__ == name, (
f"{name} NewType __name__ is {nt.__name__!r}; mypy prints this in errors."
)
def test_newtypes_pairwise_distinct() -> None:
# AC-1b — guards against IndexId = SkillId = NewType("Id", str) aliasing.
nts = [getattr(ids, n) for n in _FIVE_NEWTYPES]
for a, b in itertools.combinations(nts, 2):
assert a is not b, f"{a.__name__} and {b.__name__} are the same object"
def test_runtime_value_type_is_exactly_str() -> None:
# AC-7a — type(...) is str (stricter than isinstance; catches str subclasses).
for name in _FIVE_NEWTYPES:
val = getattr(ids, name)("scip")
assert val == "scip"
assert type(val) is str
def test_isinstance_against_newtype_raises_typeerror() -> None:
# AC-8a — NewType is not a class. Footgun pinned as enforced behavior.
with pytest.raises(TypeError):
isinstance("scip", ids.IndexId) # type: ignore[arg-type]
def test_package_manager_reexported_hard_path() -> None:
# AC-5b — no try/except fallback. Layout drift fails loud.
from codegenie.probes.node_build_system import PackageManager as P1
assert ids.PackageManager is P1
def test_identity_passthrough_through_init() -> None:
# AC-5b — codegenie.types.X is codegenie.types.identifiers.X for all six.
for name in _EXPECTED_ALL:
assert getattr(types_pkg, name) is getattr(ids, name)
def test_all_exact_six_names() -> None:
# AC-3a / AC-3b — exact equality, no stowaways.
assert set(ids.__all__) == _EXPECTED_ALL
assert set(types_pkg.__all__) == _EXPECTED_ALL
def _identifiers_ast() -> ast.Module:
return ast.parse(pathlib.Path(ids.__file__).read_text())
def test_no_package_manager_redefinition_ast() -> None:
# AC-4a — AST-based; tolerates multi-line imports, catches annotation-form.
tree = _identifiers_ast()
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef) and node.name == "PackageManager":
pytest.fail("Do not redefine PackageManager via class")
if isinstance(node, ast.Assign):
for t in node.targets:
if isinstance(t, ast.Name) and t.id == "PackageManager":
pytest.fail("PackageManager rebound via assignment")
if isinstance(node, ast.AnnAssign):
t = node.target
if isinstance(t, ast.Name) and t.id == "PackageManager":
pytest.fail("PackageManager rebound via annotation-form assignment")
imports = [
n for n in ast.walk(tree)
if isinstance(n, ast.ImportFrom)
and any(a.name == "PackageManager" for a in n.names)
]
assert len(imports) == 1
alias = next(a for a in imports[0].names if a.name == "PackageManager")
assert alias.asname == "PackageManager", (
"PackageManager must be re-exported with `as PackageManager` (mypy strict)."
)
def test_identifiers_module_is_pure() -> None:
# AC-9a (module-purity invariant) — family symmetric discipline.
tree = _identifiers_ast()
ALLOWED_FROM = {"__future__", "typing", "codegenie.probes.node_build_system"}
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
assert node.module in ALLOWED_FROM, (
f"identifiers.py imports from {node.module!r}; allowed: {ALLOWED_FROM}"
)
elif isinstance(node, ast.Import):
pytest.fail(f"bare import in identifiers.py: {[a.name for a in node.names]}")
New file — subprocess negative-mypy meta-test (AC-6a)¶
# tests/unit/types/test_identifiers_mypy_negative.py
"""AC-6a — mypy --strict MUST reject cross-NewType assignment.
The whole point of NewType is mypy-time nominal discrimination. The original
draft commented out the bad lines and called it "verified" — no automation ran
them. This test executes mypy against a file with the swap uncommented and
asserts non-zero exit + the expected error substring.
"""
from __future__ import annotations
import subprocess
import sys
import textwrap
from pathlib import Path
_NEGATIVE = textwrap.dedent('''
from __future__ import annotations
from codegenie.types.identifiers import IndexId, SkillId
def _accepts_index(_x: IndexId) -> None: ...
def _accepts_skill(_x: SkillId) -> None: ...
def main() -> None:
i: IndexId = IndexId("scip")
s: SkillId = SkillId("scip.maintenance")
_accepts_index(s) # mypy MUST flag: SkillId where IndexId expected
_accepts_skill(i) # mypy MUST flag: IndexId where SkillId expected
''')
def test_mypy_strict_rejects_cross_newtype_assignment(tmp_path: Path) -> None:
target = tmp_path / "negative_swap.py"
target.write_text(_NEGATIVE)
result = subprocess.run(
[sys.executable, "-m", "mypy", "--strict", str(target)],
capture_output=True, text=True, check=False,
)
assert result.returncode != 0, (
f"mypy --strict accepted cross-NewType assignment; NewType discipline is broken.\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
combined = result.stdout + result.stderr
assert 'incompatible type "SkillId"' in combined, combined
assert 'expected "IndexId"' in combined, combined
Green — make it pass (post-remediation shape)¶
# src/codegenie/types/identifiers.py
"""Phase 2 kernel-tier identifier newtypes (production ADR-0033).
Each NewType is a nominal type at mypy --strict time; identity-to-str at
runtime. Adding a new identifier here is the canonical extension point —
do NOT redefine PackageManager (Phase 1 ADR-0013); import it.
"""
from __future__ import annotations
from typing import NewType
# DO NOT redefine — Phase 1 ADR-0013 owns this enum; this is a re-export.
from codegenie.probes.node_build_system import PackageManager as PackageManager
IndexId = NewType("IndexId", str)
SkillId = NewType("SkillId", str)
TaskClassId = NewType("TaskClassId", str)
IndexName = NewType("IndexName", str)
ProbeId = NewType("ProbeId", str)
__all__ = ["IndexId", "IndexName", "PackageManager", "ProbeId", "SkillId", "TaskClassId"]
# src/codegenie/types/__init__.py
from codegenie.types.identifiers import (
IndexId, IndexName, PackageManager, ProbeId, SkillId, TaskClassId,
)
__all__ = ["IndexId", "IndexName", "PackageManager", "ProbeId", "SkillId", "TaskClassId"]
Refactor — clean up¶
- Module docstring on
identifiers.pycites production ADR-0033 §3 (primitive-obsession is a review-blocker) and names the Phase 1 ADR-0013 source location. - Comment above the
import PackageManagerline:# DO NOT redefine — Phase 1 ADR-0013 owns this enum; this is a re-export. - Confirm CI gates per AC-9a/b/c/d:
ruff format src/codegenie/types/ tests/unit/types/ruff check src/codegenie/types/ tests/unit/types/mypy --strict src/codegenie/types/ tests/unit/types/pytest tests/unit/types/
Files to touch¶
| Path | Why |
|---|---|
src/codegenie/types/__init__.py |
New package; re-export the six names exactly. |
src/codegenie/types/identifiers.py |
Declare five NewTypes (add ProbeId); re-export PackageManager. |
tests/unit/types/test_identifiers.py |
AST source-scan, pairwise distinctness, identity passthrough, __all__ exact-set, runtime-identity-is-exactly-str, isinstance footgun, module-purity. |
tests/unit/types/test_identifiers_typecheck.py |
Positive-path mypy file — every NewType used at its declared site type-checks. |
tests/unit/types/test_identifiers_mypy_negative.py |
New (AC-6a) — subprocess-invokes mypy --strict on a file with the cross-type swap uncommented; asserts non-zero exit + expected error substring. The single load-bearing CI gate for NewType discipline. |
Out of scope¶
- Adapter-tier
TestId— declared in S1-03'scodegenie.adapters.protocols(TestId is adapter-local; this file is kernel-tier). - Phase 3+ identifiers (
AdapterId,RecipeId,BundleId,AttemptId,WorkflowId,RepoId,EventId,SymbolId,SignalKind,Language,BuildSystem) — production ADR-0033 §1 lists ~15 newtypes for the whole codebase; Phase 2 ships only the five it actively consumes. Extension is by addition: subsequent phases add newNewType("X", str)lines to this sameidentifiers.py(no new files, no decorators, no registry — Rule 2). The exact-set__all__test in AC-3a/3b means each addition is a one-line story change, but it IS a story-change (no silent additions). CommitSha/FileHash/BlobIdnewtypes — not introduced in Phase 2; commit SHAs flow as rawstrat the I/O boundary by design (../phase-arch-design.md §"Data model"—CommitsBehind.last_indexed: str).Enum-shapedPackageManagerrewrite — Phase 1 ADR-0013 is the source of truth. If Phase 1 shippedLiteral["bun","pnpm",…]and Phase 2 wantsEnum, that's a Phase 1 ADR amendment, not a Phase 2 story.- Runtime validation of newtype values —
NewTypehas no runtime semantics; if a probe needs to validateIndexNamepatterns, that's a probe-side concern (and likely a smart-constructor at the parser boundary per ADR-0033 §2), not the newtype's. - Closing
IndexName/SkillId/TaskClassId/ProbeIdto aLiteral[...]— these are open by design (registry keys for@register_index_freshness_check,@register_skill,@register_task_class,@register_probe). A closedLiteralwould violate Phase 2 Extension-by-Addition. See Notes-for-implementer.
Notes for the implementer¶
from ... import X as Xis the explicit re-export idiom mypy--strictaccepts. Underimplicit_reexport = false(whichstrict = trueimplies — verified inpyproject.toml [tool.mypy]),from x import YmakesYprivate to the importing module. The redundantas Yis the public-re-export contract.__all__is the runtime contract (from x import *honors it); both are needed. The story uses both correctly; do not "clean up" the redundancy.- NewType has zero runtime cost. At runtime,
IndexId("scip")is the string"scip". The protection is type-checker-only.test_isinstance_against_newtype_raises_typeerror(AC-8a) documents this so a future contributor doesn't add runtimeisinstance(x, IndexId)checks —NewTypeis not a class;isinstanceraisesTypeError, not a silent always-true. - Open
NewTypevs closedLiteral—IndexName,SkillId,TaskClassId,ProbeIdare open on purpose. Each is a registry key:IndexNamefor@register_index_freshness_check(S1-01 / ADR-0006),SkillIdfor skill registration,TaskClassIdfor the TCCMtask_classfield,ProbeIdfor@register_probe. Phase 3+ extends the registries by new file + new decorator — Extension by Addition. A closedLiteral["scip","ctags","tree-sitter","semgrep"]would freeze the set and require editing the kernel for every new index source / skill / task class / probe. A reviewer who proposes "tightening" these toLiteral[...]is reaching for false safety.PackageManageris the exception — it'sLiteral[...]because Phase 1 ADR-0013 explicitly closed the package-manager taxonomy; extension is by ADR amendment, not by user-data. - Phase 1 ADR-0013 location at validation time: flat (
src/codegenie/probes/node_build_system.py). The architect's package tree intends a futurelayer_a/reorganization (per../phase-arch-design.md §"Development view"). When that move happens it's a Phase 1 ADR-0013 amendment and updates the single import line inidentifiers.py. Do not branch in the story or test code ontry/except ImportError— that hides drift. Fail loud, then update the one line. The remediation pass removes thetry/except. - Production ADR-0033 §1 names
TaskClass(noIdsuffix). Phase 2 choseTaskClassIdfor naming symmetry withProbeId/SkillId/IndexId/IndexName; the phase-arch design and all Phase 2 stories settled on this spelling. The deviation from production ADR-0033 is deliberate — a future production-ADR amendment may reconcile (TaskClass→TaskClassId), but Phase 2 doesn't wait. Do not redeclare or alias the two names. - The AST source-scan tests are structural defense, not paranoia. They catch the most common mistakes: a reviewer who copy-pastes the enum into
identifiers.py"for convenience" (AC-4a); a contributor who addsfrom codegenie.probes.registry import _REGISTRYtoidentifiers.pyto "see what probes are registered" (AC-9a) and silently pulls the probe registry into kernel-import order. Keep the AST scans; do not weaken to allow conditional imports or platform-specific bare imports — there are no platform-specific identifiers in this file. - The subprocess mypy meta-test (AC-6a) is the load-bearing CI gate. Without it, AC-6's "verified via mypy" claim is theatre — the commented-out swap lines never run against mypy. If the meta-test is flaky in CI (e.g., mypy not on
$PATH), fix the CI environment; do not weaken the assertion. Sibling story S1-03 uses the same shape for compile-fail tests; mirror it. - NewType vs. Annotated[str, …] vs. dataclass.
NewTypeis the lightest; production ADR-0033 explicitly names it. Do not invent@dataclass(frozen=True) class IndexId: value: str— that's pattern-soup, breaks string interop the architecture relies on, and re-creates the runtime-validation surface this file deliberately rejects. types/is the kernel-tier package. ADR-0006's "new top-level packages" list (indices/, adapters/, tccm/, skills/, conventions/, depgraph/, report/) enumerated domain packages only;types/joins the kernel peer group alongsideerrors/,cache/,coordinator/, etc. No new arch decision is required.- Hypothesis (property-based) is NOT warranted here. The mutation space is categorical (alias vs distinct, supertype right vs wrong,
__name__right vs wrong), not value-space. The remediation unit tests + the subprocess meta-test exhaust the failure modes. Adding Hypothesis would be ceremony without coverage gain. - Cross-story dependency:
ProbeIdis consumed by S1-04. S1-04 lines 51, 78, 656 importProbeIdfromcodegenie.types.identifiers. The original S1-05 merge did NOT shipProbeId; the remediation pass closes this gap. The correct serialization is S1-05 (remediation) before S1-04 execution. If S1-04 runs against the pre-remediation tree, itImportErrors — that's the loud failure mode we want, not a workaround in S1-04.