Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions mcp_server/core/change_remediation.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,43 @@
from __future__ import annotations

from enum import Enum
from typing import Protocol


class Remediation(str, Enum):
REINGEST = "reingest"
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.

Expand Down
23 changes: 22 additions & 1 deletion mcp_server/handlers/consolidation/change_remediation_pass.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)
24 changes: 24 additions & 0 deletions tests_py/core/test_change_remediation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions tests_py/handlers/consolidation/test_change_remediation_pass.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = []
Expand Down Expand Up @@ -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