Skip to content
Open
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
27 changes: 26 additions & 1 deletion src/apm_cli/compilation/claude_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -105,6 +107,15 @@ 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.
self.link_resolver.register_contexts(primitives)

# Generate Claude placements from the placement map
placements = self._generate_placements(
placement_map, primitives, source_attribution=source_attribution
Expand Down Expand Up @@ -340,7 +351,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
Expand Down
116 changes: 115 additions & 1 deletion tests/unit/compilation/test_claude_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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()