diff --git a/mcp_server/core/change_remediation.py b/mcp_server/core/change_remediation.py index 12ebe3d4..08e97da2 100644 --- a/mcp_server/core/change_remediation.py +++ b/mcp_server/core/change_remediation.py @@ -24,6 +24,7 @@ from __future__ import annotations from enum import Enum +from typing import Protocol class Remediation(str, Enum): @@ -31,6 +32,35 @@ class Remediation(str, Enum): FLAG_STALE = "flag_stale" +class _ImpactMatch(Protocol): + """The subset of ``core.change_impact_matcher.ImpactMatch`` this needs. + + Duck-typed (not imported) so this policy module stays a leaf. + """ + + memory_id: int + matched_files: list[str] + + +def build_impacted( + matches: list[_ImpactMatch], memory_by_id: dict[int, dict] +) -> list[dict]: + """Glue ``change_impact`` output onto ``remediate_impacted`` input. + + ``change_impact`` yields one ``ImpactMatch`` per impacted memory, whose + ``matched_files`` is exactly the subset of that memory's file references the + commit changed. Attach it to the memory dict as ``changed_refs`` — the shape + ``handlers/consolidation/change_remediation_pass.remediate_impacted`` + consumes. Matches with no known memory are dropped. + """ + impacted: list[dict] = [] + for m in matches: + mem = memory_by_id.get(m.memory_id) + if mem is not None: + impacted.append({**mem, "changed_refs": list(m.matched_files)}) + return impacted + + def is_code_derived(memory: dict) -> bool: """True when the memory was produced by codebase ingestion. diff --git a/mcp_server/handlers/consolidation/change_remediation_pass.py b/mcp_server/handlers/consolidation/change_remediation_pass.py index fd3bdb2a..954df3aa 100644 --- a/mcp_server/handlers/consolidation/change_remediation_pass.py +++ b/mcp_server/handlers/consolidation/change_remediation_pass.py @@ -21,7 +21,11 @@ import logging from typing import Callable, Protocol -from mcp_server.core.change_remediation import Remediation, classify_remediation +from mcp_server.core.change_remediation import ( + Remediation, + build_impacted, + classify_remediation, +) logger = logging.getLogger(__name__) @@ -56,3 +60,20 @@ def remediate_impacted( counts["reingest_paths"] = len(reingest_paths) logger.info("change remediation: %s", counts) return counts + + +def remediate_from_impact( + matches: list, + memory_by_id: dict[int, dict], + store: _RemediationStore, + reingest_fn: ReingestFn, +) -> dict[str, int]: + """Drive remediation straight from ``change_impact``'s match output. + + ``matches`` is the ``ImpactMatch`` list from ``handlers/change_impact.py`` + (each carrying ``memory_id`` + the changed ``matched_files``); ``memory_by_id`` + resolves those ids to memory dicts. This is the one composition the caller + needs — supply the real ``reingest_fn`` (an incremental ``codebase_analyze`` + over the changed paths), validated against AP + a real codebase. + """ + return remediate_impacted(build_impacted(matches, memory_by_id), store, reingest_fn) diff --git a/tests_py/core/test_change_remediation.py b/tests_py/core/test_change_remediation.py index 8a35d9f2..c6014bc6 100644 --- a/tests_py/core/test_change_remediation.py +++ b/tests_py/core/test_change_remediation.py @@ -2,13 +2,37 @@ from __future__ import annotations +from dataclasses import dataclass + from mcp_server.core.change_remediation import ( Remediation, + build_impacted, classify_remediation, is_code_derived, ) +@dataclass +class _Match: + memory_id: int + matched_files: list[str] + + +def test_build_impacted_attaches_changed_refs() -> None: + matches = [_Match(1, ["src/a.py", "src/b.py"]), _Match(2, ["src/c.py"])] + memory_by_id = {1: {"id": 1, "content": "x"}, 2: {"id": 2, "content": "y"}} + impacted = build_impacted(matches, memory_by_id) + assert impacted[0]["changed_refs"] == ["src/a.py", "src/b.py"] + assert impacted[0]["content"] == "x" # memory fields preserved + assert impacted[1]["changed_refs"] == ["src/c.py"] + + +def test_build_impacted_drops_unknown_memory() -> None: + matches = [_Match(1, ["src/a.py"]), _Match(99, ["src/z.py"])] + impacted = build_impacted(matches, {1: {"id": 1}}) + assert [m["id"] for m in impacted] == [1] # id 99 has no memory → dropped + + def test_code_derived_by_agent_context() -> None: assert is_code_derived({"agent_context": "codebase"}) assert classify_remediation({"agent_context": "codebase"}) is Remediation.REINGEST diff --git a/tests_py/handlers/consolidation/test_change_remediation_pass.py b/tests_py/handlers/consolidation/test_change_remediation_pass.py index e7a0a22b..5ed709ad 100644 --- a/tests_py/handlers/consolidation/test_change_remediation_pass.py +++ b/tests_py/handlers/consolidation/test_change_remediation_pass.py @@ -7,11 +7,22 @@ from __future__ import annotations +from dataclasses import dataclass + from mcp_server.handlers.consolidation.change_remediation_pass import ( + remediate_from_impact, remediate_impacted, ) +@dataclass +class _Match: + """Stand-in for change_impact_matcher.ImpactMatch (duck-typed).""" + + memory_id: int + matched_files: list[str] + + class _FakeStore: def __init__(self) -> None: self.marked: list[tuple[int, bool]] = [] @@ -71,3 +82,21 @@ def test_empty_impacted_is_noop() -> None: assert counts == {"reingest_memories": 0, "flagged_stale": 0, "reingest_paths": 0} assert calls == [] assert store.marked == [] + + +def test_remediate_from_impact_glues_change_impact_output() -> None: + # ImpactMatches straight from change_impact: id 1 code-derived → reingest its + # changed file; id 2 hand-authored → flag; id 9 has no memory row → dropped. + matches = [_Match(1, ["src/a.py"]), _Match(2, ["src/a.py"]), _Match(9, ["x.py"])] + memory_by_id = { + 1: {"id": 1, "agent_context": "codebase"}, + 2: {"id": 2, "agent_context": "", "tags": ["lesson"]}, + } + store = _FakeStore() + calls, reingest = _recorder() + + counts = remediate_from_impact(matches, memory_by_id, store, reingest) + + assert counts == {"reingest_memories": 1, "flagged_stale": 1, "reingest_paths": 1} + assert calls == [["src/a.py"]] # only the code-derived memory's changed ref + assert store.marked == [(2, True)] # only the hand-authored memory flagged