Story S7-01 — plugins/vulnerability-remediation--node--npm/ scaffold, manifest, TCCM, registration¶
Step: Step 7 — First production plugin, universal HITL fallback plugin, synthetic third plugin
Status: HARDENED (validated 2026-05-19; see _validation/S7-01-vuln-node-npm-plugin-scaffold.md)
Effort: M
Depends on: S6-04 (RemediationOrchestrator + the 5-node subgraph + _validate_stage6 seam must already exist for the plugin's build_subgraph to wire into), S7-03 (the universal-fallback plugin must be registrable for the negative-resolution assertion to be meaningful — until S7-03, tests register tests/fixtures/plugins/universal_fallback_fixture.make_universal_fallback())
ADRs honored: ADR-0002 (the plugin registers via the register_plugin(plugin) function call at module import time — NOT a decorator; the kernel is closed for modification), ADR-0003 (the concrete plugin's specificity-3 scope (vulnerability-remediation, node, npm) makes it the resolver head against the (*, *, *) universal), ADR-0004 (provides.vuln_index_capabilities carries the NVD/GHSA/OSV feed entrypoints — the kernel Plugin Protocol stays at four methods; the TCCM is observed via ConcreteResolution.composed_tccm.provides, NOT via plugin.manifest.tccm.provides), ADR-0011 (PLUGINS.lock is the integrity check populated when this plugin's tree first lands)
Validation notes (2026-05-19)¶
This story was hardened by the phase-story-validator pass on 2026-05-19. Summary of the changes:
- Block fixes (correctness). The original story prescribed a
plugin.yamland tests that contradicted the actual code surfaces shipped by S2-02 (PluginManifest), S2-03 (load_plugins), S2-04 (resolver), and S3-03 (vuln-index feeds). Specifically: scope:is a nested submodel (task_class | languages | build_systems), not a slug string.- The
tccm:reference iscontributes.tccm, not a top-level field — and./tccm.yamlis the schema default, so it may be omitted entirely. - Vuln-feed classes are
NvdFeed/GhsaFeed/OsvFeedundercodegenie.vuln_index.feeds.{nvd,ghsa,osv}— NOTNvdParseretc. undercodegenie.vuln_index.{nvd,ghsa,osv}. - The loader API is
compute_plugin_tree_digest+LockFile.from_path(bothResult-returning), notcompute_plugin_tree_sha256+read_plugins_lock. PluginSubgraphis a TYPE_CHECKING-only forward-ref stub incodegenie.plugins.protocols; it is NOT in__all__and cannot be runtime-imported.manifest.tccmis a path string (perManifestContributes); the parsed TCCM is observable onConcreteResolution.composed_tccm(via the resolver), not on the manifest.- Mutation-resistance hardening. Tests as originally drafted would pass for
scope: (*,*,*),precedence: 0,must_read: [], four entries invuln_index_capabilities, and a no-fallback-registered registry. ACs and tests were rewritten to assert field-level invariants (Concretevalue of each scope dim; exactprecedence; exact key-set ofvuln_index_capabilities; resolver runs against a registry where the universal fallback fixture is also registered). - Architectural gap surfaced (DEFERRED). The current resolver uses a placeholder
ComposedTccmwith onlyprovides+requires(seesrc/codegenie/plugins/resolver.py:120-134). The realTCCMwithmust_read/should_read/may_readlives incodegenie.plugins.tccm.TCCMbut the resolver has not been wired to load it from disk yet (_plugin_tccmreadsgetattr(plugin, "_composed_tccm", None)). The TCCM-loading upgrade is a precondition for this story'smust_readACs; until that upgrade lands, the plugin must expose_composed_tccmdirectly. The story's ACs were split accordingly — theprovidesassertion is observable today; themust_read/should_readassertions are gated on the resolver upgrade (called out as AC-13 below). - Anti-pattern removed. The original Implementation Outline shipped
__file__.replace("api.py", "plugin.yaml")then "fixed" it in Refactor — Rule-3 violation. The hardened outline writespathlib.Path(__file__).parent / "plugin.yaml"upfront and uses a@dataclass(frozen=True)instance shape (mirroringuniversal_fallback_fixture.py) instead of an ad-hoc class with manifest as a class-body attribute. - Slug-to-module mapping clarified. The note that "Python module name will need underscoring per the loader's slug-to-module mapping" was incorrect —
loader.py:289-293doesimportlib.import_module(f"plugins.{slug}.api")with the literal hyphenated slug, andtests/unit/plugins/test_loader.py:176-188confirms hyphenated dirs work whensys.pathincludes the parent ofplugins/(the production tree addsplugins/__init__.py; per-plugin dirs need their own__init__.pybecause the slug contains hyphens).
Context¶
This story lands the first concrete production plugin under the plugins/ directory and is the moment the kernel built in Step 2 (PluginRegistry, loader, PLUGINS.lock integrity check) first proves it can host a real plugin contributed by addition. The directory naming pattern is {task}--{language}--{build} — verbatim plugins/vulnerability-remediation--node--npm/ — and the contract surface is exactly the four kernel Plugin methods (manifest, build_subgraph, adapters, transforms) plus a tccm.yaml declaring must_read/should_read/may_read queries and the plugin-private provides.vuln_index_capabilities namespace (ADR-0004). The four adapters/ modules and the four recipes/ classes are S7-02's surface; this story stops at the scaffold + manifest + TCCM + @register_plugin(...) call + PLUGINS.lock row, so S7-02 can drop adapters and recipes into a tree the resolver is already happy with.
The plugin's manifest must declare the scope vulnerability-remediation--node--npm (parsed by PluginScope.parse(...) into three Concrete dims — specificity 3), precedence: 100 (higher than the universal's), and an extends: [] (no inheritance in Phase 3; depth-4 walk exercised only via the synthetic plugin in S7-04). The tccm.yaml declares the queries the BundleBuilder will dispatch against the four ADR-0032 adapters S7-02 ships. The api.py declaration calls @register_plugin(...) against default_registry so that, at plugin loader import time, the resolver can return a ConcreteResolution for (vulnerability-remediation, node, npm) scopes.
The story is intentionally narrow: scaffold + contract declaration, not behavior. Recipes and adapters arrive in S7-02; the synthetic third plugin and the three-plugin bake-test arrive in S7-04. The done test is the resolver resolves the scope to this concrete plugin, not the plugin actually remediates a CVE — that end-to-end claim is S8-02's.
References — where to look¶
- Architecture:
../phase-arch-design.md §Component design C2(thePluginProtocol's four methods:manifest,build_subgraph,adapters,transforms).../phase-arch-design.md §Scenarios D(loader walksplugins/*/plugin.yaml, registers each, the resolver composes TCCM left-to-right).../phase-arch-design.md §Component design C7(BundleBuilder readscomposed_tccmto dispatchmust_readqueries).../phase-arch-design.md §"Open questions deferred to implementation"—provides.vuln_index_capabilitiesshape.- Phase ADRs:
../ADRs/0002-plugin-registry-kernel-instance-with-default-singleton.md—@register_plugin(plugin, *, registry=None)is the only registration mechanism; production code usesdefault_registry.../ADRs/0003-plugin-resolution-and-universal-fallback-semantics.md— specificity ordering puts this plugin (3-Concrete-dim) above the universal(*,*,*)(0-Concrete-dim).../ADRs/0004-plugin-private-capabilities-via-tccm.md— task-class-specific NVD/GHSA/OSV parsers live in TCCMprovides, NOT on acve_feed_parsers()method on the kernelPlugin.../ADRs/0011-honest-framing-capability-sandboxedpath-pluginslock.md—PLUGINS.lockis the integrity check; CODEOWNERS gates edits.- Production ADRs:
../../../production/adrs/0031-plugin-architecture.md— the umbrella manifest shape this story instantiates.../../../production/adrs/0029-task-class-context-manifests.md— TCCMmust_read/should_read/may_readsemantics this story declares against.../../../production/adrs/0032-language-search-adapters.md— the four adapter Protocols thecontributes.adaptersmap points at (S7-02 implements them).- High-level impl:
../High-level-impl.md §"Step 7"— features delivered list. - Source precedent for the registration shape:
src/codegenie/plugins/registry.py(thedefault_registry+@register_plugindecorator from S2-01). - Source precedent for a probe-shaped explicit-import collection point:
src/codegenie/probes/__init__.py— the same explicit-import discipline applies to plugin loader (no entry-point scan).
Goal¶
Land plugins/vulnerability-remediation--node--npm/{plugin.yaml,api.py,tccm.yaml} plus an empty recipes/__init__.py and adapters/__init__.py (so S7-02 can drop files in), wire @register_plugin(...) into api.py, populate plugins/PLUGINS.lock with this plugin's tree sha256, and prove that default_registry.resolve(PluginScope.parse("vulnerability-remediation--node--npm")) returns a ConcreteResolution whose plugin.manifest.name == PluginId("vulnerability-remediation--node--npm") and whose composed_tccm.must_read is non-empty.
Acceptance criteria¶
- [ ] AC-1 (manifest shape).
plugins/vulnerability-remediation--node--npm/plugin.yamlparses cleanly throughPluginManifest.from_yaml(...)(extra="forbid"Pydantic-validated) into aPluginManifestwith: name == PluginId("vulnerability-remediation--node--npm")versionnon-empty ("0.1.0")scopeis aManifestScopewith exactlytask_class="vulnerability-remediation",languages="javascript",build_systems="npm"(per the existing fixture precedent attests/fixtures/plugins/loader_fixtures.py:36-42; do NOT usenode— Layer A'sLanguageDetectionemitsjavascript/typescript. The plugin's directory name uses--node--for human readability per production-ADR-0031 §dir convention, but the innerlanguages:token must match Layer A. See validation note.)extends == ()(empty tuple — no inheritance in Phase 3)precedence == 100(overridden from the schema default of 50; the explicit value is the audit anchor for "concrete plugins use higher precedence than the universal fallback's 0")contributes.adaptersis a non-emptydict[PrimitiveName, str]whose values aremodule:Classstrings (shape^[a-zA-Z_][a-zA-Z0-9_.]*:[A-Z][a-zA-Z0-9_]*$) pointing at the four ADR-0032 import paths S7-02 will land — keysdep_graph,import_graph,scip,test_inventory(one each)contributes.tccmeither omitted (relies on schema default"./tccm.yaml") or present with that value. No top-leveltccm:field (forbidden byextra="forbid").- [ ] AC-2 (api.py shape).
plugins/vulnerability-remediation--node--npm/api.pydefines aPlugin-Protocol-conforming@dataclass(frozen=True)(mirroringtests/fixtures/plugins/universal_fallback_fixture._UniversalFallbackPlugin), binds it asplugin: Final = _VulnNodeNpmPlugin(manifest=_load_manifest()), and callsregister_plugin(plugin)exactly once at module-import time againstdefault_registry. The manifest-loading path usespathlib.Path(__file__).parent / "plugin.yaml"(NOT__file__.replace); see Notes-for-implementer for the load-failure routing. - [ ] AC-3 (tccm.yaml shape).
plugins/vulnerability-remediation--node--npm/tccm.yamlparses cleanly throughcodegenie.plugins.tccm.TCCM.model_validate(yaml.safe_load(...))and exposes: must_readis a list ofContextQueryof length ≥ 1, where at least one entry hasprimitive == "dep_graph.consumers"(one of the five closed_KNOWN_PRIMITIVESfromtccm.py:78-87); theargsdict contains apackagekey (the affected-package template variable BundleBuilder will substitute)should_readis a list ofContextQueryof length ≥ 1, with at least oneprimitive == "import_graph.transitive_callers"ORprimitive == "scip.refs"(the index-health-freshness substitute; pin one and add a Notes line citing the choice)may_readmay be emptyprovidescontains the key"vuln_index_capabilities", andprovides["vuln_index_capabilities"]is adict[str, str]whose key-set is exactly{"nvd_parser", "ghsa_parser", "osv_parser"}(three; not two, not four) and whose values are the literal import pathscodegenie.vuln_index.feeds.nvd:NvdFeed,codegenie.vuln_index.feeds.ghsa:GhsaFeed,codegenie.vuln_index.feeds.osv:OsvFeed(the actual S3-03 class names — NOTNvdParser/GhsaParser/OsvParser)- Each value resolves at plugin-load time:
importlib.import_module(module)+getattr(mod, class_name)returns a non-None class — verified by a targeted unit test in §TDD that loops over the three entries. - [ ] AC-4 (
PLUGINS.lockrow).plugins/PLUGINS.lock, parsed viaLockFile.from_path(Path("plugins/PLUGINS.lock")).unwrap(), contains an entry whose key isPluginId("vulnerability-remediation--node--npm")and whose value is aBlobDigest(64-lowercase-hex matching^[0-9a-f]{64}$) equal tocompute_plugin_tree_digest(Path("plugins/vulnerability-remediation--node--npm")).unwrap(). Re-derived via eithercodegenie plugins lock-update(if S2-03 shipped that helper) or by-hand fromcompute_plugin_tree_digest; the commit message names which method was used. - [ ] AC-5 (loader registration witness). Running
load_plugins(Path("plugins"), Path("plugins/PLUGINS.lock"), registry=PluginRegistry())against a freshPluginRegistryinstance (notdefault_registry) returnsOk(LoadReport(loaded=tuple_containing_our_plugin_id, total_walked=N)), and the registry passed in has the plugin registered (registry.get(PluginId("vulnerability-remediation--node--npm")) is plugin). The test must NOT rely ondefault_registrymutation — that path is reserved for production CLI use; tests use fresh registries (ADR-0002 §Consequences row 7). - [ ] AC-6 (resolution to ConcreteResolution). With a fresh
PluginRegistrypopulated with both this plugin AND the universal fallback fixture (make_universal_fallback()), callingregistry.resolve(PluginScope.parse("vulnerability-remediation--node--npm").unwrap())returns aConcreteResolutionwhere: resolution.kind == "concrete"resolution.plugin.manifest.name == PluginId("vulnerability-remediation--node--npm")resolution.matched_scopehas all three dims asConcrete(notWildcard) with values"vulnerability-remediation","javascript"(or whatever language token AC-1 settled on),"npm"resolution.extends_chain == (resolution.plugin,)(length-1 chain, head is self — emptyextendsmeans the only entry in the chain is the plugin itself)isinstance(resolution, UniversalFallbackResolution)is False — this assertion is meaningful BECAUSE the universal fallback IS registered in the test setup. Without the fallback registered, the negative would be vacuous.- [ ] AC-7 (
composed_tccm.providesflows through). The sameConcreteResolutionfrom AC-6 exposesresolution.composed_tccm.provides["vuln_index_capabilities"]with the same three-entry key-set described in AC-3. (Note: this validates the resolver actually composed the plugin's TCCM and surfacedprovidescorrectly — the resolver loads each plugin's_composed_tccmvia_plugin_tccm; this story's plugin exposes it via the dataclass field, see Notes-for-implementer.) - [ ] AC-8 (no LLM SDKs).
make fenceandmake lint-importsstay green; no module underplugins/vulnerability-remediation--node--npm/importsanthropic,langgraph,openai,langchain, ortransformers(Phase 3 fence contracts from S1-05). - [ ] AC-9 (CODEOWNERS for lockfile).
grep -F 'PLUGINS.lock' CODEOWNERSreturns a non-empty match assigning the platform team (per ADR-0011 §Consequences row 4). If S2-03 already landed it, this story verifies and leaves untouched. - [ ] AC-10 (idempotency / single-registration). Importing
plugins.vulnerability_remediation__node__npm.api(Python-module form perloader.py:289-293) twice into the same registry raisesPluginAlreadyRegistered; the test exercises this by callingregister_plugin(plugin, registry=fresh_registry)twice and asserts the second call raises with both colliding-origin strings in the error message. - [ ] AC-11 (TCCM rejection coverage). A mutated-fixture
tccm.yamlwhosemust_readentry hasprimitive: "dep_graph.consumer"(typo — missing terminals) is rejected at plugin-load time as aPluginRejectedvariant (likely surfaced via the resolver's_plugin_tccm/the manifest's TCCM loader); the test pins the typed variant rather thanException. - [ ] AC-12 (precedence is honored, not defaulted). With a second fixture plugin registered at the same scope
(vulnerability-remediation, javascript, npm)butprecedence=50(the schema default),registry.resolve(...)returns this story's plugin (theprecedence=100one) at the head. (Mutation guard: prevents an implementer from silently relying on the schema default.) - [ ] AC-13 (DEFERRED — TCCM
must_read/should_readend-to-end flow). As of 2026-05-19 the resolver'sComposedTccmplaceholder exposes onlyprovidesandrequires(notmust_read/should_read/may_read). This AC is blocked on a separate story that wires the resolver to use the realcodegenie.plugins.tccm.TCCM. When that lands, this AC fires:resolution.composed_tccm.must_readis non-empty AND every entry's.primitiveis a member of_KNOWN_PRIMITIVES. Until the resolver upgrade ships, S7-01's AC-7 (providesonly) is the sufficient observable. - [ ] AC-14 (red test landed and now green). The red tests from §TDD plan exist on a commit annotated "red" and are green on the final commit; the attempt log under
_attempts/S7-01-vuln-node-npm-plugin-scaffold.mdnames the red-commit SHA. - [ ] AC-15 (toolchain clean).
ruff format --check,ruff check,mypy --strictclean on touched files; the new unit + integration tests pass; existingpytest tests/unit/plugins/test_loader.pystill green (the new plugin tree must not break any sibling test).
Implementation outline¶
- Create the directory tree:
plugins/vulnerability-remediation--node--npm/ __init__.py # empty; required because the slug contains hyphens (see `tests/unit/plugins/test_loader.py:182-188`) plugin.yaml tccm.yaml api.py recipes/__init__.py # empty; S7-02 fills adapters/__init__.py # empty; S7-02 fillssubgraph/__init__.pyis omitted until a real subgraph override is needed (Rule 2 / Simplicity First — the manifest defaultcontributes.subgraph: str = "./subgraph/"is a path-pointer that does not require an existing directory for the loader; if a future story needs an override, the directory can be added then). The plugin'sbuild_subgraph(registry)returns the orchestrator's default subgraph imported from S6-04 (see step 4 — call out the exact import once S6-04 finalises the export). The loader doesimportlib.import_module(f"plugins.{slug}.api")with the literal hyphenated slug;plugins/__init__.py(top-level) and the per-plugin__init__.pyboth must exist so Python's filesystem-based finder accepts the dotted-name lookup. plugin.yaml— minimal validPluginManifest. Hand-compose; do NOT usemodel_dump_yamlfrom a constructed instance (that would couple this file to Pydantic field order). Canonical shape:name: vulnerability-remediation--node--npm version: "0.1.0" scope: task_class: vulnerability-remediation languages: javascript build_systems: npm extends: [] precedence: 100 contributes: adapters: dep_graph: plugins.vulnerability_remediation__node__npm.adapters.dep_graph:NpmDepGraphAdapter import_graph: plugins.vulnerability_remediation__node__npm.adapters.import_graph:NpmImportGraphAdapter scip: plugins.vulnerability_remediation__node__npm.adapters.scip:NpmScipAdapter test_inventory: plugins.vulnerability_remediation__node__npm.adapters.test_inventory:NpmTestInventoryAdapterextends: []Pydantic-validates as the empty tuple().contributes.tccmis omitted; the schema default"./tccm.yaml"applies.requirementsis omitted; the schema defaultManifestRequirements()applies.- No top-level
tccm:key — that would tripextra="forbid"→SchemaViolation. - The adapter import-path strings target modules S7-02 will create; YAML parse only validates shape (
module:Classgrammar viatccm.py:_IMPORT_PATH_RE); the actual import resolution fires at S7-02's runtime when the BundleBuilder dispatches. ConfirmConcreteclass-name suffixes (e.g.,NpmDepGraphAdapter) match what S7-02 will land — if S7-02's names differ at story-execution time, this story'scontributes.adaptersvalue strings must be updated as part of S7-02, not retroactively. tccm.yaml— canonical shape:must_read: - primitive: dep_graph.consumers args: package: "{vulnerability.affected_package}" should_read: - primitive: scip.refs args: symbol: "{vulnerability.affected_symbol}" max_files: 50 may_read: [] provides: vuln_index_capabilities: nvd_parser: codegenie.vuln_index.feeds.nvd:NvdFeed ghsa_parser: codegenie.vuln_index.feeds.ghsa:GhsaFeed osv_parser: codegenie.vuln_index.feeds.osv:OsvFeed requires: {}- Every
primitivemust be a member of the closed set_KNOWN_PRIMITIVES(seesrc/codegenie/plugins/tccm.py:78-87). The two used here aredep_graph.consumersandscip.refs. provides.vuln_index_capabilitiesimport-path values point at the real S3-03 classes —NvdFeed,GhsaFeed,OsvFeedundercodegenie.vuln_index.feeds.*. AC-3 verifies these resolve at load time.argsvalue types restricted tostr | int | bool | list[str](perContextQuery.argsannotation).api.py— module body, frozen-dataclass shape mirroringtests/fixtures/plugins/universal_fallback_fixture.py:"""Vulnerability-remediation Node/npm plugin — first concrete production plugin. Per ADR-0002 the registration is a function call, not a decorator. Per ADR-0004 task-class-specific capabilities live on TCCM provides, not on the kernel Plugin Protocol. """ from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path from typing import Any, Final from codegenie.plugins.manifest import PluginManifest from codegenie.plugins.protocols import Adapter, Plugin, RecipeEngine from codegenie.plugins.registry import register_plugin from codegenie.plugins.tccm import TCCM # only if the TCCM is exposed via _composed_tccm; see Notes from codegenie.types.identifiers import PluginId, PrimitiveName, TransformKind _MANIFEST_PATH: Final[Path] = Path(__file__).parent / "plugin.yaml" _TCCM_PATH: Final[Path] = Path(__file__).parent / "tccm.yaml" def _load_manifest() -> PluginManifest: """Load and validate the plugin's manifest at module-import time. Failure routing: `from_yaml` returns Result; on `Err` the unwrap raises and surfaces through `loader.py:289-293` as a typed `PluginImportError`. Note that this is a *defence-in-depth* reload: the loader already validated the manifest in Gate 2 before importing this module. Re-loading here keeps the plugin self-contained for ad-hoc Python imports outside the loader. """ result = PluginManifest.from_yaml(_MANIFEST_PATH) if result.is_err(): raise RuntimeError(f"plugin manifest invalid: {result.unwrap_err()!r}") return result.unwrap() @dataclass(frozen=True) class _VulnNodeNpmPlugin: """Plugin-Protocol-conforming instance for the vuln node/npm plugin.""" manifest: PluginManifest _adapters: dict[PrimitiveName, Adapter] = field(default_factory=dict) _transforms: dict[TransformKind, RecipeEngine] = field(default_factory=dict) def build_subgraph(self, registry: Any) -> Any: # Returns the orchestrator's default 5-node subgraph; S6-04 owns # the exact import. Until that lands, raise NotImplementedError — # the resolver does not call build_subgraph during S7-01's tests. raise NotImplementedError("subgraph wiring is S6-04 / S7-02's surface") def adapters(self) -> dict[PrimitiveName, Adapter]: return dict(self._adapters) def transforms(self) -> dict[TransformKind, RecipeEngine]: return dict(self._transforms) plugin: Final[_VulnNodeNpmPlugin] = _VulnNodeNpmPlugin(manifest=_load_manifest()) register_plugin(plugin)- Do NOT import
PluginSubgraphfromcodegenie.plugins.protocols— it is only aTYPE_CHECKINGforward-ref stub and is not in__all__. Thebuild_subgraphreturn annotation isAnyuntil S6-04 lands the real type. - Do NOT call
register_plugininsideif __name__ == "__main__":or any conditional — the loader's Gate 4 relies on the unconditional import side-effect (ADR-0002). - The
_adaptersand_transformsdicts are empty in this story; S7-02 lands the values. The dataclass-field default usesfield(default_factory=dict)so the empty-state is typed correctly. - TCCM exposure (interim). Because the resolver currently reads
getattr(plugin, "_composed_tccm", None)(placeholderComposedTccmwith onlyprovides/requires), this story's plugin must additionally expose a_composed_tccm: ComposedTccmfield built from the plugin'stccm.yaml'sprovides/requiresmap. Themust_read/should_readfields land via the resolver upgrade tracked by AC-13. PLUGINS.lockrow — compute the SHA-256 tree digest viacompute_plugin_tree_digest(Path("plugins/vulnerability-remediation--node--npm"))(fromsrc/codegenie/plugins/loader.py). Append the JSON-shaped entry toplugins/PLUGINS.lock(the file is a JSON objectdict[PluginId, BlobDigest]perLockFile's schema). Document the operator workflow in the commit message (codegenie plugins lock-updateif the helper exists; otherwise the hand-computation Python one-liner usingcompute_plugin_tree_digest).- CODEOWNERS —
grep -F 'PLUGINS.lock' CODEOWNERSto verify S2-03 already landed the entry; if absent, add the entry per ADR-0011 §Consequences row 4. - Tests — see TDD plan.
TDD plan — red / green / refactor¶
Red¶
Test file path: tests/unit/plugins/test_vuln_node_npm_plugin_scaffold.py (unit) and tests/integration/plugins/test_vuln_node_npm_plugin_load.py (integration).
Tests use fresh PluginRegistry() instances populated by load_plugins(...) — they do NOT depend on default_registry mutation (ADR-0002 §Consequences row 7). Tests that need the universal fallback also register make_universal_fallback() so the negative assertions are meaningful.
# tests/unit/plugins/test_vuln_node_npm_plugin_scaffold.py
from __future__ import annotations
import importlib
from pathlib import Path
import pytest
from codegenie.plugins.errors import PluginAlreadyRegistered
from codegenie.plugins.loader import compute_plugin_tree_digest, load_plugins
from codegenie.plugins.lockfile import LockFile
from codegenie.plugins.manifest import PluginManifest
from codegenie.plugins.registry import PluginRegistry, register_plugin
from codegenie.plugins.resolver import (
ConcreteResolution,
UniversalFallbackResolution,
)
from codegenie.plugins.scope import Concrete, PluginScope
from codegenie.plugins.tccm import TCCM
from codegenie.types.identifiers import BlobDigest, PluginId, PrimitiveName
from tests.fixtures.plugins.universal_fallback_fixture import make_universal_fallback
_PLUGIN_ROOT = Path("plugins")
_PLUGIN_DIR = _PLUGIN_ROOT / "vulnerability-remediation--node--npm"
_PLUGIN_ID = PluginId("vulnerability-remediation--node--npm")
@pytest.fixture
def loaded_registry() -> PluginRegistry:
"""Fresh registry, populated by running the real loader against `plugins/`."""
registry = PluginRegistry()
result = load_plugins(_PLUGIN_ROOT, _PLUGIN_ROOT / "PLUGINS.lock", registry=registry)
assert result.is_ok(), f"loader returned {result.unwrap_err()!r}"
return registry
# --- AC-1: manifest shape (field-level pins) ---
def test_manifest_loads_and_has_exact_field_values() -> None:
manifest = PluginManifest.from_yaml(_PLUGIN_DIR / "plugin.yaml").unwrap()
assert manifest.name == _PLUGIN_ID
assert manifest.version == "0.1.0"
assert manifest.scope.task_class == "vulnerability-remediation"
assert manifest.scope.languages == "javascript"
assert manifest.scope.build_systems == "npm"
assert manifest.extends == ()
assert manifest.precedence == 100 # NOT default 50
# Adapter map keys are exactly the four ADR-0032 primitives.
assert set(manifest.contributes.adapters.keys()) == {
PrimitiveName("dep_graph"),
PrimitiveName("import_graph"),
PrimitiveName("scip"),
PrimitiveName("test_inventory"),
}
# --- AC-3: TCCM shape + import-path resolution ---
def test_tccm_declares_vuln_index_capabilities_with_exact_keyset() -> None:
import yaml
data = yaml.safe_load((_PLUGIN_DIR / "tccm.yaml").read_text(encoding="utf-8"))
tccm = TCCM.model_validate(data)
# must_read non-empty AND uses known primitive.
assert len(tccm.must_read) >= 1
assert tccm.must_read[0].primitive == PrimitiveName("dep_graph.consumers")
# provides has exactly the three expected feed entries.
vic = tccm.provides["vuln_index_capabilities"]
assert set(vic.keys()) == {"nvd_parser", "ghsa_parser", "osv_parser"}
# Each import path resolves to a real class object at load time.
for key in ("nvd_parser", "ghsa_parser", "osv_parser"):
module_path, _, class_name = vic[key].partition(":")
mod = importlib.import_module(module_path)
cls = getattr(mod, class_name)
assert isinstance(cls, type), f"{vic[key]} did not resolve to a class"
# --- AC-4: lockfile row ---
def test_plugins_lock_row_matches_recomputed_tree_digest() -> None:
lockfile = LockFile.from_path(_PLUGIN_ROOT / "PLUGINS.lock").unwrap()
recomputed: BlobDigest = compute_plugin_tree_digest(_PLUGIN_DIR).unwrap()
assert lockfile.root[_PLUGIN_ID] == recomputed
# --- AC-5: loader registers via fresh registry ---
def test_loader_registers_plugin_in_fresh_registry(loaded_registry: PluginRegistry) -> None:
plugin = loaded_registry.get(_PLUGIN_ID)
assert plugin.manifest.name == _PLUGIN_ID
# --- AC-6: resolution to ConcreteResolution against a registry that also has the fallback ---
def test_resolution_returns_concrete_and_not_fallback_when_fallback_is_registered(
loaded_registry: PluginRegistry,
) -> None:
# Register the universal fallback so the negative is meaningful.
loaded_registry.register(make_universal_fallback())
scope = PluginScope.parse("vulnerability-remediation--javascript--npm").unwrap()
resolution = loaded_registry.resolve(scope)
assert isinstance(resolution, ConcreteResolution)
assert not isinstance(resolution, UniversalFallbackResolution)
assert resolution.plugin.manifest.name == _PLUGIN_ID
# Field-level assertions on matched_scope — all Concrete, NOT wildcard.
assert isinstance(resolution.matched_scope.task_class, Concrete)
assert resolution.matched_scope.task_class.value == "vulnerability-remediation"
assert isinstance(resolution.matched_scope.language, Concrete)
assert resolution.matched_scope.language.value == "javascript"
assert isinstance(resolution.matched_scope.build_system, Concrete)
assert resolution.matched_scope.build_system.value == "npm"
# extends_chain is length-1 (self only) when extends == ().
assert len(resolution.extends_chain) == 1
assert resolution.extends_chain[0] is resolution.plugin
# --- AC-7: composed_tccm.provides flows through ---
def test_composed_tccm_provides_vuln_index_capabilities_after_resolution(
loaded_registry: PluginRegistry,
) -> None:
loaded_registry.register(make_universal_fallback())
scope = PluginScope.parse("vulnerability-remediation--javascript--npm").unwrap()
resolution = loaded_registry.resolve(scope)
assert isinstance(resolution, ConcreteResolution)
vic = resolution.composed_tccm.provides["vuln_index_capabilities"]
assert set(vic.keys()) == {"nvd_parser", "ghsa_parser", "osv_parser"}
# --- AC-10: double-registration raises PluginAlreadyRegistered ---
def test_double_register_raises_typed_collision(loaded_registry: PluginRegistry) -> None:
existing = loaded_registry.get(_PLUGIN_ID)
with pytest.raises(PluginAlreadyRegistered):
register_plugin(existing, registry=loaded_registry)
# --- AC-12: precedence is honored against a peer at the same scope ---
def test_precedence_100_beats_peer_at_default_50(loaded_registry: PluginRegistry) -> None:
# Construct a peer plugin at the same scope but with the schema default.
# Use model_construct to bypass `name` collision against the loaded plugin
# — the peer has a distinct id but a colliding scope.
from dataclasses import dataclass, field
from typing import Any
from codegenie.plugins.manifest import (
ManifestContributes,
ManifestRequirements,
ManifestScope,
PluginManifest,
)
peer_manifest = PluginManifest.model_construct(
name=PluginId("vulnerability-remediation--javascript--npm-peer-fixture"),
version="0.0.0",
scope=ManifestScope(
task_class="vulnerability-remediation",
languages="javascript",
build_systems="npm",
),
extends=(),
precedence=50, # the schema default — the mutation surface.
contributes=ManifestContributes(),
requirements=ManifestRequirements(),
)
@dataclass(frozen=True)
class _Peer:
manifest: PluginManifest
def build_subgraph(self, registry: Any) -> Any: raise NotImplementedError
def adapters(self) -> dict[Any, Any]: return {}
def transforms(self) -> dict[Any, Any]: return {}
loaded_registry.register(_Peer(manifest=peer_manifest)) # type: ignore[arg-type]
scope = PluginScope.parse("vulnerability-remediation--javascript--npm").unwrap()
resolution = loaded_registry.resolve(scope)
assert isinstance(resolution, ConcreteResolution)
assert resolution.plugin.manifest.name == _PLUGIN_ID # NOT the peer.
A second TCCM-rejection test exercises AC-11:
# tests/unit/plugins/test_vuln_node_npm_plugin_tccm_rejection.py
import pytest
from pydantic import ValidationError
from codegenie.plugins.tccm import TCCM
def test_tccm_rejects_unknown_primitive_typo() -> None:
"""A typo in the primitive name (`consumer` vs `consumers`) must
raise at parse time, not silently pass through."""
payload = {
"must_read": [{"primitive": "dep_graph.consumer", "args": {"package": "p"}}],
"should_read": [],
"may_read": [],
"provides": {},
"requires": {},
}
with pytest.raises(ValidationError):
TCCM.model_validate(payload)
Property test for tree-digest invariance (mirrors the existing tests/unit/plugins/test_loader_digest_property.py precedent):
# tests/unit/plugins/test_vuln_node_npm_tree_digest_property.py
from pathlib import Path
import hypothesis
from hypothesis import strategies as st
from codegenie.plugins.loader import compute_plugin_tree_digest
# Property: re-running compute_plugin_tree_digest against the same plugin
# tree returns byte-identical digests across N invocations. Catches any
# accidental non-determinism (sort key drift, locale-dependent encoding,
# walk-order leak).
@hypothesis.given(_n=st.integers(min_value=2, max_value=8))
def test_tree_digest_is_idempotent(_n: int) -> None:
plugin_dir = Path("plugins/vulnerability-remediation--node--npm")
first = compute_plugin_tree_digest(plugin_dir).unwrap()
for _ in range(_n - 1):
again = compute_plugin_tree_digest(plugin_dir).unwrap()
assert again == first
Run all four test files; confirm each one fails for the expected reason (the plugin directory does not yet exist → IoError / FileNotFoundError); commit at red with the SHA recorded in _attempts/S7-01-vuln-node-npm-plugin-scaffold.md.
Green¶
Land the directory tree per §Implementation outline step 1, the plugin.yaml per step 2, the tccm.yaml per step 3, the api.py per step 4, and the PLUGINS.lock row per step 5. Verify each AC's named test passes; the property test should be deterministic.
Refactor¶
- Confirm
mypy --strictclean acrossplugins/vulnerability-remediation--node--npm/api.pyand all new test files.Pluginis a Protocol; structural conformance is enough; noclass _VulnNodeNpmPlugin(Plugin):inheritance. - Re-read
api.pyfor any forgottenprint/ debug-log statements; the plugin module body is import-time-only — anything printed leaks into the loader's startup output. - Confirm the
forbidden-patternspre-commit hook does not flag any pattern in the new files (it shouldn't — but theplugins/subtree is new ground for the hook). - Verify CODEOWNERS for
plugins/PLUGINS.lock(AC-9) is in place. - Note in
_attempts/S7-01-vuln-node-npm-plugin-scaffold.md: themust_read/should_readend-to-end assertions (AC-13) are blocked on the resolver upgrade story — add that story as a follow-up dependency for whichever story integrates with the BundleBuilder (likely S7-02 or S8-02).
Files to touch¶
| Path | Why |
|---|---|
plugins/vulnerability-remediation--node--npm/__init__.py |
New — empty; required for Python's filesystem-based finder to admit the hyphenated dir (see tests/unit/plugins/test_loader.py:182-188) |
plugins/vulnerability-remediation--node--npm/plugin.yaml |
New — PluginManifest YAML (nested scope, precedence: 100, extends: [], contributes.adapters map; NO top-level tccm:) |
plugins/vulnerability-remediation--node--npm/tccm.yaml |
New — must_read referencing dep_graph.consumers, should_read referencing scip.refs, provides.vuln_index_capabilities with exact three-entry key-set pointing at the real codegenie.vuln_index.feeds.*:*Feed classes |
plugins/vulnerability-remediation--node--npm/api.py |
New — @dataclass(frozen=True) plugin instance + register_plugin(plugin) call at module-import time; uses pathlib.Path(__file__).parent / "plugin.yaml" (NOT __file__.replace); imports do NOT pull PluginSubgraph (TYPE_CHECKING-only) |
plugins/vulnerability-remediation--node--npm/recipes/__init__.py |
New — empty; S7-02 populates |
plugins/vulnerability-remediation--node--npm/adapters/__init__.py |
New — empty; S7-02 populates |
plugins/PLUGINS.lock |
Modified — add the row keyed by PluginId("vulnerability-remediation--node--npm") with value BlobDigest from compute_plugin_tree_digest(...) |
CODEOWNERS |
Modified IFF S2-03 did not already add plugins/PLUGINS.lock ownership (verify via grep -F 'PLUGINS.lock' CODEOWNERS first) |
tests/unit/plugins/test_vuln_node_npm_plugin_scaffold.py |
New — unit tests covering AC-1, AC-3, AC-4, AC-5, AC-6, AC-7, AC-10, AC-12 |
tests/unit/plugins/test_vuln_node_npm_plugin_tccm_rejection.py |
New — AC-11 typed-rejection test for malformed tccm.yaml |
tests/unit/plugins/test_vuln_node_npm_tree_digest_property.py |
New — Hypothesis property test for AC-4's tree-digest determinism |
Out of scope¶
- Recipe implementations (
NpmLockfileSemverBumpRecipe, etc.) — S7-02. - Adapter implementations (the four ADR-0032 npm adapters) — S7-02.
- NVD/GHSA/OSV parser code — S3-03 ships those; this story only references them by import path.
- End-to-end Express CVE test — S8-02. This story stops at "the resolver returns this plugin for the scope," not "the plugin successfully remediates."
- Plugin signing (Sigstore) — Phase 11; ADR-0011 explicitly defers.
- OpenRewriteRecipeEngine wiring — S5-03 ships the scaffold; this plugin's
transforms()does not list it in Phase 3 (Dockerfile fixture is Phase-7-tagged).
Notes for the implementer¶
- Scope parsing.
PluginScope.parse("vulnerability-remediation--javascript--npm")succeeds — each dim matches^[a-z0-9_-]+$and the hyphen invulnerability-remediationis in-grammar (Concrete.valuecarries the full string verbatim). The same is true forvulnerability-remediation--node--npm; we pickjavascript(notnode) for the innerlanguages:field because Layer A'sLanguageDetectionemitsjavascript/typescript— the BundleBuilder's matching against probe outputs needs an exact string match. The plugin directory uses--node--for operator readability (everynodeplugin is npm-family); the manifest scope'slanguages:field usesjavascript. This split is intentional and documented here so the next plugin's author sees the precedent. - Hyphenated module imports work.
loader.py:289-293doesimportlib.import_module(f"plugins.{slug}.api")with the literal hyphenated slug. Python's filesystem-based finder happily resolves the dotted name to a directory even when the name parts contain hyphens (proof:tests/unit/plugins/test_loader.py:176-188). The per-plugin__init__.pyis required because Python needs the directory marked as a package; the top-levelplugins/__init__.pyalready exists. - The
adapters()returning{}is correct in this story. The BundleBuilder will surface a missing adapter asAdapterConfidence.Unavailableand either fall back to a TCCM-declared substitute or logLowConfidenceAnswerUsed. The contract is satisfied; behavior arrives in S7-02. PLUGINS.lockrow computation. Usecompute_plugin_tree_digestdirectly (seesrc/codegenie/plugins/loader.py:158-185). The algorithm:_collect_plugin_fileswalks the tree, skips__pycache__/and*.pyc, refuses symlink-escapes, returns sorted(relpath, bytes)pairs;tree_digest_of_files(incodegenie.hashing) is the chokepoint that produces the 64-hex SHA-256. The lockfile's value is then lifted throughparse_blob_digestinto aBlobDigest.- The
recipes/__init__.py+adapters/__init__.pyMUST exist even when empty — without them, the Python import system won't treat the directories as packages and S7-02 will trip onModuleNotFoundError: plugins.vulnerability_remediation__node__npm.recipes. - Do NOT register two plugins in one file.
register_plugin(plugin)is called exactly once inapi.py; the registry raisesPluginAlreadyRegisteredon collision. forbidden-patternshook surface. The Phase 0 pre-commit hook banssubprocess.run(..., shell=True),os.system,os.popen,eval(,exec(,__import__(,pickle.loads. The plugin directory inherits the same coverage; the loader (S2-03) does its ownimportlib.import_module(...)which is allowed. Do not add an__import__call here.- No probe import. This plugin does NOT import from
codegenie.probes. The TCCM'smust_readquery references probe outputs at runtime via the BundleBuilder; the YAML is the seam. Importingcodegenie.probes.layer_d.foodirectly would break the layering and trip Phase 3's import-linter contract from S1-05. PluginSubgraphis unimportable at runtime. It lives underif TYPE_CHECKING:incodegenie/plugins/protocols.py:35-41and is intentionally not in__all__. UseAnyfor thebuild_subgraphreturn annotation until S6-04 lands the real type, and rely onfrom __future__ import annotationsto defer evaluation.- TCCM placeholder ↔ real TCCM gap. The resolver currently uses a placeholder
ComposedTccmwith onlyprovides/requires(seesrc/codegenie/plugins/resolver.py:120-134); the realTCCMwithmust_read/should_read/may_readlives incodegenie.plugins.tccm. The resolver loads each plugin's TCCM via_plugin_tccmwhich doesgetattr(plugin, "_composed_tccm", None)— so this plugin's_VulnNodeNpmPlugindataclass must expose a_composed_tccm: ComposedTccmfield built from the YAML'sprovides/requires. Until a follow-up story upgrades the resolver to use the realTCCM(covered by AC-13), themust_read/should_readassertions cannot be tested end-to-end through the resolver — they are tested by directly parsingtccm.yamlviaTCCM.model_validate(...)(see AC-3). - Rule-of-three observation. S7-01 is the first concrete production plugin; S7-04 will make 3 (with the synthetic third plugin). Do not extract a
PluginScaffoldhelper now (Rule 2 — three similar lines is better than premature abstraction). When S7-04 lands, audit the copy-paste overlap withapi.py; if >60% is duplicated, lift amake_plugin(manifest_path) -> Pluginfactory at that point. Document this rule-of-three trigger inapi.py's module docstring so the next author sees the precedent. - Class-body manifest load: defence-in-depth re-read. The loader's Gate 2 already parses
plugin.yamland surfacesSchemaViolation/MalformedYamlcleanly before Gate 4 importsapi.py. The_load_manifest()call inapi.pyre-parses the same YAML at module-import time, redundant with Gate 2. The redundancy is intentional: it keeps the plugin self-contained for ad-hoc Python imports outside the loader (e.g., a future REPL session) and ensures the plugin'smanifestfield always reflects the on-disk YAML. The redundancy cost is one file read per process startup, which is negligible. Failure routing: if the YAML is malformed at this point,_load_manifest()raisesRuntimeError, which the loader's Gate 4 catches and surfaces asPluginImportError— operator sees the typed variant, but with a less-precise reason than Gate 2'sSchemaViolation. This is acceptable because Gate 2 would have caught the same condition first in normal operation. - Resist gold-plating. Rule 2 (Simplicity First) — do not pre-emptively add
should_readqueries you can't justify; do not pre-emptively wireextendseven to the universal plugin (Phase 3'sextends_chainempty is the documented happy path); do not pre-emptively add asubgraph/directory (the orchestrator's default subgraph applies until a real reason to override arrives).