Skip to content

Commit 3c687e5

Browse files
committed
fix(compile): address Copilot review feedback on #1742
- Decide empty-shell suppression before generating content, skipping content generation + link resolution for placements that will be suppressed (perf: avoids wasted work). - Cache constitution-existence per directory across placements in compile_distributed() to avoid repeated disk reads; _is_placement_empty_shell consults the cache. - Promote the generated-file marker to public AGENTS_MD_GENERATED_MARKER (was _AGENTS_MD_GENERATED_MARKER) since it gates deletion and is a contract surface depended on by tests; mirrors public CLAUDE_HEADER. - Fix _cleanup_orphaned_files docstring to match debug-only/no-warning behavior for hand-authored skips. - Make the INFO-message test hermetic by writing the source instruction file on disk.
1 parent 3fb4088 commit 3c687e5

5 files changed

Lines changed: 1416 additions & 1375 deletions

File tree

src/apm_cli/compilation/distributed_compiler.py

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@
2929
# Marker present in every APM-generated distributed AGENTS.md.
3030
# Used to gate --clean deletion: files without this marker are
3131
# hand-authored and must never be removed automatically.
32-
_AGENTS_MD_GENERATED_MARKER = "<!-- Generated by APM CLI from distributed .apm/ primitives -->"
32+
#
33+
# Public (no leading underscore) because it is a stable contract surface:
34+
# it gates file deletion and is depended on by tests. Mirrors the public
35+
# CLAUDE_HEADER marker on the Claude path.
36+
AGENTS_MD_GENERATED_MARKER = "<!-- Generated by APM CLI from distributed .apm/ primitives -->"
3337

3438
# CRITICAL: Shadow Click commands to prevent namespace collision
3539
set = builtins.set
@@ -221,22 +225,30 @@ def compile_distributed(
221225
# Multi-target builds where skip_instructions=False are unaffected.
222226
content_map: builtins.dict[Path, str] = {}
223227
suppressed_paths: builtins.list[Path] = []
228+
# Cache constitution-existence per directory for the duration of this
229+
# compile so repeated placements under the same tree do not re-read the
230+
# same constitution file from disk (O(placements) reads -> O(dirs)).
231+
constitution_cache: builtins.dict[Path, bool] = {}
224232

225233
for p in placements:
226-
content = self._generate_agents_content(
227-
p, primitives, skip_instructions=skip_instructions
228-
)
234+
# Decide suppression BEFORE generating content: a suppressed
235+
# placement is never written, so generating + link-resolving its
236+
# content would be wasted work.
229237
if skip_instructions and self._is_placement_empty_shell(
230-
p, with_constitution=with_constitution
238+
p,
239+
with_constitution=with_constitution,
240+
constitution_cache=constitution_cache,
231241
):
232242
suppressed_paths.append(p.agents_path)
233243
_logger.debug(
234244
"AGENTS.md suppressed (would-be-empty shell, instructions already"
235245
" in .github/instructions/): %s",
236246
p.agents_path,
237247
)
238-
else:
239-
content_map[p.agents_path] = content
248+
continue
249+
content_map[p.agents_path] = self._generate_agents_content(
250+
p, primitives, skip_instructions=skip_instructions
251+
)
240252

241253
# Phase 4: Handle orphaned file cleanup.
242254
# generated_paths = ALL placement paths (written + suppressed this run).
@@ -621,7 +633,7 @@ def _generate_agents_content(
621633

622634
# Header with source attribution
623635
sections.append("# AGENTS.md")
624-
sections.append(_AGENTS_MD_GENERATED_MARKER)
636+
sections.append(AGENTS_MD_GENERATED_MARKER)
625637
sections.append(BUILD_ID_PLACEHOLDER)
626638
sections.append(f"<!-- APM Version: {get_version()} -->")
627639

@@ -665,7 +677,11 @@ def _generate_agents_content(
665677
return content
666678

667679
def _is_placement_empty_shell(
668-
self, placement: PlacementResult, *, with_constitution: bool = True
680+
self,
681+
placement: PlacementResult,
682+
*,
683+
with_constitution: bool = True,
684+
constitution_cache: builtins.dict[Path, bool] | None = None,
669685
) -> bool:
670686
"""Return True when a placement would produce a content-free AGENTS.md shell.
671687
@@ -683,25 +699,39 @@ def _is_placement_empty_shell(
683699
a constitution file exists, so the predicate must agree and report "empty".
684700
685701
Target logic:
686-
empty = not (with_constitution and read_constitution(placement.agents_path.parent) is not None)
702+
empty = not (with_constitution and constitution_exists(placement.agents_path.parent))
687703
688704
Args:
689705
placement: The placement to evaluate.
690706
with_constitution: Whether the caller will inject a constitution at
691707
write time (mirrors ``CompilationConfig.with_constitution``).
692708
Defaults to True for back-compat.
709+
constitution_cache: Optional ``dict[Path, bool]`` keyed by placement
710+
directory, used to memoize constitution-existence checks across
711+
placements within a single ``compile_distributed()`` call so the
712+
same directory is not read from disk repeatedly. When omitted,
713+
the check reads from disk directly (back-compat for direct callers).
693714
694715
Returns:
695716
bool: True if writing this placement would produce a content-free file.
696717
"""
697-
# With constitution injection enabled, check whether a constitution file
698-
# actually exists in the placement directory. ConstitutionInjector uses
699-
# agents_path.parent as its base_dir, so we mirror that here.
700-
# If with_constitution is False the writer will skip injection regardless
701-
# of what is on disk, so we treat it as "no constitution".
702-
return not (
703-
with_constitution and read_constitution(placement.agents_path.parent) is not None
704-
)
718+
# When with_constitution is False the writer skips injection regardless of
719+
# what is on disk, so short-circuit without touching the filesystem.
720+
if not with_constitution:
721+
return True
722+
723+
# ConstitutionInjector uses agents_path.parent as its base_dir, so mirror
724+
# that here. Memoize per directory to avoid repeated disk I/O in large
725+
# repos with many placements sharing a tree.
726+
directory = placement.agents_path.parent
727+
if constitution_cache is not None and directory in constitution_cache:
728+
has_constitution = constitution_cache[directory]
729+
else:
730+
has_constitution = read_constitution(directory) is not None
731+
if constitution_cache is not None:
732+
constitution_cache[directory] = has_constitution
733+
734+
return not has_constitution
705735

706736
def _validate_coverage(
707737
self,
@@ -791,7 +821,7 @@ def _find_orphaned_agents_files(
791821
file_content = agents_file.read_text(encoding="utf-8")
792822
except OSError:
793823
continue
794-
if _AGENTS_MD_GENERATED_MARKER not in file_content:
824+
if AGENTS_MD_GENERATED_MARKER not in file_content:
795825
continue # Hand-authored -- skip silently
796826

797827
orphaned_files.append(agents_file)
@@ -844,10 +874,12 @@ def _cleanup_orphaned_files(
844874
) -> builtins.list[str]:
845875
"""Actually remove orphaned AGENTS.md files.
846876
847-
Only APM-generated files (those bearing ``_AGENTS_MD_GENERATED_MARKER``)
848-
are removed. Hand-authored files are warned about but left untouched.
849-
(The marker gate is also applied in ``_find_orphaned_agents_files``; this
850-
check is defense-in-depth for direct callers.)
877+
Only APM-generated files (those bearing ``AGENTS_MD_GENERATED_MARKER``)
878+
are removed. Hand-authored files are skipped silently and recorded at
879+
DEBUG level only -- no user-facing message is appended to the returned
880+
list. (The marker gate is also applied in ``_find_orphaned_agents_files``,
881+
so reaching the skip branch here is a defense-in-depth path for direct
882+
callers and is not expected in normal operation.)
851883
852884
Args:
853885
orphaned_files: Orphaned files to remove (should be pre-screened by
@@ -882,7 +914,7 @@ def _cleanup_orphaned_files(
882914
except OSError:
883915
cleanup_messages.append(f" x Failed to read {rel_path} -- skipping")
884916
continue
885-
if _AGENTS_MD_GENERATED_MARKER not in existing_content:
917+
if AGENTS_MD_GENERATED_MARKER not in existing_content:
886918
# Defense-in-depth: _find_orphaned_agents_files already
887919
# marker-gates, so this branch is unreachable in normal
888920
# operation. Emit at debug level only to avoid a stray

tests/unit/compilation/test_distributed_compiler_hermetic.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from unittest.mock import MagicMock, patch
2525

2626
from apm_cli.compilation.distributed_compiler import (
27-
_AGENTS_MD_GENERATED_MARKER,
27+
AGENTS_MD_GENERATED_MARKER,
2828
CompilationResult,
2929
DirectoryMap,
3030
DistributedAgentsCompiler,
@@ -33,7 +33,7 @@
3333
from apm_cli.primitives.models import Instruction, PrimitiveCollection
3434

3535
# Content used by tests that need an APM-generated AGENTS.md
36-
_GENERATED_CONTENT = f"{_AGENTS_MD_GENERATED_MARKER}\n# Generated content\n"
36+
_GENERATED_CONTENT = f"{AGENTS_MD_GENERATED_MARKER}\n# Generated content\n"
3737
# Content used by tests that need a hand-authored AGENTS.md (no APM marker)
3838
_HAND_AUTHORED_CONTENT = "# My custom AGENTS.md\nThis file was written by hand.\n"
3939

tests/unit/compilation/test_distributed_compiler_phase3.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from unittest.mock import MagicMock, patch
2525

2626
from apm_cli.compilation.distributed_compiler import (
27-
_AGENTS_MD_GENERATED_MARKER,
27+
AGENTS_MD_GENERATED_MARKER,
2828
CompilationResult,
2929
DirectoryMap,
3030
DistributedAgentsCompiler,
@@ -33,7 +33,7 @@
3333
from apm_cli.primitives.models import Instruction, PrimitiveCollection
3434

3535
# APM-generated AGENTS.md content (marker present) for orphan tests
36-
_GENERATED_CONTENT = f"{_AGENTS_MD_GENERATED_MARKER}\n# Generated content\n"
36+
_GENERATED_CONTENT = f"{AGENTS_MD_GENERATED_MARKER}\n# Generated content\n"
3737

3838
# ---------------------------------------------------------------------------
3939
# Helpers

tests/unit/compilation/test_empty_agents_shells_1730.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
from apm_cli.compilation.agents_compiler import AgentsCompiler, CompilationConfig
2525
from apm_cli.compilation.distributed_compiler import (
26-
_AGENTS_MD_GENERATED_MARKER,
26+
AGENTS_MD_GENERATED_MARKER,
2727
DistributedAgentsCompiler,
2828
PlacementResult,
2929
)
@@ -64,7 +64,7 @@ def _apm_generated_marker_content(extra: str = "") -> str:
6464
"""Produce minimal APM-generated AGENTS.md content (marker present)."""
6565
return (
6666
f"# AGENTS.md\n"
67-
f"{_AGENTS_MD_GENERATED_MARKER}\n"
67+
f"{AGENTS_MD_GENERATED_MARKER}\n"
6868
f"<!-- Build ID: abc123def456 -->\n"
6969
f"<!-- APM Version: 0.0.0 -->\n"
7070
f"\n"
@@ -285,11 +285,21 @@ def test_info_message_logged_when_suppression_fires(self, tmp_path: Path):
285285
encoding="utf-8",
286286
)
287287

288+
# Write the source instruction on disk so the fixture mirrors real
289+
# project state and stays hermetic if filesystem-based validation tightens.
290+
apm_instr_dir = tmp_path / ".apm" / "instructions"
291+
apm_instr_dir.mkdir(parents=True)
292+
apm_instr_file = apm_instr_dir / "global.instructions.md"
293+
apm_instr_file.write_text(
294+
"---\ndescription: Global\n---\nGlobal guidance.\n",
295+
encoding="utf-8",
296+
)
297+
288298
instruction = _make_instruction(
289299
name="global",
290300
content="Global guidance.",
291301
apply_to=None,
292-
file_path=tmp_path / ".apm" / "instructions" / "global.instructions.md",
302+
file_path=apm_instr_file,
293303
)
294304
primitives = _make_primitives(instruction)
295305

0 commit comments

Comments
 (0)