From d3514f30c1e67f601134a660c841961ba0fd589a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Vodr=C3=A1=C5=BEka?= Date: Mon, 7 Sep 2026 18:37:36 +0200 Subject: [PATCH 1/2] fix(compile): resolve context links when folding instructions into CLAUDE.md claude_formatter.py never called into the link resolver, so any relative link embedded in an instruction body (in particular a link to a .apm/context/*.context.md fragment) was emitted verbatim from the source file when folded into CLAUDE.md -- correct only when CLAUDE.md happens to live in the same directory as the instruction that referenced it, and broken for any dependency-sourced or non-root placement. agents_compiler.py / distributed_compiler.py already call UnifiedLinkResolver.resolve_links_for_compilation() for the equivalent AGENTS.md fold; this mirrors that call in ClaudeFormatter: - construct a UnifiedLinkResolver once in __init__ - register_contexts() at the start of format_distributed() - resolve_links_for_compilation() on the assembled content before returning it from _generate_claude_content() Adds regression tests covering a local context fragment and a dependency-sourced one (apm_modules/_local//... anchor), both of which reproduce the stale/broken link before this change and resolve correctly after it. All 35 existing + new unit tests in test_claude_formatter.py pass; full unit/compilation suite (1402 tests) unaffected. --- src/apm_cli/compilation/claude_formatter.py | 23 +++- .../unit/compilation/test_claude_formatter.py | 116 +++++++++++++++++- 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/src/apm_cli/compilation/claude_formatter.py b/src/apm_cli/compilation/claude_formatter.py index 8f8fc59189..8d3aa5c295 100644 --- a/src/apm_cli/compilation/claude_formatter.py +++ b/src/apm_cli/compilation/claude_formatter.py @@ -15,6 +15,7 @@ from .constants import BUILD_ID_PLACEHOLDER from .constitution import read_constitution from .footer import build_generation_footer +from .link_resolver import UnifiedLinkResolver from .template_builder import build_attributed_instructions # CRITICAL: Shadow Click commands to prevent namespace collision @@ -80,6 +81,7 @@ def __init__(self, base_dir: str = ".", source_dir: str | None = None): self.warnings: builtins.list[str] = [] self.errors: builtins.list[str] = [] + self.link_resolver = UnifiedLinkResolver(self.source_dir) def format_distributed( self, @@ -105,6 +107,11 @@ def format_distributed( source_attribution = config.get("source_attribution", True) skip_instructions = config.get("skip_instructions", False) + # Register context/memory fragments so embedded links to them + # (e.g. ".context.md") resolve to their actual on-disk location, + # mirroring the AGENTS.md distributed compiler. + self.link_resolver.register_contexts(primitives) + # Generate Claude placements from the placement map placements = self._generate_placements( placement_map, primitives, source_attribution=source_attribution @@ -340,7 +347,21 @@ def _generate_claude_content( if source_attribution: sections.extend(build_generation_footer()) - return "\n".join(sections) + content = "\n".join(sections) + + # Resolve context/memory links (".context.md", ".memory.md") to their + # actual on-disk location, mirroring the AGENTS.md distributed + # compiler (distributed_compiler.py). Without this, embedded + # relative links are emitted verbatim -- correct only when CLAUDE.md + # happens to live in the same directory as their source file, and + # broken for any dependency-sourced or non-root placement. + content = self.link_resolver.resolve_links_for_compilation( + content=content, + source_file=placement.claude_path.parent, + compiled_output=placement.claude_path, + ) + + return content def _compile_stats( self, placements: builtins.list[ClaudePlacement], primitives: PrimitiveCollection diff --git a/tests/unit/compilation/test_claude_formatter.py b/tests/unit/compilation/test_claude_formatter.py index 9a77827842..b71a70de5f 100644 --- a/tests/unit/compilation/test_claude_formatter.py +++ b/tests/unit/compilation/test_claude_formatter.py @@ -16,7 +16,7 @@ format_claude_md, ) from apm_cli.compilation.constants import BUILD_ID_PLACEHOLDER -from apm_cli.primitives.models import Chatmode, Instruction, PrimitiveCollection +from apm_cli.primitives.models import Chatmode, Context, Instruction, PrimitiveCollection from apm_cli.version import get_version @@ -705,3 +705,117 @@ def test_is_root_flag_used_for_skip_filtering(self, temp_project, sample_primiti content = next(iter(result.content_map.values())) assert "## Dependencies" in content.splitlines() assert "Project Standards" not in content + + +class TestContextLinkResolution: + """Regression tests: CLAUDE.md must resolve ``.context.md``/``.memory.md`` + links the same way AGENTS.md already does (distributed_compiler.py). + + Before this fix, ``_generate_claude_content`` returned + ``"\\n".join(sections)`` directly with no call into + ``UnifiedLinkResolver`` at all, so any relative link embedded in an + instruction body was emitted byte-for-byte from the source file -- + correct only when CLAUDE.md happens to land in the same directory as the + instruction that referenced it, and silently broken otherwise (e.g. a + global/no-``applyTo`` instruction whose body links to a sibling + ``.apm/context/*.context.md`` fragment). + """ + + @pytest.fixture + def temp_project(self): + temp_dir = tempfile.mkdtemp() + resolved = Path(temp_dir).resolve() + yield resolved + shutil.rmtree(resolved, ignore_errors=True) + + def test_context_link_rewritten_relative_to_claude_md(self, temp_project): + """A link to a `.context.md` fragment must resolve from CLAUDE.md's + own directory, not from the source instruction's directory.""" + primitives = PrimitiveCollection() + + context_file = temp_project / ".apm" / "context" / "conventions.context.md" + context_file.parent.mkdir(parents=True) + context_file.write_text("Real content lives here.") + primitives.add_primitive( + Context( + name="conventions", + file_path=context_file, + content="Real content lives here.", + source="local", + ) + ) + + instruction_file = temp_project / ".apm" / "instructions" / "signpost.instructions.md" + instruction_file.parent.mkdir(parents=True) + instruction = Instruction( + name="signpost", + file_path=instruction_file, + description="Signpost", + apply_to="", + content="See [conventions](../context/conventions.context.md) for details.", + author="test", + source="local", + ) + primitives.add_primitive(instruction) + + formatter = ClaudeFormatter(str(temp_project)) + placement_map = {temp_project: list(primitives.instructions)} + result = formatter.format_distributed(primitives, placement_map) + + assert result.success + content = result.content_map[temp_project / "CLAUDE.md"] + + # The link must now be anchored to CLAUDE.md's own directory + # (temp_project), matching what AGENTS.md already produces for the + # same source instruction -- not the original "../context/..." + # written relative to the instruction file's own directory. + assert "(.apm/context/conventions.context.md)" in content + assert "../context/conventions.context.md" not in content + + # And the rewritten link must actually resolve on disk. + rewritten_target = temp_project / ".apm" / "context" / "conventions.context.md" + assert rewritten_target.exists() + + def test_context_link_rewritten_for_dependency_sourced_instruction(self, temp_project): + """Same as above, but the instruction+context pair are sourced from a + dependency materialized under apm_modules/, matching how a real + component-repo dependency is discovered.""" + primitives = PrimitiveCollection() + + dep_root = temp_project / "apm_modules" / "_local" / "some-repo" + context_file = dep_root / ".apm" / "context" / "conventions.context.md" + context_file.parent.mkdir(parents=True) + context_file.write_text("Real content lives here.") + primitives.add_primitive( + Context( + name="conventions", + file_path=context_file, + content="Real content lives here.", + source="dependency:some-repo", + ) + ) + + instruction_file = dep_root / ".apm" / "instructions" / "signpost.instructions.md" + instruction_file.parent.mkdir(parents=True) + instruction = Instruction( + name="signpost", + file_path=instruction_file, + description="Signpost", + apply_to="", + content="See [conventions](../context/conventions.context.md) for details.", + author="test", + source="dependency:some-repo", + ) + primitives.add_primitive(instruction) + + formatter = ClaudeFormatter(str(temp_project)) + placement_map = {temp_project: [instruction]} + result = formatter.format_distributed(primitives, placement_map) + + assert result.success + content = result.content_map[temp_project / "CLAUDE.md"] + + expected_relative = "apm_modules/_local/some-repo/.apm/context/conventions.context.md" + assert f"({expected_relative})" in content + assert "../context/conventions.context.md" not in content + assert (temp_project / expected_relative).exists() From d26c2e1ab28621dd8169a38dfc8ba3c0c38083ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Vodr=C3=A1=C5=BEka?= Date: Wed, 9 Sep 2026 13:30:14 +0200 Subject: [PATCH 2/2] Reset link resolver context before compilation Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/apm_cli/compilation/claude_formatter.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/apm_cli/compilation/claude_formatter.py b/src/apm_cli/compilation/claude_formatter.py index 8d3aa5c295..ecef79b8bb 100644 --- a/src/apm_cli/compilation/claude_formatter.py +++ b/src/apm_cli/compilation/claude_formatter.py @@ -107,6 +107,10 @@ def format_distributed( source_attribution = config.get("source_attribution", True) skip_instructions = config.get("skip_instructions", False) + # Reset any previous state so contexts from earlier compile passes + # can't leak into later calls and rewrite links incorrectly. + self.link_resolver.context_registry.clear() + # Register context/memory fragments so embedded links to them # (e.g. ".context.md") resolve to their actual on-disk location, # mirroring the AGENTS.md distributed compiler.