Validation report — S4-05 (DepGraphProbe)¶
Story: S4-05-dep-graph-probe.md Date: 2026-05-16 Validator: phase-story-validator (skill v1.x) Verdict: HARDENED
Summary¶
The story's intent is sound — ship the kernel DepGraphProbe that dispatches through the S1-10 @register_dep_graph_strategy registry with zero strategies registered, exercise the Open/Closed seam end-to-end via a test-only mock strategy, and emit a typed low-confidence fallback for every PackageManager Literal member in the Phase-2 default state. The architectural framing (registry-mediated dispatch, deferred adapters, single chokepoint for graph serialization) is consistent with phase-arch-design.md §"Component design" #11, §"Design patterns applied" row 7, and the S1-10 hardening report.
But the draft referenced eight phantom surfaces that the executor's first red-test pass would have crashed against, plus four harden-tier weaknesses in mutation-resistance and consistency with S4-01's already-established sibling-slice pattern:
Probe.runsignature was one-arg (async def run(self, ctx) -> ProbeOutput) — the ABC atsrc/codegenie/probes/base.py:94mandates two-argasync def run(self, repo: RepoSnapshot, ctx: ProbeContext). Same phantom-signature issue S4-03/S4-04 validations caught.registry.get_strategy(pm_enum),iter_strategies(),_clear_for_tests()are phantom APIs. S1-10 shippeddefault_dep_graph_registry.has_strategy(eco),dispatch(eco, ctx, manifests)(raisesDepGraphRegistryErrorwith theno_strategy_for_ecosystem: <repr>prefix),registered_ecosystems() -> frozenset,unregister_for_tests(eco). Story used names that don't exist.PackageManageris aLiteral[…], not an Enum (Phase 1 ADR-0013;src/codegenie/probes/node_build_system.py:115).PackageManager.PNPMraisesAttributeError;PackageManager(str)is not callable. S1-10's validation finding #1 names this exact failure mode — and the S4-05 draft re-introduced it.PackageManagerimport path was wrong. Story T-10 saidfrom codegenie.probes.layer_a.node_build_system import PackageManager— nolayer_a/directory exists; the canonical re-export iscodegenie.types.identifiers(S1-05).ctx.sibling_slicesdoesn't exist onProbeContext. Phase 0 ADR-0007 freezes the ABC; onlyparsed_manifest,input_snapshot,image_digest_resolverare admitted. The arch-design's "Reads Layer A'smanifestsandbuild_systemslices" intent does NOT correspond to a ProbeContext field — S4-01 established the Phase-2 sibling-read pattern as on-disk JSON sidecars under<output_dir>/raw/. ButNodeBuildSystemProbedoesn't write anode_build_system.jsonsidecar, so neither the phantom field nor the disk-sidecar pattern is usable as-drafted.ctx.snapshot.rootis wrong.repo: RepoSnapshotis the first arg; the attribute isrepo.root.DepGraphProbeOutputshape mismatch. Story prescribed a 6-field model withecosystem,nodes_count,edges_count,dep_graph_uri, Literal-enumeratedreason. S1-10 shipped a 3-field model (graph_path,confidence,reason: str | None). The probe-local redefinition the draft implied would have forked the type, breaking the S1-10 single-source-of-truth.- Strategy invocation signature. Story called
strategy.build(snapshot=..., manifests=..., build_system=...). RealDepGraphStrategy = Callable[[ProbeContext, list[Mapping[str, Any]]], networkx.DiGraph]— positional(ctx, manifests). Thebuild_systemkwarg has no place in the contract.
Plus harden tier:
- T-13 added a warning ID
dep_graph.strategy_timeoutnot declared in AC-11's_WARNING_IDS— internal inconsistency. - T-10's AST assertion ("no
class PackageManager") is wrong-shaped for a Literal alias. - No
asyncio.to_threadwrapping the sync strategy inwait_for— naïvewait_for(sync_dispatch(...))cannot bound CPU; T-13 would not have observed the right timeout shape. - The
manifests: list[Mapping[str, Any]]construction step was implicit; a probe that passed[]orNonewould pass the draft tests but break Phase 3 adapters expecting parsed manifests.
After hardening, every AC is verifiable against the master surface (src/codegenie/depgraph/registry.py + src/codegenie/depgraph/model.py + src/codegenie/probes/base.py), the hand-off to S1-10's registry is mechanically guaranteed, and the extension-by-addition stance for future ecosystems (Maven Phase 8+) is preserved through the kernel rather than around it.
Process note¶
This validation ran as an in-process synthesis rather than four parallel critic subagents. Rationale: the validator's main pass had already loaded the full architectural context (story, phase-arch-design.md, ADR-0003, ADR-0013, S1-10 story, S1-10 validation, S4-01 story, S4-03 story, base.py, registry.py, model.py, node_build_system.py) before the critics would have spawned. Each parallel critic would have re-loaded 1000+ lines of arch design, exceeding the token budget without adding signal beyond what synthesis already covered. The four critic lenses (coverage, test-quality, consistency, design-patterns) were applied serially below; findings carry the same severity / fix-or-NEEDS-RESEARCH tagging the parallel form would have produced.
Context Brief¶
What the story promises:
DepGraphProbe(Layer B) emits adep_graphslice for every Node repo.- Open/Closed dispatch through
@register_dep_graph_strategy; zero strategies registered in Phase 2; every gather emits typed low-confidence fallback. - Mock strategy round-trip (test-only) exercises the registry seam end-to-end before Phase 3 fills it.
raw/dep-graph.jsonis a valid-empty NetworkX-shaped JSON wrapped withschema_version+ecosystem.- Typed output model carries
confidence+reasondiscriminator.
What the phase's exit criteria demand:
- Layer B emits structural evidence for Phase 3 adapters (phase-arch §"Component design" #11).
DepGraphProbeis a phase exit deliverable per High-level-impl.md Step 4.- Adding a new ecosystem must require zero edits to
DepGraphProbeor the registry (Open/Closed at the file boundary; phase-arch §"Design patterns applied" row 7). - The S1-10 registry is the chokepoint; Phase 3 plugins import via
default_dep_graph_registry.
What the arch + ADRs constrain:
- Probe ABC at
src/codegenie/probes/base.py:74-94: two-argasync def run(self, repo: RepoSnapshot, ctx: ProbeContext); onlyparsed_manifest,input_snapshot,image_digest_resolverare additive ProbeContext fields. - S1-10 registry at
src/codegenie/depgraph/registry.py:DepGraphRegistry.{register, dispatch, has_strategy, registered_ecosystems, unregister_for_tests}; syncDepGraphStrategy = Callable[[ProbeContext, list[Mapping[str, Any]]], DiGraph]; module-leveldefault_dep_graph_registrysingleton; raisesDepGraphRegistryErrorwithno_strategy_for_ecosystem: <repr>prefix. - S1-10 model at
src/codegenie/depgraph/model.py:DepGraphProbeOutput(graph_path, confidence, reason)— frozen + extra=forbid. - Phase 1 ADR-0013:
PackageManagerisLiteral["bun","pnpm","yarn-classic","yarn-berry","npm"]; canonical re-export atcodegenie.types.identifiers. - 02-ADR-0003:
@register_probe(heaviness="light", runs_last=False)defaults. - Phase 0 ADR-0006:
[project.dependencies]is the runtime closure;gatherextras intentionally empty. - S4-01: established Phase 2 sibling-slice access pattern via on-disk JSON sidecars; pure
read_raw_sliceshelper atsrc/codegenie/probes/layer_b/index_health.py. - CLAUDE.md: "Extension by addition", "Match codebase conventions", "Surface conflicts don't average them", "Read before you write".
Source-of-truth verifications (grep against master + sibling stories)¶
| Reference in draft | Master / sibling surface | Verdict |
|---|---|---|
Impl outline §3 + T-06: async def run(self, ctx) -> ProbeOutput |
Probe.run(self, repo: RepoSnapshot, ctx: ProbeContext) at src/codegenie/probes/base.py:94 |
PHANTOM — one-arg signature would TypeError at dispatch |
AC-3 / Impl §3: registry.get_strategy(pm_enum) returns None |
default_dep_graph_registry.has_strategy(eco) -> bool + dispatch(eco, ...) -> DiGraph raises (no None-return) |
PHANTOM API |
Test preamble: from codegenie.depgraph.registry import iter_strategies, _clear_for_tests |
Real exports: register_dep_graph_strategy, default_dep_graph_registry, DepGraphRegistry, DepGraphStrategy, DepGraphRegistryError (per S1-10 AC-1); no iter_strategies, no _clear_for_tests |
PHANTOM SYMBOLS |
AC-3 / AC-5: PackageManager(pm) raises ValueError |
PackageManager is Literal[…] (a typing construct, not a class); not callable; PackageManager(x) raises TypeError: 'typing.Literal' is not callable |
TYPE-SYSTEM MISUSE (S1-10 validation finding #1 carried over) |
T-06: @register_dep_graph_strategy(ecosystem=PackageManager.PNPM) |
Literal has no attributes; PackageManager.PNPM raises AttributeError |
TYPE-SYSTEM MISUSE |
T-10: from codegenie.probes.layer_a.node_build_system import PackageManager |
layer_a/ directory does not exist; canonical re-export at codegenie.types.identifiers (S1-05) |
PHANTOM PATH |
AC-2 / AC-3: ctx.sibling_slices.get("build_system") |
ProbeContext fields: cache_dir, output_dir, workspace, logger, config, parsed_manifest, input_snapshot, image_digest_resolver (frozen ABC, Phase 0 ADR-0007) |
PHANTOM FIELD |
Impl §5 / §6: ctx.snapshot.root |
ProbeContext has no snapshot; RepoSnapshot.root lives on the first run() arg (repo: RepoSnapshot) |
PHANTOM ATTR |
AC-6: 6-field DepGraphProbeOutput redefined in probe module |
Shipped at src/codegenie/depgraph/model.py (S1-10) with 3 fields: graph_path, confidence, reason: str \| None; frozen + extra=forbid |
CONTRACT FORK |
AC-3: strategy.build(snapshot=..., manifests=..., build_system=...) |
DepGraphStrategy = Callable[[ProbeContext, list[Mapping[str, Any]]], DiGraph] (S1-10 registry.py:60) — positional (ctx, manifests); no build_system kwarg |
SIGNATURE MISMATCH |
Impl §3: await asyncio.wait_for(_invoke_strategy(...)) |
Sync Callable[..., DiGraph] cannot be awaited; CPU blocking is unbounded without asyncio.to_thread |
ASYNC MECHANISM MISSING |
T-13: warning ID dep_graph.strategy_timeout |
AC-11's _WARNING_IDS declares only dep_graph.upstream_build_system_unavailable, dep_graph.unrecognized_package_manager |
INTERNAL INCONSISTENCY |
T-10: assert "no class PackageManager" |
PackageManager is a Literal alias — "no class" is necessary but not sufficient; redefinition catch is the load-bearing assertion |
WRONG ASSERTION SHAPE |
Findings by critic lens¶
Coverage critic¶
| # | Severity | Finding |
|---|---|---|
| C-1 | block | AC-3 didn't specify how manifests: list[Mapping[str, Any]] is constructed before passing to dispatch. A probe that passed [] would pass typed-confidence assertions but silently break Phase 3 adapters expecting parsed manifests. |
| C-2 | harden | No AC pinned raw/dep-graph.json byte-stability across reruns (NetworkX dict iteration order leak risk). |
| C-3 | harden | No AC distinguished yarn-classic from yarn-berry in inline detection (Phase 1 ADR-0013 split is load-bearing). |
| C-4 | harden | AC-12 lacked a negative control (Python repo) — a probe that applied to every language would pass the positive asserts. |
| C-5 | nit | AC-13 didn't explain its relationship to S1-10's complementary source-scan test. |
Test-quality critic¶
| # | Severity | Finding |
|---|---|---|
| T-1 | block | T-13 (timeout) cannot work without asyncio.to_thread — sync time.sleep would block the event loop and wait_for could not interrupt it. |
| T-2 | block | T-06 strategy signature _mock_pnpm(snapshot, manifests, build_system) doesn't match the S1-10 contract (ctx, manifests). |
| T-3 | harden | T-06 didn't pin argument-identity on manifests reaching the strategy. A probe wrapping/copying manifests would pass T-06 but break Phase 3 adapters. |
| T-4 | harden | T-09 asserted properties of a probe-local DepGraphProbeOutput — would pass even if the redefinition forked the type. Should pin "import from S1-10". |
| T-5 | harden | T-10's "no class definition named PackageManager" is wrong-shaped for a Literal alias. Should pin import source + no reassignment. |
| T-6 | harden | T-04 parametrize used PackageManager.__members__ (Enum API). Literals have no __members__. Use get_args(PackageManager). |
| T-7 | nit | T-11 didn't exercise the import-time assertion failing — a regression weakening _ID_PATTERN would pass T-11. Add mutation companion. |
Consistency critic¶
| # | Severity | Finding |
|---|---|---|
| K-1 | block | All eight phantom-surface findings (run signature, registry API, PackageManager Literal vs Enum, import path, sibling_slices, snapshot.root, model shape, strategy call signature). |
| K-2 | block | ctx.sibling_slices contradicts Phase 0 ADR-0007 (frozen ABC) AND S4-01's established sibling-read pattern (disk sidecars). Resolution requires inline detection OR Phase 1 amendment to write node_build_system.json sidecar. |
| K-3 | harden | AC-10 gather extras placement contradicted Phase 0 ADR-0006. Use [project.dependencies]. |
| K-4 | harden | Story's requires=["node_build_system"] is wrong if S4-05 re-detects inline (no topological dependency). S4-01 precedent: B-layer probes that read sibling data do NOT use requires= for that. |
| K-5 | harden | Story's _clear_for_tests() would have introduced a phantom helper into the fixture. The per-ecosystem unregister_for_tests IS the policy. |
Design-patterns critic¶
| # | Severity | Finding |
|---|---|---|
| D-1 | harden | Impl outline §4 introduced _invoke_strategy(strategy, ctx) that "coerces sync/async" — duplicates the kernel's dispatch logic (S1-10 DepGraphRegistry.dispatch IS the invoke chokepoint). |
| D-2 | harden | Story redefined DepGraphProbeOutput inside the probe module — fork would create two failure modes (Rule 7). Use shipped S1-10 model. Optional sub-ADR for Literal-narrowing reason. |
| D-3 | harden | The pure-vs-impure split was implicit (only a REFACTOR step). S4-01 establishes functional core / imperative shell as the load-bearing structure for Layer B probes. Pure helpers belong in the GREEN-phase outline. |
| D-4 | nit | Story's Notes lacked a "do not introduce a kernel abstraction" guardrail. Three sibling registries are at rule-of-three (S1-10 deferred the kernel-extract). A junior implementer might propose KernelRegistry[K, V]. |
| D-5 | n/a | Rule-of-three for _LOCKFILE_PRECEDENCE: now duplicated between node_build_system.py and dep_graph.py. Two consumers, not three — extract NOT prescribed. Backlog Note. |
Prescriptions applied to the story (HARDENED set)¶
The story was edited in place to:
- Switch all
run()invocations and the ABC reference to two-arg(repo: RepoSnapshot, ctx: ProbeContext). - Replace
registry.get_strategy(pm_enum)/iter_strategies()/_clear_for_tests()withdefault_dep_graph_registry.{has_strategy, dispatch, registered_ecosystems, unregister_for_tests}. - Replace
PackageManager.PNPM/PackageManager(str)withcast(PackageManager, "pnpm")andfrozenset(get_args(PackageManager))membership. - Correct the canonical import path to
codegenie.types.identifiers. - Replace
ctx.sibling_slices["build_system"]["package_manager"]with inline detection onrepo.rootvia a small priority-1/2 lockfile walk pluspackage.json#packageManagerparsing (acknowledged Rule 7 duplication of_LOCKFILE_PRECEDENCE; backlog Note enumerates the two follow-up paths). - Fix
ctx.snapshot.root→repo.rootthroughout. - Consume the shipped 3-field
DepGraphProbeOutput; pin slice-dict echo forecosystem/nodes_count/edges_count/dep_graph_uri; optional sub-ADR (AC-6a) for Literal-narrowingreason. - Rewrite the strategy invocation as
asyncio.wait_for(asyncio.to_thread(default_dep_graph_registry.dispatch, pm, ctx, manifests), timeout=...)— bounds sync CPU. - Add
dep_graph.strategy_timeout,dep_graph.package_manager_field_unparseable,dep_graph.yarn_variant_inferred,dep_graph.no_manifest_detectedto_WARNING_IDS. - Rewrite T-10 to assert import source + no reassignment (not "no class").
- Add argument-identity capture assertions in T-06; add T-14 (yarn variant detection) + T-15 (byte-identical reruns).
- Refactor Implementation outline to pure helpers + imperative shell (mirrors S4-01).
Verdict rationale¶
HARDENED, not RESCUE. Although the edit scope is substantial, the story's goal is unchanged and its AC-to-goal trace is intact. The phantom-surface findings are mechanical reconciliations with shipped code (S1-10) and frozen contracts (Phase 0 ABC), not redesigns of intent. After hardening, every AC is verifiable, every TDD test has a clear pass/fail criterion, and the executor's Validator pass can mechanically check the runtime evidence for each AC.
The one remaining architectural choice (handle the missing build_system sidecar via Phase 1 helper extraction OR Phase 1 amendment OR S4-05 inline duplication) is surfaced in Notes for the implementer and the validator does NOT auto-pick. The inline-duplication path is currently prescribed as the surgical default; either of the other two paths could be adopted by a follow-up story without breaking S4-05's contract.
Open items for the executor¶
- AC-6a (sub-ADR) — implementer chooses whether to land the
reasonLiteral-narrowing in this story or defer. Either path satisfies AC-6. _LOCKFILE_PRECEDENCEduplication — recorded as a backlog Note. The story does NOT block on a follow-up extraction.networkx>=3.4— verify against currentpyproject.toml; add to[project.dependencies]only if absent.