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
1 change: 1 addition & 0 deletions .apm/instructions/architecture.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ semicolon-delimited, and specific to the file(s) that own the fact.
| Runtime descriptors | runtime/registry.py | `src/apm_cli/runtime/registry.py` |
| User-facing output / diagnostics | CommandLogger / console owner | `src/apm_cli/core/command_logger.py`; `src/apm_cli/utils/console.py` |
| Compiled-output writes (atomic) | CompiledOutputWriter | `src/apm_cli/compilation/output_writer.py` |
| Generated-content footer ownership wording | compilation/footer.py (build_generation_footer) | `src/apm_cli/compilation/footer.py` |
| Deployment provenance / state | deployment_ledger.py | `src/apm_cli/core/deployment_ledger.py` |
| Target-scoped deployed-file contraction | install/manifest_reconcile.py (reconcile_target_deployed_files) | `src/apm_cli/install/manifest_reconcile.py` |
| Install success / failure outcome | the canonical install-outcome path | `src/apm_cli/install/outcome.py` |
Expand Down
1 change: 1 addition & 0 deletions .github/instructions/architecture.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ semicolon-delimited, and specific to the file(s) that own the fact.
| Runtime descriptors | runtime/registry.py | `src/apm_cli/runtime/registry.py` |
| User-facing output / diagnostics | CommandLogger / console owner | `src/apm_cli/core/command_logger.py`; `src/apm_cli/utils/console.py` |
| Compiled-output writes (atomic) | CompiledOutputWriter | `src/apm_cli/compilation/output_writer.py` |
| Generated-content footer ownership wording | compilation/footer.py (build_generation_footer) | `src/apm_cli/compilation/footer.py` |
| Deployment provenance / state | deployment_ledger.py | `src/apm_cli/core/deployment_ledger.py` |
| Target-scoped deployed-file contraction | install/manifest_reconcile.py (reconcile_target_deployed_files) | `src/apm_cli/install/manifest_reconcile.py` |
| Install success / failure outcome | the canonical install-outcome path | `src/apm_cli/install/outcome.py` |
Expand Down
4 changes: 2 additions & 2 deletions apm.lock.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2783,7 +2783,7 @@ deployments:
owners:
- .
active_owner: .
content_hash: sha256:3445ddcf51a14f5a730cddb7f3cc5ce2bb11b5078951521a4903a098250f6f86
content_hash: sha256:7cefd135891c5022abfa5c509769a2debccb5c1a4d944153580a7a3d077beb37
- kind: project-relative
target: copilot
value: .github/instructions/changelog.instructions.md
Expand Down Expand Up @@ -3290,7 +3290,7 @@ local_deployed_file_hashes:
.github/agents/spec-tag-architect.agent.md: sha256:82907265c5e7cf1ac61ad96866fa7c5683b69c8f09b7a4c5f3cc241acc9568ca
.github/agents/supply-chain-security-expert.agent.md: sha256:8fb8cc426d6af17ba084a28b3f026c2b475b62e3ca63ed2f88b83bd823f877af
.github/agents/test-coverage-expert.agent.md: sha256:48c2172d1f18a394fa83ef9dc2be0b9b921a4e51e976498165250fed66369711
.github/instructions/architecture.instructions.md: sha256:3445ddcf51a14f5a730cddb7f3cc5ce2bb11b5078951521a4903a098250f6f86
.github/instructions/architecture.instructions.md: sha256:7cefd135891c5022abfa5c509769a2debccb5c1a4d944153580a7a3d077beb37
.github/instructions/changelog.instructions.md: sha256:1e51ec4c74e847967962bd279dc4c6e582c5d3578490b3c28d5f3acd3e05f73e
.github/instructions/cicd.instructions.md: sha256:33201cb88ea2f34b4950a9b52f87dc8dfb682796aaf53068ba7ae406c0c5e2c2
.github/instructions/cli.instructions.md: sha256:8e39e8d5047ce88575cb02f87c2bcede584dfef258bd86f7466c7badf136541a
Expand Down
3 changes: 2 additions & 1 deletion docs/src/content/docs/producer/compile.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,8 @@ you can omit `start_marker` and `end_marker` if you use those verbatim.
- `start_marker` and `end_marker` must be distinct non-empty strings.
- Content outside the markers is preserved verbatim across every compile
run for the root `AGENTS.md`; only the block between the markers is
replaced.
replaced. When source attribution emits a footer, it identifies this block
as a generated section rather than describing the whole file as generated.
- In distributed compile mode, subdirectory `AGENTS.md` files remain fully
APM-owned and are overwritten on each run.

Expand Down
83 changes: 83 additions & 0 deletions scripts/check_agents_footer_authority.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Enforce one canonical owner for generated-content footer wording."""

from __future__ import annotations

import ast
import sys
from pathlib import Path

_OWNER = Path("src/apm_cli/compilation/footer.py")
_CONSUMERS = (
Path("src/apm_cli/compilation/agents_compiler.py"),
Path("src/apm_cli/compilation/claude_formatter.py"),
Path("src/apm_cli/compilation/distributed_compiler.py"),
Path("src/apm_cli/compilation/template_builder.py"),
)
_OWNERSHIP_WORDING = "was generated by APM CLI. Do not edit manually."


def _string_constants(path: Path) -> list[str]:
"""Return string constants from a Python source file."""
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
return [
node.value
for node in ast.walk(tree)
if isinstance(node, ast.Constant) and isinstance(node.value, str)
]


def _calls_function(path: Path, function_name: str) -> bool:
"""Return whether executable code calls the named function."""
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
return any(
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == function_name
for node in ast.walk(tree)
)


def find_violations(root: Path) -> list[str]:
"""Find duplicate footer wording or consumers bypassing the owner."""
owner = root / _OWNER
owner_tree = ast.parse(owner.read_text(encoding="utf-8"), filename=str(owner))
owner_definitions = [
node
for node in owner_tree.body
if isinstance(node, ast.FunctionDef) and node.name == "build_generation_footer"
]
violations: list[str] = []
if len(owner_definitions) != 1:
violations.append(f"{_OWNER}: build_generation_footer must have exactly one definition")

for relative_path in _CONSUMERS:
consumer = root / relative_path
if not _calls_function(consumer, "build_generation_footer"):
violations.append(f"{relative_path}: generated footer must use build_generation_footer")

compilation_root = root / "src/apm_cli/compilation"
for source_path in compilation_root.rglob("*.py"):
if source_path == owner:
continue
if any(_OWNERSHIP_WORDING in value for value in _string_constants(source_path)):
relative_path = source_path.relative_to(root)
violations.append(f"{relative_path}: footer ownership wording duplicates {_OWNER}")

return violations


def main() -> int:
"""Run the generated-footer authority check."""
root = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path(__file__).parents[1]
violations = find_violations(root)
if violations:
print("[x] Generated footer wording must route through compilation/footer.py")
for violation in violations:
print(violation)
return 1
return 0


if __name__ == "__main__":
raise SystemExit(main())
6 changes: 6 additions & 0 deletions scripts/lint-architecture-boundaries.sh
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,12 @@ if [ "$agents_source_attribution_status" -ne 0 ]; then
echo "$agents_source_attribution_output"
violations=$((violations + 1))
fi
agents_footer_output=$(python3 scripts/check_agents_footer_authority.py "$ROOT" 2>&1)
agents_footer_status=$?
if [ "$agents_footer_status" -ne 0 ]; then
echo "$agents_footer_output"
violations=$((violations + 1))
fi
hook_file="src/apm_cli/integration/hook_integrator.py"
validation_line=$(grep -n 'if not validation\.valid:' "$hook_file" | tail -1 | cut -d: -f1)
continue_line=$(awk -v start="$validation_line" 'NR > start && /continue/ {print NR; exit}' "$hook_file")
Expand Down
18 changes: 8 additions & 10 deletions src/apm_cli/compilation/agents_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from ..version import get_version
from .claude_formatter import CLAUDE_HEADER, ClaudeFormatter
from .constants import BUILD_ID_PLACEHOLDER
from .footer import VALID_AGENTS_MD_MODES, build_generation_footer
from .inventory import CompileInventory
from .link_resolver import resolve_markdown_links, validate_link_targets
from .template_builder import (
Expand Down Expand Up @@ -178,11 +179,10 @@ def __post_init__(self):
# Initialize exclude list if None
if self.exclude is None:
self.exclude = []
_valid_modes = ("full", "managed_section")
if self.agents_md_mode not in _valid_modes:
if self.agents_md_mode not in VALID_AGENTS_MD_MODES:
raise ValueError(
f"Unknown agents_md.mode {self.agents_md_mode!r}. "
f"Supported values: {', '.join(repr(m) for m in _valid_modes)}."
f"Supported values: {', '.join(repr(mode) for mode in VALID_AGENTS_MD_MODES)}."
)

@classmethod
Expand Down Expand Up @@ -625,6 +625,7 @@ def _compile_distributed(
"dry_run": config.dry_run,
"skip_instructions": skip_instructions,
"with_constitution": config.with_constitution,
"agents_md_mode": config.agents_md_mode,
"placement_map": self._get_distributed_placement(
config,
primitives,
Expand Down Expand Up @@ -1333,7 +1334,7 @@ def generate_output(self, template_data: TemplateData, config: CompilationConfig
Returns:
str: Generated AGENTS.md content.
"""
content = generate_agents_md_template(template_data)
content = generate_agents_md_template(template_data, config.agents_md_mode)

# Resolve markdown links if enabled
if config.resolve_links:
Expand Down Expand Up @@ -1524,10 +1525,7 @@ def _generate_copilot_root_instructions_content(
sections.append("")

if config.source_attribution:
sections.append("---")
sections.append("*This file was generated by APM CLI. Do not edit manually.*")
sections.append("*To regenerate: `apm compile`*")
sections.append("")
sections.extend(build_generation_footer())

content = "\n".join(sections)
if config.resolve_links:
Expand Down Expand Up @@ -1634,10 +1632,10 @@ def _prepare_output_content_with_config(
)
except ManagedSectionError as exc:
raise ManagedSectionError(f"[{target}] {exc}") from exc
elif config.agents_md_mode != "full":
elif config.agents_md_mode not in VALID_AGENTS_MD_MODES:
raise ValueError(
f"Unknown agents_md.mode {config.agents_md_mode!r}. "
"Supported values: 'full', 'managed_section'."
f"Supported values: {', '.join(repr(mode) for mode in VALID_AGENTS_MD_MODES)}."
)

return content
Expand Down
6 changes: 2 additions & 4 deletions src/apm_cli/compilation/claude_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from ..version import get_version
from .constants import BUILD_ID_PLACEHOLDER
from .constitution import read_constitution
from .footer import build_generation_footer
from .template_builder import build_attributed_instructions

# CRITICAL: Shadow Click commands to prevent namespace collision
Expand Down Expand Up @@ -337,10 +338,7 @@ def _generate_claude_content(

# Footer is opt-in (cosmetic).
if source_attribution:
sections.append("---")
sections.append("*This file was generated by APM CLI. Do not edit manually.*")
sections.append("*To regenerate: `apm compile`*")
sections.append("")
sections.extend(build_generation_footer())

return "\n".join(sections)

Expand Down
13 changes: 9 additions & 4 deletions src/apm_cli/compilation/distributed_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from .constants import BUILD_ID_PLACEHOLDER
from .constitution import find_constitution
from .context_optimizer import ContextOptimizer
from .footer import build_generation_footer
from .inventory import CompileInventory
from .link_resolver import UnifiedLinkResolver
from .template_builder import (
Expand Down Expand Up @@ -226,6 +227,7 @@ def compile_distributed(
# Mirrors CompilationConfig.with_constitution: when False, the writer
# skips constitution injection, so the emptiness predicate must agree.
with_constitution = config.get("with_constitution", True)
agents_md_mode = config.get("agents_md_mode", "full")

# Phase 0: Context Link Resolution
# Register all context files and compile referenced ones
Expand Down Expand Up @@ -315,6 +317,7 @@ def compile_distributed(
primitives,
skip_instructions=skip_instructions,
source_attribution=source_attribution,
agents_md_mode=agents_md_mode,
)

# Phase 4: Handle orphaned file cleanup.
Expand Down Expand Up @@ -681,6 +684,7 @@ def _generate_agents_content(
*,
skip_instructions: bool = False,
source_attribution: bool = True,
agents_md_mode: str = "full",
) -> str:
"""Generate AGENTS.md content for a specific placement.

Expand All @@ -694,6 +698,8 @@ def _generate_agents_content(
(APM version comment, generated-by footer), mirroring the
CLAUDE.md path. Distinct from ``placement.source_attribution``,
which is the per-instruction source map.
agents_md_mode: Root AGENTS.md ownership mode. Subdirectory files
always use full-file ownership wording.

Returns:
str: Generated AGENTS.md content.
Expand Down Expand Up @@ -731,10 +737,9 @@ def _generate_agents_content(

# Footer is opt-in (cosmetic).
if source_attribution:
sections.append("---")
sections.append("*This file was generated by APM CLI. Do not edit manually.*")
sections.append("*To regenerate: `apm compile`*")
sections.append("")
is_root = placement.agents_path.parent == self.base_dir
footer_mode = agents_md_mode if is_root else "full"
sections.extend(build_generation_footer(footer_mode))

content = "\n".join(sections)

Expand Down
20 changes: 20 additions & 0 deletions src/apm_cli/compilation/footer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Canonical generated-content footer rendering."""

VALID_AGENTS_MD_MODES = ("full", "managed_section")


def build_generation_footer(agents_md_mode: str = "full") -> list[str]:
"""Build a footer whose ownership wording matches the output mode."""
if agents_md_mode not in VALID_AGENTS_MD_MODES:
raise ValueError(
f"Unknown agents_md.mode {agents_md_mode!r}. "
f"Supported values: {', '.join(repr(mode) for mode in VALID_AGENTS_MD_MODES)}."
)

subject = "section" if agents_md_mode == "managed_section" else "file"
return [
"---",
f"*This {subject} was generated by APM CLI. Do not edit manually.*",
"*To regenerate: `apm compile`*",
"",
]
10 changes: 4 additions & 6 deletions src/apm_cli/compilation/template_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from ..primitives.models import Chatmode, Instruction
from ..utils.paths import portable_relpath
from .footer import build_generation_footer

GLOBAL_INSTRUCTIONS_HEADING = "## Global Instructions"

Expand Down Expand Up @@ -197,11 +198,12 @@ def find_chatmode_by_name(chatmodes: list[Chatmode], chatmode_name: str) -> Chat
return None


def generate_agents_md_template(template_data: TemplateData) -> str:
def generate_agents_md_template(template_data: TemplateData, agents_md_mode: str = "full") -> str:
"""Generate the complete AGENTS.md file content.

Args:
template_data (TemplateData): Data for template generation.
agents_md_mode: Root AGENTS.md ownership mode.

Returns:
str: Complete AGENTS.md file content.
Expand All @@ -226,10 +228,6 @@ def generate_agents_md_template(template_data: TemplateData) -> str:
if template_data.instructions_content:
sections.append(template_data.instructions_content)

# Footer
sections.append("---")
sections.append("*This file was generated by APM CLI. Do not edit manually.*")
sections.append("*To regenerate: `apm compile`*")
sections.append("")
sections.extend(build_generation_footer(agents_md_mode))

return "\n".join(sections)
Loading