Story S1-10 — codegenie.depgraph package + @register_dep_graph_strategy registry¶
Step: Step 1 — Plant new domain primitives, kernel contracts, and the nine new ADRs
Status: Done (GREEN 2026-05-15, all 16 ACs satisfied — see _attempts/S1-10.md)
Effort: S
Depends on: S1-05
ADRs honored: production ADR-0033 (typed identifiers), 02-ADR-0006 §Decisions noted (registry symmetry)
Validation notes (2026-05-15, phase-story-validator)¶
Verdict: HARDENED. The draft was structurally sound (no RESCUE-tier findings) and the design-pattern shape correctly mirrors S1-02's @register_index_freshness_check precedent. Eight harden-tier edits were applied to close mutation-resistance gaps the executor's Validator could not have caught:
PackageManageris aLiteral[...], not an Enum. Phase 1 ADR-0013'sPackageManager = Literal["bun","pnpm","yarn-classic","yarn-berry","npm"](verified atsrc/codegenie/probes/node_build_system.py:115) is a type alias —hasattr(PackageManager, "PNPM")is always False at runtime. The draft tests' defensive ternary (PackageManager.PNPM if hasattr(PackageManager, "PNPM") else "pnpm") collapsed to bare string literals on every dispatch — a no-op disguised as flexibility. Tests rewritten to usecast(PackageManager, "pnpm")at the type seam; the registry'sdict[PackageManager, …]typing carries the mypy-strict contract.- Decorator return-identity was not pinned (
reg.register(eco)(fn) is fn) — aregisterthat returnsNonewould have passed the draft tests. New AC + test. - Strategy argument-order and return-identity through
dispatchwere not pinned — adispatchimpl that calledfn(manifests, ctx)(swapped args) or returned a copy/wrapper of the graph would have passed the draft (test strategy ignored both args;isinstance(g, DiGraph)does not check identity). New AC + test with sentinel objects andis-identity assertion. (Mirrors S1-02 validation finding #4 — slice/head argument-swap was the same class of silent mutation.) - Duplicate-registration error message was untested for "both call sites named". The Green code names
prior+ new origin, but no test pinned this — a regression to bare__qualname__would still pass AC-2. AC strengthened, test extended (mirrors S1-02 validation finding #4). registered_ecosystems()had no AC. Implementation outline named it but no AC pinned its contract (return type, ordering, empty-registry behavior). New AC + test.DepGraphRegistryErrorinerrors.py's__all__was not pinned. Implementation outline §4 says "append" but__all__was silent — would let a regression that omits the export pass. New AC.- Module-level decorator singleton test used a non-
PackageManagerstring ("__test_singleton_eco__") with# type: ignore[arg-type]— that bypasses both the type contract and the duplicate-detection seam. Rewritten to use a realPackageManagerliteral and afinally: unregister_for_teststo keep the singleton clean. - AC-4 dispatch-error reason format was named only as "in
args[0]"; the exact prefix (no_strategy_for_ecosystem: <repr>) was not pinned. Tightened so AC-4 + S4-05's downstream translation contract are unambiguous.
Design-pattern note carried in §Notes for the implementer (no AC mandate): the rule-of-three threshold for a shared KernelRegistry[K, V] kernel is reached with this story (the 3rd registry of the family — probes/registry.py, indices/registry.py, now depgraph/registry.py). The three sites' dispatch shapes diverge non-trivially (for_task filter + LRU / dispatch_all total / single-dispatch(key)), so per Rule 2 (simplicity first) + Rule 3 (surgical changes) the kernel-extract is NOT prescribed in this story. The opportunity is recorded as a Notes paragraph so it survives for a post-Phase-2 cleanup story to evaluate. This deferral mirrors S1-02's own rule-of-three note ("intentionally does not pre-extract").
Full audit at _validation/S1-10-depgraph-strategy-registry.md.
Context¶
DepGraphProbe (B5, S4-05) is the kernel skeleton; ecosystem-specific resolution (pnpm vs. npm vs. yarn vs. bun, eventually Maven, Cargo) lives in Phase 3+ plugin adapters. To preserve the Open/Closed seam, the decorator-registry pattern (same family as @register_index_freshness_check from S1-02 and @register_probe from S1-08) is the right shape: each ecosystem's strategy registers via @register_dep_graph_strategy(ecosystem=PackageManager.PNPM). Phase 2 ships zero strategies — the registry is the seam Phase 3 fills.
References — where to look¶
- Architecture:
../phase-arch-design.md §"Component design" #11 — DepGraphProbe + strategy registry— public interface, the@register_dep_graph_strategy(ecosystem=PackageManager.PNPM)shape, and the "zero strategies in Phase 2" rule.../phase-arch-design.md §"Design patterns applied"row 5 — Open/Closed at the file boundary; adding Maven is a new file + new decorator + ADR-amend onPackageManager.../phase-arch-design.md §"Integration with Phase 3"row "PackageManagerenum +@register_dep_graph_strategy" — Phase 3 registersbuild_npm,build_pnpmvia new files, never editsDepGraphProbe.- Phase ADRs (rules this story must honor):
../ADRs/0006-index-freshness-sum-type-location.md— 02-ADR-0006 §Decisions noted — registry-symmetry principle (@register_index_freshness_check,@register_dep_graph_strategy,@register_probeall share shape).- Production ADRs (if applicable):
../../../production/adrs/0033-typed-identifiers.md— production ADR-0033 —PackageManager(Phase 1 ADR-0013) is the typed registry key, notecosystem: str.- Source design:
../final-design.md §11 — DepGraphProbe + strategy registry— the deliberate kernel-skeleton scope.- Existing code:
src/codegenie/probes/registry.py— mirror the class-based-Registry+ decorator-factory shape S1-08 finishes.src/codegenie/indices/registry.py(S1-02) — sibling pattern.src/codegenie/types/identifiers.py(S1-05) — re-exportsPackageManagerfrom Phase 1 ADR-0013.networkx—networkx.DiGraphis the strategy return type (Phase 2 already acceptsnetworkxper../phase-arch-design.md §"Component design" #11); confirm it's inpyproject.tomlextras or add it togatherextras.- External docs (only if directly relevant):
- https://networkx.org/documentation/stable/reference/classes/digraph.html —
DiGraphAPI surface.
Goal¶
Implement src/codegenie/depgraph/{__init__.py,model.py,registry.py} — @register_dep_graph_strategy(ecosystem: PackageManager) decorator-registry returning Callable[[ProbeContext, list[Manifest]], networkx.DiGraph] strategies, zero strategies registered in Phase 2, with typed DepGraphRegistryError on unknown ecosystem dispatch.
Acceptance criteria¶
- [ ] AC-1.
src/codegenie/depgraph/__init__.pyexportsregister_dep_graph_strategy,default_dep_graph_registry,DepGraphRegistry,DepGraphStrategy(type alias),DepGraphRegistryError, and aDepGraphProbeOutputPydantic model (frozen, the typed slice shape — fields:graph_path: Path | None,confidence: Literal["high","medium","low"],reason: str | None). Public surface symmetry:__all__in__init__.pyis exactly these six names, sorted; a test asserts the set equality. - [ ] AC-2.
@register_dep_graph_strategy(ecosystem: PackageManager)is a decorator-factory; registers the function indefault_dep_graph_registry; duplicate-ecosystem registration raisesDepGraphRegistryErrorat decoration time (i.e., module import) with both registration sites named as dottedmodule.qualnamestrings inargs[0](mirror S1-02 hardening; an operator grepping a multi-file plugin tree can locate both registrations from the message alone). - [ ] AC-3.
DepGraphStrategytype alias is exactlyCallable[[ProbeContext, list[Mapping[str, Any]]], "networkx.DiGraph"]. TheMapping[str, Any]shape is final, not provisional — verified by source scan that noManifest/NodeManifestPydantic model exists undersrc/codegenie/as of S1-10 (Phase 1'sparsed_manifestreturnsMapping[str, Any] | Nonepersrc/codegenie/probes/base.py:53). If a future story promotes manifests to a Pydantic model, this alias rebinds by ADR amendment, never by silent widening. - [ ] AC-4.
DepGraphRegistry.dispatch(ecosystem: PackageManager, ctx: ProbeContext, manifests: list[Mapping[str, Any]]) -> networkx.DiGraphinvokes the registered strategy and returns the exact object the strategy returned (identity, not a copy/wrapper — pinned byis-test in the TDD plan). Unknown ecosystem raisesDepGraphRegistryErrorwhoseargs[0]begins with the literal prefixno_strategy_for_ecosystem:followed byrepr(ecosystem)(the prefix is the structural token S4-05's probe matches when translating toDepGraphProbeOutput(confidence="low", reason="no_strategy_for_ecosystem")). - [ ] AC-5.
DepGraphRegistry.has_strategy(ecosystem: PackageManager) -> boolis a non-raising query for the no-strategy case —DepGraphProbeuses this in S4-05 to decide whether to dispatch or emit low-confidence directly.has_strategyis total over thePackageManagerLiteral members: returnsTruefor registered ecosystems,Falsefor unregistered, never raises for an unregistered Literal value. - [ ] AC-6. Zero strategies are registered in Phase 2. A test scans
src/codegenie/(recursiverglob("*.py")) for any module other thandepgraph/registry.pythat contains the substring"@register_dep_graph_strategy"and asserts the offender list is empty — Phase 3 plugins register underplugins/*/(outsidesrc/codegenie/); Phase 2 doesn't (the architect's commitment,../phase-arch-design.md §"Component design" #11). Do NOT lower the test to "≤ N strategies" for convenience. - [ ] AC-7.
PackageManageris imported fromcodegenie.types.identifiers(which re-exports from Phase 1 ADR-0013 via S1-05); the registry module does NOT redefine it. A source-scan test (mirroring S1-05's pattern) asserts no localclass PackageManagerand no top-levelPackageManager = ...re-assignment insrc/codegenie/depgraph/registry.py. - [ ] AC-8. A synthetic test registers a stub strategy via the decorator on a fresh
DepGraphRegistry();dispatchreturns the stub's exactnetworkx.DiGraphobject (identity-equal to the closure's returned graph);has_strategyreturnsTruefor that ecosystem andFalsefor every other registeredPackageManagerLiteral member (the test enumerates the five Phase 1 Literal members explicitly). - [ ] AC-9. A test asserts an unknown-ecosystem dispatch raises
DepGraphRegistryErrorwhoseargs[0]starts withno_strategy_for_ecosystem:(the exact AC-4 prefix). - [ ] AC-10. Decorator return-identity.
reg.register(eco)(fn) is fn— the decorator returns the strategy function unchanged so registration is non-invasive (mirrors S1-02'sregister_index_freshness_checkand Phase 0'sregister_probe). A test pins this withis. - [ ] AC-11. Strategy invocation contract. A test passes two sentinel objects as
ctxandmanifeststodispatchand asserts the stub strategy received them in that positional order (assert captured["ctx"] is ctx_sentinel and captured["manifests"] is manifests_sentinel) — adispatchimplementation that swapped the positional args, copied the manifests list, or wrapped the graph would fail this test. - [ ] AC-12.
DepGraphRegistry.registered_ecosystems() -> frozenset[PackageManager]returns the set of registered ecosystems (unordered, non-mutating, returns an emptyfrozenseton a fresh registry). Tested explicitly — empty onDepGraphRegistry(), single-member after one registration, two-member after two. - [ ] AC-13.
src/codegenie/errors.pyappendsDepGraphRegistryErrorand extends__all__to include it (mirrors S1-02'sFreshnessRegistryErrorinsertion). A test asserts"DepGraphRegistryError" in codegenie.errors.__all__and thatDepGraphRegistryErroris a direct subclass ofCodegenieError. - [ ] AC-14. The
forbidden-patternsextension from S1-11 will banmodel_constructundercodegenie.depgraph/**; this story does not usemodel_constructand the discipline starts here. - [ ] AC-15. The TDD plan's red test exists, was committed, and is green.
- [ ] AC-16.
ruff check,ruff format --check,mypy --strict src/codegenie/depgraph/ tests/unit/depgraph/, andpytest tests/unit/depgraph/all pass on the touched files.
Implementation outline¶
- Create
src/codegenie/depgraph/model.pywithDepGraphProbeOutputPydantic model (frozen=True, extra="forbid"; fields per AC-1). - Create
src/codegenie/depgraph/registry.pywith:DepGraphStrategytype alias,DepGraphRegistryclass (register,dispatch,has_strategy,registered_ecosystems,unregister_for_tests), module-leveldefault_dep_graph_registry, and theregister_dep_graph_strategy(ecosystem)decorator-factory targeting the default. The class records bothdict[PackageManager, DepGraphStrategy](strategies) anddict[PackageManager, str](origins as dottedmodule.qualname) so duplicate-detection messages can name both sites. - Create
src/codegenie/depgraph/__init__.pyre-exporting the six public names in AC-1, with__all__declared as the explicit list. - Append
DepGraphRegistryErrortosrc/codegenie/errors.pyas a bare marker and extend the module's__all__to include it (mirroring how S1-02 addedFreshnessRegistryErrorto__all__). - Red tests → impl → refactor.
TDD plan — red / green / refactor¶
Red — write the failing test first¶
Test file path: tests/unit/depgraph/test_registry.py
Implementer note on
PackageManager.PackageManageris aLiteral["bun","pnpm","yarn-classic","yarn-berry","npm"]type alias defined atsrc/codegenie/probes/node_build_system.py:115and re-exported fromcodegenie.types.identifiers. It is not a class or Enum — there is no.PNPMattribute. At runtime,PackageManagervalues are the literal strings;mypy --strictenforces nominal correctness at the type seam. Tests usecast(PackageManager, "pnpm")to type-tag a literal where the type checker cannot infer it from context.
from __future__ import annotations
import inspect
import pathlib
import re
from logging import getLogger
from pathlib import Path
from typing import cast, get_args
import networkx
import pytest
from codegenie.depgraph import (
DepGraphProbeOutput,
DepGraphRegistry,
DepGraphRegistryError,
DepGraphStrategy,
default_dep_graph_registry,
register_dep_graph_strategy,
)
from codegenie.errors import CodegenieError
from codegenie.probes.base import ProbeContext
from codegenie.types.identifiers import PackageManager
PNPM = cast(PackageManager, "pnpm")
NPM = cast(PackageManager, "npm")
YARN_CLASSIC = cast(PackageManager, "yarn-classic")
YARN_BERRY = cast(PackageManager, "yarn-berry")
BUN = cast(PackageManager, "bun")
ALL_PACKAGE_MANAGERS: tuple[PackageManager, ...] = (BUN, PNPM, YARN_CLASSIC, YARN_BERRY, NPM)
def _make_ctx(tmp_path: Path) -> ProbeContext:
return ProbeContext(
cache_dir=tmp_path / "cache",
output_dir=tmp_path / "out",
workspace=tmp_path / "ws",
logger=getLogger("test"),
config={},
)
# ---------- AC-8 / AC-11 — register + dispatch contract + identity ----------
def test_register_and_dispatch_returns_strategy_graph_identity(tmp_path: Path) -> None:
"""AC-8 + AC-11 — dispatch returns the strategy's exact graph object (identity, not copy)."""
reg = DepGraphRegistry()
graph_returned_by_strategy = networkx.DiGraph()
graph_returned_by_strategy.add_edge("@org/a", "@org/b")
@reg.register(PNPM)
def build_pnpm(ctx: ProbeContext, manifests: list[dict[str, object]]) -> networkx.DiGraph:
return graph_returned_by_strategy
ctx = _make_ctx(tmp_path)
out = reg.dispatch(PNPM, ctx, [])
# Identity, not isinstance — a dispatch impl that wrapped/copied the graph would fail here.
assert out is graph_returned_by_strategy
assert ("@org/a", "@org/b") in out.edges
# ---------- AC-11 — strategy receives ctx + manifests verbatim, in that order ----------
def test_dispatch_passes_ctx_and_manifests_positionally(tmp_path: Path) -> None:
"""AC-11 — argument-swap mutation pin. A dispatch that called fn(manifests, ctx) would fail."""
reg = DepGraphRegistry()
captured: dict[str, object] = {}
ctx_sentinel = _make_ctx(tmp_path)
manifests_sentinel: list[dict[str, object]] = [{"name": "@org/a"}]
@reg.register(PNPM)
def _strategy(ctx: ProbeContext, manifests: list[dict[str, object]]) -> networkx.DiGraph:
captured["ctx"] = ctx
captured["manifests"] = manifests
return networkx.DiGraph()
reg.dispatch(PNPM, ctx_sentinel, manifests_sentinel)
assert captured["ctx"] is ctx_sentinel
assert captured["manifests"] is manifests_sentinel # not copied, not coerced
# ---------- AC-4 / AC-9 — unknown-ecosystem raises with structural prefix ----------
def test_unknown_ecosystem_raises_with_exact_prefix(tmp_path: Path) -> None:
"""AC-4 + AC-9 — args[0] begins with the exact prefix `no_strategy_for_ecosystem: `."""
reg = DepGraphRegistry()
with pytest.raises(DepGraphRegistryError) as exc_info:
reg.dispatch(NPM, _make_ctx(tmp_path), [])
msg = exc_info.value.args[0]
assert msg.startswith("no_strategy_for_ecosystem: "), msg
assert repr(NPM) in msg # the repr of the ecosystem value is included
# ---------- AC-5 / AC-8 — has_strategy is total over the Literal members ----------
def test_has_strategy_is_total_over_package_manager_literal() -> None:
"""AC-5 + AC-8 — has_strategy returns True only for registered, False for every other Literal member,
never raises for an unregistered Literal value."""
reg = DepGraphRegistry()
@reg.register(PNPM)
def _stub(ctx: ProbeContext, manifests: list[dict[str, object]]) -> networkx.DiGraph:
return networkx.DiGraph()
assert reg.has_strategy(PNPM) is True
for other in (BUN, NPM, YARN_CLASSIC, YARN_BERRY):
assert reg.has_strategy(other) is False, f"unexpected truthy for {other!r}"
# ---------- AC-10 — decorator return-identity ----------
def test_decorator_returns_function_unchanged() -> None:
"""AC-10 — `reg.register(eco)(fn) is fn` — non-invasive registration."""
reg = DepGraphRegistry()
def build_pnpm(ctx: ProbeContext, manifests: list[dict[str, object]]) -> networkx.DiGraph:
return networkx.DiGraph()
returned = reg.register(PNPM)(build_pnpm)
assert returned is build_pnpm
# ---------- AC-2 — duplicate-registration error names BOTH call sites ----------
def test_duplicate_ecosystem_error_names_both_call_sites() -> None:
"""AC-2 — error message names both registration sites as dotted `module.qualname` strings."""
reg = DepGraphRegistry()
@reg.register(PNPM)
def first_strategy(ctx: ProbeContext, manifests: list[dict[str, object]]) -> networkx.DiGraph:
return networkx.DiGraph()
with pytest.raises(DepGraphRegistryError) as exc_info:
@reg.register(PNPM)
def second_strategy(ctx: ProbeContext, manifests: list[dict[str, object]]) -> networkx.DiGraph:
return networkx.DiGraph()
msg = exc_info.value.args[0]
# Both module.qualname strings present — a regression to bare __qualname__ would fail this.
assert f"{__name__}.{first_strategy.__qualname__}" in msg, msg
assert f"{__name__}.test_duplicate_ecosystem_error_names_both_call_sites.<locals>.second_strategy" in msg or "second_strategy" in msg
# ---------- AC-12 — registered_ecosystems() contract ----------
def test_registered_ecosystems_returns_frozenset() -> None:
"""AC-12 — empty on fresh registry; populated as strategies are added; never mutates state."""
reg = DepGraphRegistry()
assert reg.registered_ecosystems() == frozenset()
assert isinstance(reg.registered_ecosystems(), frozenset)
@reg.register(PNPM)
def _a(ctx: ProbeContext, manifests: list[dict[str, object]]) -> networkx.DiGraph:
return networkx.DiGraph()
assert reg.registered_ecosystems() == frozenset({PNPM})
@reg.register(NPM)
def _b(ctx: ProbeContext, manifests: list[dict[str, object]]) -> networkx.DiGraph:
return networkx.DiGraph()
assert reg.registered_ecosystems() == frozenset({PNPM, NPM})
# Non-mutating: calling twice doesn't change anything.
assert reg.registered_ecosystems() == reg.registered_ecosystems()
# ---------- AC-6 — zero strategies registered in Phase 2 ----------
def test_zero_strategies_registered_in_phase2() -> None:
"""AC-6 — Phase 2 ships the registry empty. Walk src/codegenie/ and
assert no file other than the registry definition itself calls register_dep_graph_strategy."""
root = pathlib.Path(inspect.getsourcefile(__import__("codegenie"))).parent
offenders: list[pathlib.Path] = []
for py in root.rglob("*.py"):
text = py.read_text()
if "@register_dep_graph_strategy" in text and "depgraph/registry.py" not in str(py):
offenders.append(py)
assert offenders == [], (
"Phase 2 registers zero dep_graph strategies "
f"(02-ADR-0007 / arch §11). Found: {offenders}"
)
# ---------- AC-7 — registry module does not redefine PackageManager ----------
def test_package_manager_not_redefined_in_registry_module() -> None:
"""AC-7 — DepGraphRegistry imports PackageManager; does not redefine it."""
import codegenie.depgraph.registry as r
src = pathlib.Path(inspect.getsourcefile(r)).read_text()
assert "class PackageManager" not in src
assert not re.search(r"^PackageManager\s*=\s*(?!.*import)", src, flags=re.MULTILINE)
# ---------- AC-2 — module-level decorator targets the default singleton ----------
def test_module_level_decorator_uses_default_singleton() -> None:
"""AC-2 — `register_dep_graph_strategy` is sugar for `default_dep_graph_registry.register`.
Uses a real PackageManager Literal member; cleans up via unregister_for_tests."""
# Pre-condition: singleton should NOT have a strategy for this ecosystem (Phase 2 = empty).
assert default_dep_graph_registry.has_strategy(BUN) is False
@register_dep_graph_strategy(BUN)
def _stub(ctx: ProbeContext, manifests: list[dict[str, object]]) -> networkx.DiGraph:
return networkx.DiGraph()
try:
assert default_dep_graph_registry.has_strategy(BUN) is True
assert BUN in default_dep_graph_registry.registered_ecosystems()
finally:
default_dep_graph_registry.unregister_for_tests(BUN)
# Post-condition: singleton is empty again (no leak into other tests).
assert default_dep_graph_registry.has_strategy(BUN) is False
# ---------- AC-1 — public surface symmetry ----------
def test_public_surface_is_exact() -> None:
"""AC-1 — __all__ exposes exactly the six documented names; no extras leak."""
import codegenie.depgraph as dg
assert set(dg.__all__) == {
"DepGraphProbeOutput",
"DepGraphRegistry",
"DepGraphRegistryError",
"DepGraphStrategy",
"default_dep_graph_registry",
"register_dep_graph_strategy",
}
# ---------- AC-13 — DepGraphRegistryError in errors.__all__, marker-subclass shape ----------
def test_dep_graph_registry_error_is_a_marker_in_errors_module() -> None:
"""AC-13 — DepGraphRegistryError exported from codegenie.errors __all__; subclass of CodegenieError."""
import codegenie.errors as ce
assert "DepGraphRegistryError" in ce.__all__
assert issubclass(DepGraphRegistryError, CodegenieError)
# Marker shape: no custom __init__ beyond Exception's; no class state.
assert "__init__" not in DepGraphRegistryError.__dict__
# ---------- AC-3 — DepGraphProbeOutput shape (frozen, extra=forbid, three fields) ----------
def test_dep_graph_probe_output_shape() -> None:
"""AC-1 — DepGraphProbeOutput is frozen, extra=forbid; the typed slice shape S4-05 will return."""
out = DepGraphProbeOutput(graph_path=None, confidence="low", reason="no_strategy_for_ecosystem")
assert out.confidence == "low"
with pytest.raises(Exception): # pydantic frozen
out.confidence = "high" # type: ignore[misc]
with pytest.raises(Exception): # pydantic extra=forbid
DepGraphProbeOutput(graph_path=None, confidence="low", reason=None, extra_field="x") # type: ignore[call-arg]
Run — confirm ImportError: cannot import name 'DepGraphRegistry' from 'codegenie.depgraph'. Commit.
Green — make it pass¶
# src/codegenie/depgraph/registry.py
from __future__ import annotations
from collections.abc import Callable, Mapping
from typing import Any, TYPE_CHECKING
import structlog
from codegenie.errors import DepGraphRegistryError
from codegenie.probes.base import ProbeContext
from codegenie.types.identifiers import PackageManager # do NOT redefine
if TYPE_CHECKING:
import networkx
DepGraphStrategy = Callable[[ProbeContext, list[Mapping[str, Any]]], "networkx.DiGraph"]
_log = structlog.get_logger(__name__)
class DepGraphRegistry:
def __init__(self) -> None:
self._strategies: dict[PackageManager, DepGraphStrategy] = {}
# Origin strings ("module.qualname") kept alongside so duplicate errors
# can name BOTH call sites without re-introspecting the prior function
# (a caller could have mutated it). Mirrors codegenie.indices.registry.
self._origins: dict[PackageManager, str] = {}
def register(self, ecosystem: PackageManager) -> Callable[[DepGraphStrategy], DepGraphStrategy]:
def _decorator(fn: DepGraphStrategy) -> DepGraphStrategy:
origin = f"{fn.__module__}.{fn.__qualname__}"
if ecosystem in self._strategies:
prior = self._origins[ecosystem]
raise DepGraphRegistryError(
f"duplicate ecosystem {ecosystem!r}: {prior} and {origin}"
)
self._strategies[ecosystem] = fn
self._origins[ecosystem] = origin
_log.debug("depgraph.strategy.registered", ecosystem=str(ecosystem), origin=origin)
return fn # return identity — AC-10
return _decorator
def has_strategy(self, ecosystem: PackageManager) -> bool:
return ecosystem in self._strategies
def dispatch(
self,
ecosystem: PackageManager,
ctx: ProbeContext,
manifests: list[Mapping[str, Any]],
) -> "networkx.DiGraph":
try:
fn = self._strategies[ecosystem]
except KeyError:
raise DepGraphRegistryError(
f"no_strategy_for_ecosystem: {ecosystem!r}"
) from None
# Pass ctx and manifests positionally and verbatim — AC-11.
# Return the strategy's exact graph (no wrap, no copy) — AC-4.
return fn(ctx, manifests)
def registered_ecosystems(self) -> frozenset[PackageManager]:
return frozenset(self._strategies)
def unregister_for_tests(self, ecosystem: PackageManager) -> None:
"""**Test-only** convenience for cleaning the module-level singleton.
The deliberately-awkward name *is* the policy (mirrors S1-02)."""
self._strategies.pop(ecosystem, None)
self._origins.pop(ecosystem, None)
default_dep_graph_registry = DepGraphRegistry()
def register_dep_graph_strategy(
ecosystem: PackageManager,
) -> Callable[[DepGraphStrategy], DepGraphStrategy]:
return default_dep_graph_registry.register(ecosystem)
__all__ = [
"DepGraphRegistry", "DepGraphRegistryError", "DepGraphStrategy",
"default_dep_graph_registry", "register_dep_graph_strategy",
]
# src/codegenie/depgraph/model.py
from __future__ import annotations
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, ConfigDict
class DepGraphProbeOutput(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
graph_path: Path | None
confidence: Literal["high", "medium", "low"]
reason: str | None = None
# src/codegenie/depgraph/__init__.py
from codegenie.depgraph.model import DepGraphProbeOutput
from codegenie.depgraph.registry import (
DepGraphRegistry, DepGraphRegistryError, DepGraphStrategy,
default_dep_graph_registry, register_dep_graph_strategy,
)
__all__ = [
"DepGraphProbeOutput", "DepGraphRegistry", "DepGraphRegistryError",
"DepGraphStrategy", "default_dep_graph_registry",
"register_dep_graph_strategy",
]
Add DepGraphRegistryError as a bare marker to src/codegenie/errors.py.
Refactor — clean up¶
- Module docstring on
registry.py: cite 02-ADR-0006 §Decisions noted (registry symmetry),../phase-arch-design.md §"Component design" #11, and the Phase-3 hand-off (plugins/vulnerability-remediation--node--npm/strategies/dep_graph_pnpm.pyis the canonical first consumer). - The
DepGraphProbeOutputmodel is the typed slice shape — its consumer is theDepGraphProbeitself in S4-05 (this story ships the model so the probe can import it without circularity). - The
unregister_for_testshook mirrors S1-02; same discipline. - Run
ruff format,ruff check,mypy --strict src/codegenie/depgraph/ tests/unit/depgraph/,pytest tests/unit/depgraph/ -v.
Files to touch¶
| Path | Why |
|---|---|
src/codegenie/depgraph/__init__.py |
New package; re-exports. |
src/codegenie/depgraph/model.py |
DepGraphProbeOutput Pydantic model. |
src/codegenie/depgraph/registry.py |
The decorator-registry primitive. |
src/codegenie/errors.py |
Append DepGraphRegistryError marker AND extend __all__ to include it (mirrors S1-02's FreshnessRegistryError insertion). |
tests/unit/depgraph/test_registry.py |
Coverage: register/dispatch/duplicate/has_strategy/zero-strategies/no-redefinition. |
pyproject.toml |
Confirm networkx is in gather extras (add if missing — one-line). |
Out of scope¶
- Any actual ecosystem strategy implementation (
build_pnpm,build_npm,build_yarn_classic,build_yarn_berry,build_bun) — Phase 3 plugins. Zero strategies in Phase 2 is the architect's commitment. DepGraphProbe(probes/layer_b/dep_graph.py) — handled by S4-05; this story ships only the strategy registry and the output model.ManifestPydantic model — Phase 1 may ship a typedManifestmodel already; if so, import it. If not, the strategy signature usesMapping[str, Any](Phase 1parsed_manifestreturnsMapping[str, Any] | Nonepersrc/codegenie/probes/base.py). Adapt at impl time.- Maven / Cargo / Gradle extensions — those require an ADR amendment to Phase 1 ADR-0013's
PackageManagerenum (new variants) before a strategy can be registered. Phase 8+. - Runtime cross-strategy resolution (e.g., a multi-ecosystem monorepo with both pnpm and npm) — out of scope;
DepGraphProbepicks one ecosystem per analysis based on Phase 1'sBuildSystemProbeslice.
Notes for the implementer¶
PackageManageris aLiteral[...], NOT an Enum. Phase 1 ADR-0013 definesPackageManager = Literal["bun","pnpm","yarn-classic","yarn-berry","npm"]atsrc/codegenie/probes/node_build_system.py:115. There is no.PNPMattribute —hasattr(PackageManager, "PNPM")is alwaysFalseat runtime. Production ADR-0033 §3 prohibits primitive obsession; the registry'sdict[PackageManager, …]carries the nominal-type contract at the mypy seam (passing a rawstris a type error under--strict). In tests, usecast(PackageManager, "pnpm")to type-tag string literals where the type checker can't infer it from context — never thehasattrdefensive fallback (it is dead code masquerading as flexibility, removed in this hardening).- Manifest shape is
Mapping[str, Any]— confirmed, not provisional. Source scan againstsrc/codegenie/confirms noManifest/NodeManifestPydantic model exists (Phase 1 shipsparsed_manifest: Callable[[Path], Mapping[str, Any] | None] | Nonepersrc/codegenie/probes/base.py:53). AC-3 pinsMapping[str, Any]as final. If a future story promotes manifests to a Pydantic model, the rebind is by ADR amendment to this story's contract, never silent widening. - Rule-of-three kernel-extract opportunity — deferred to a future cleanup story (NOT in this scope). This is the 3rd registry of the decorator-registry family in this codebase (
codegenie.probes.registry1st;codegenie.indices.registry2nd from S1-02; nowcodegenie.depgraph.registry). The shared shape isregister / dedup-with-named-origins / registered_X / unregister_for_tests / module-level singleton + decorator. A genericKernelRegistry[K, V]base could absorb ~15–25 LOC × 3. However: the three sites diverge non-trivially on dispatch shape —for_task(task, languages)with LRU filter /dispatch_all(slices, head)total over registered names /dispatch(eco, ctx, manifests)single-shot withhas_strategyquery. The shared lines are below the cost-of-introducing-a-generic threshold mid-phase (Rule 2 + Rule 3). Per S1-02's validation-note deferral ("intentionally does not pre-extract"), this story also defers. A post-Phase-2 cleanup story should evaluate after the three sites have a few weeks of churn; if any of the three diverge further (e.g.,RuntimeFreshnessRegistryintroduces a per-key freshness window), the kernel-extract becomes net-cost-negative. The pattern symmetry acrossregister / dedup / registered_X / unregister_for_testsis itself a documentation win even without extraction. - Zero-strategies invariant is structurally enforced. The
test_zero_strategies_registered_in_phase2test walkssrc/codegenie/for@register_dep_graph_strategy. Phase 3 implementations will live underplugins/*/— outsidesrc/codegenie/, so they don't trip the test. Do NOT lower the test's strictness to "≤ 5 strategies" for convenience. networkx.DiGraphis the return type — not a Pydantic wrapper. The graph is a runtime data structure; Pydantic serialization comes at the writer chokepoint vianx.node_link_data(...)(already a JSON-serializable dict). Do not invent aDepGraphSliceModelPydantic wrapper here.DepGraphRegistryErroris a marker. Same Phase 0/1 marker-only convention; reason strings live inargs[0]with structural prefixes (no_strategy_for_ecosystem: <repr>,duplicate ecosystem <repr>: <prior> and <new>). Both prefixes are pinned by AC; do not introduce a custom__init__or class-level state.unregister_for_testsis the same intentional-awkwardness as S1-02. Test-only; do not promote to public. Tests that touch the module-leveldefault_dep_graph_registryMUST clean up in afinally:block (the singleton-test exemplar).PackageManagerimport location. Import fromcodegenie.types.identifiers(S1-05's re-export) — that's the canonical kernel-tier alias surface. Do not import directly fromcodegenie.probes.node_build_systemhere; the types package is the right interface.- Dispatch contract: identity, not equality.
dispatchreturns the strategy's exact graph object (AC-4) — no.copy(), nonx.DiGraph(g)wrap, no defensive serialization round-trip. The probe at S4-05 owns serialization (nx.node_link_data); the registry is a pass-through. AC-11's sentinel-identity test pins this; an executor that "defensively copies for safety" will fail it.