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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ jobs:
- name: Run tests
run: uv run pytest tests/unit tests/test_console.py -n auto --dist worksteal

- name: Lint - no raw str(relative_to) patterns
run: |
# Fail if any code uses str(x.relative_to(y)) instead of portable_relpath()
if grep -rn --include="*.py" -P 'str\([^)]*\.relative_to\(' src/apm_cli/ | grep -v portable_relpath | grep -v '\.pyc'; then
echo "::error::Found raw str(path.relative_to()) calls. Use portable_relpath() from apm_cli.utils.paths instead."
exit 1
fi

- name: Install UPX
run: |
sudo apt-get update
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Systematic Windows path compatibility hardening: added `portable_relpath()` utility, migrated ~23 `relative_to()` call sites to use `.resolve()` on both sides with `.as_posix()` output, and added CI lint guard to block raw `str(path.relative_to())` regressions (#419, #422)
- Cross-platform YAML encoding: all `apm.yml` read/write operations now use explicit UTF-8 encoding via centralized `yaml_io` helpers, preventing silent mojibake on Windows cp1252 terminals; non-ASCII characters (e.g. accented author names) are preserved as real UTF-8 instead of `\xNN` escape sequences (#387, #433) -- based on #388 by @alopezsanchez
- SSL certificate verification failures in PyInstaller binary on systems without python.org Python installed; bundled `certifi` CA bundle is now auto-configured via runtime hook (#429)
- Virtual package types (files, collections, subdirectories) now respect `ARTIFACTORY_ONLY=1`, matching the primary zip-archive proxy-only behavior (#418)
Expand Down
5 changes: 3 additions & 2 deletions src/apm_cli/commands/uninstall/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from ...constants import APM_MODULES_DIR, APM_YML_FILENAME
from ...core.command_logger import CommandLogger
from ...utils.path_security import PathTraversalError, safe_rmtree
from ...utils.paths import portable_relpath

from ...deps.lockfile import LockFile
from ...models.apm_package import APMPackage, DependencyReference
Expand Down Expand Up @@ -132,7 +133,7 @@ def _remove_packages_from_disk(packages_to_remove, apm_modules_dir, logger):
try:
safe_rmtree(package_path, apm_modules_dir)
logger.progress(f"Removed {package} from apm_modules/")
logger.verbose_detail(f" Path: {package_path.relative_to(apm_modules_dir)}")
logger.verbose_detail(f" Path: {portable_relpath(package_path, apm_modules_dir)}")
removed += 1
deleted_pkg_paths.append(package_path)
except Exception as e:
Expand Down Expand Up @@ -212,7 +213,7 @@ def _cleanup_transitive_orphans(lockfile, packages_to_remove, apm_modules_dir, a
try:
safe_rmtree(orphan_path, apm_modules_dir)
logger.progress(f"Removed transitive dependency {orphan_key} from apm_modules/")
logger.verbose_detail(f" Path: {orphan_path.relative_to(apm_modules_dir)}")
logger.verbose_detail(f" Path: {portable_relpath(orphan_path, apm_modules_dir)}")
removed += 1
deleted_orphan_paths.append(orphan_path)
except Exception as e:
Expand Down
49 changes: 10 additions & 39 deletions src/apm_cli/compilation/agents_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
find_chatmode_by_name
)
from .link_resolver import resolve_markdown_links, validate_link_targets
from ..utils.paths import portable_relpath


@dataclass
Expand Down Expand Up @@ -438,10 +439,7 @@ def _compile_claude_md(self, config: CompilationConfig, primitives: PrimitiveCol
f"CLAUDE.md Preview: Would generate {len(claude_result.placements)} files"
]
for claude_path in claude_result.content_map.keys():
try:
rel_path = claude_path.relative_to(self.base_dir)
except ValueError:
rel_path = claude_path
rel_path = portable_relpath(claude_path, self.base_dir)
preview_lines.append(f" {rel_path}")

return CompilationResult(
Expand Down Expand Up @@ -534,10 +532,7 @@ def _compile_claude_md(self, config: CompilationConfig, primitives: PrimitiveCol
f"Generated {files_written} CLAUDE.md files:"
]
for placement in claude_result.placements:
try:
rel_path = placement.claude_path.relative_to(self.base_dir)
except ValueError:
rel_path = placement.claude_path
rel_path = portable_relpath(placement.claude_path, self.base_dir)
summary_lines.append(f"- {rel_path} ({len(placement.instructions)} instructions)")

return CompilationResult(
Expand Down Expand Up @@ -628,12 +623,7 @@ def validate_primitives(self, primitives: PrimitiveCollection) -> List[str]:
for primitive in primitives.all_primitives():
primitive_errors = primitive.validate()
if primitive_errors:
try:
# Try to get relative path, but fall back to absolute if it fails
file_path = str(primitive.file_path.relative_to(self.base_dir))
except ValueError:
# File is outside base_dir, use absolute path
file_path = str(primitive.file_path)
file_path = portable_relpath(primitive.file_path, self.base_dir)

for error in primitive_errors:
# Treat validation errors as warnings instead of hard errors
Expand All @@ -645,10 +635,7 @@ def validate_primitives(self, primitives: PrimitiveCollection) -> List[str]:
primitive_dir = primitive.file_path.parent
link_errors = validate_link_targets(primitive.content, primitive_dir)
if link_errors:
try:
file_path = str(primitive.file_path.relative_to(self.base_dir))
except ValueError:
file_path = str(primitive.file_path)
file_path = portable_relpath(primitive.file_path, self.base_dir)

for link_error in link_errors:
self.warnings.append(f"{file_path}: {link_error}")
Expand Down Expand Up @@ -784,11 +771,7 @@ def _display_placement_preview(self, distributed_result) -> None:
self._log("progress", "")

for placement in distributed_result.placements:
try:
rel_path = placement.agents_path.relative_to(self.base_dir.resolve())
except ValueError:
# Fallback for path resolution issues
rel_path = placement.agents_path
rel_path = portable_relpath(placement.agents_path, self.base_dir)
self._log("verbose_detail", f"{rel_path}")
self._log("verbose_detail", f" Instructions: {len(placement.instructions)}")
self._log("verbose_detail", f" Patterns: {', '.join(sorted(placement.coverage_patterns))}")
Expand All @@ -808,18 +791,12 @@ def _display_trace_info(self, distributed_result, primitives: PrimitiveCollectio
self._log("progress", "")

for placement in distributed_result.placements:
try:
rel_path = placement.agents_path.relative_to(self.base_dir.resolve())
except ValueError:
rel_path = placement.agents_path
rel_path = portable_relpath(placement.agents_path, self.base_dir)
self._log("verbose_detail", f"{rel_path}")

for instruction in placement.instructions:
source = getattr(instruction, 'source', 'local')
try:
inst_path = instruction.file_path.relative_to(self.base_dir.resolve())
except ValueError:
inst_path = instruction.file_path
inst_path = portable_relpath(instruction.file_path, self.base_dir)

self._log("verbose_detail", f" * {instruction.apply_to or 'no pattern'} <- {source} {inst_path}")
self._log("verbose_detail", "")
Expand All @@ -836,10 +813,7 @@ def _generate_placement_summary(self, distributed_result) -> str:
lines = ["Distributed AGENTS.md Placement Summary:", ""]

for placement in distributed_result.placements:
try:
rel_path = placement.agents_path.resolve().relative_to(self.base_dir.resolve()).as_posix()
except ValueError:
rel_path = str(placement.agents_path)
rel_path = portable_relpath(placement.agents_path, self.base_dir)
lines.append(f"{rel_path}")
lines.append(f" Instructions: {len(placement.instructions)}")
lines.append(f" Patterns: {', '.join(sorted(placement.coverage_patterns))}")
Expand All @@ -866,10 +840,7 @@ def _generate_distributed_summary(self, distributed_result, config: CompilationC
]

for placement in distributed_result.placements:
try:
rel_path = placement.agents_path.resolve().relative_to(self.base_dir.resolve()).as_posix()
except ValueError:
rel_path = str(placement.agents_path)
rel_path = portable_relpath(placement.agents_path, self.base_dir)
lines.append(f"- {rel_path} ({len(placement.instructions)} instructions)")

lines.extend([
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 @@ -18,6 +18,7 @@

from ..primitives.models import Instruction, PrimitiveCollection, Chatmode
from ..version import get_version
from ..utils.paths import portable_relpath
from .constants import BUILD_ID_PLACEHOLDER
from .constitution import read_constitution

Expand Down Expand Up @@ -298,10 +299,7 @@ def _generate_claude_content(
source = placement.source_attribution.get(
str(instruction.file_path), 'local'
)
try:
rel_path = instruction.file_path.relative_to(self.base_dir)
except ValueError:
rel_path = instruction.file_path
rel_path = portable_relpath(instruction.file_path, self.base_dir)

sections.append(f"<!-- Source: {source} {rel_path} -->")

Expand Down
27 changes: 13 additions & 14 deletions src/apm_cli/compilation/context_optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
CompilationResults, ProjectAnalysis, OptimizationDecision, OptimizationStats,
PlacementStrategy, PlacementSummary
)
from ..utils.paths import portable_relpath

# CRITICAL: Shadow Click commands to prevent namespace collision
# When this module is imported during 'apm compile', Click's active context
Expand Down Expand Up @@ -349,7 +350,7 @@ def get_compilation_results(
instruction_patterns_detected=len(self._optimization_decisions),
max_depth=max((a.depth for a in self._directory_cache.values()), default=0),
constitution_detected=constitution_detected,
constitution_path=str(constitution_path.relative_to(self.base_dir)) if constitution_detected else None
constitution_path=portable_relpath(constitution_path, self.base_dir) if constitution_detected else None
)

# Create placement summaries
Expand Down Expand Up @@ -421,7 +422,7 @@ def _analyze_project_structure(self) -> None:

# Calculate depth for analysis
try:
relative_path = current_path.relative_to(self.base_dir)
relative_path = current_path.resolve().relative_to(self.base_dir.resolve())
depth = len(relative_path.parts)
except ValueError:
depth = 0
Expand Down Expand Up @@ -512,7 +513,7 @@ def _should_exclude_path(self, path: Path) -> bool:
except (OSError, FileNotFoundError):
resolved = path.absolute()
try:
rel_path = resolved.relative_to(self.base_dir)
rel_path = resolved.relative_to(self.base_dir.resolve())
except ValueError:
# Path is not relative to base_dir, don't exclude
return False
Expand Down Expand Up @@ -655,8 +656,8 @@ def _solve_placement_optimization(
if intended_dir:
# Place in the intended directory (e.g., docs/ for docs/**/*.md)
placement = intended_dir
reasoning = f"No matching files found, placed in intended directory '{intended_dir.relative_to(self.base_dir)}'"
self._warnings.append(f"Pattern '{pattern}' matches no files - placing in intended directory '{intended_dir.relative_to(self.base_dir)}'")
reasoning = f"No matching files found, placed in intended directory '{portable_relpath(intended_dir, self.base_dir)}'"
self._warnings.append(f"Pattern '{pattern}' matches no files - placing in intended directory '{portable_relpath(intended_dir, self.base_dir)}'")
else:
# Fallback to root for global patterns
placement = self.base_dir
Expand Down Expand Up @@ -796,7 +797,7 @@ def _file_matches_pattern(self, file_path: Path, pattern: str) -> bool:
try:
# Resolve both paths to handle symlinks and path inconsistencies
resolved_file = file_path.resolve()
rel_path = resolved_file.relative_to(self.base_dir)
rel_path = resolved_file.relative_to(self.base_dir.resolve())

# Use cached glob results instead of repeated glob calls
matches = self._cached_glob(expanded_pattern)
Expand All @@ -810,10 +811,8 @@ def _file_matches_pattern(self, file_path: Path, pattern: str) -> bool:
else:
# For non-recursive patterns, use fnmatch as before
try:
# Resolve both paths to handle symlinks and path inconsistencies
resolved_file = file_path.resolve()
rel_path = resolved_file.relative_to(self.base_dir)
if fnmatch.fnmatch(str(rel_path), expanded_pattern):
rel_str = portable_relpath(file_path, self.base_dir)
if fnmatch.fnmatch(rel_str, expanded_pattern):
return True
except ValueError:
pass
Expand Down Expand Up @@ -1112,7 +1111,7 @@ def _find_minimal_coverage_placement(self, matching_directories: Set[Path]) -> O
return None

# Convert to relative paths for easier analysis
relative_dirs = [d.relative_to(self.base_dir) for d in matching_directories]
relative_dirs = [d.resolve().relative_to(self.base_dir.resolve()) for d in matching_directories]

# Find the lowest common ancestor that covers all directories
if len(relative_dirs) == 1:
Expand Down Expand Up @@ -1166,7 +1165,7 @@ def _is_hierarchically_covered(self, target_dir: Path, placement_dir: Path) -> b
"""
try:
# Check if target is the same as placement or is a subdirectory of placement
target_dir.relative_to(placement_dir)
target_dir.resolve().relative_to(placement_dir.resolve())
return True
except ValueError:
# target_dir is not under placement_dir
Expand Down Expand Up @@ -1286,8 +1285,8 @@ def _is_child_directory(self, child: Path, parent: Path) -> bool:
bool: True if child is subdirectory of parent.
"""
try:
child.relative_to(parent)
return child != parent
child.resolve().relative_to(parent.resolve())
return child.resolve() != parent.resolve()
except ValueError:
return False

Expand Down
18 changes: 8 additions & 10 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 .link_resolver import UnifiedLinkResolver
from ..output.formatters import CompilationFormatter
from ..output.models import CompilationResults
from ..utils.paths import portable_relpath

# CRITICAL: Shadow Click commands to prevent namespace collision
set = builtins.set
Expand Down Expand Up @@ -241,7 +242,7 @@ def analyze_directory_structure(self, instructions: List[Instruction]) -> Direct
directories[abs_dir].add(pattern)

# Calculate depth and parent relationships
depth = len(abs_dir.relative_to(self.base_dir).parts)
depth = len(abs_dir.resolve().relative_to(self.base_dir.resolve()).parts)
depth_map[abs_dir] = depth

if depth > 0:
Expand Down Expand Up @@ -541,10 +542,7 @@ def _generate_agents_content(
# Add source attribution for individual instructions
if placement.source_attribution:
source = placement.source_attribution.get(str(instruction.file_path), 'local')
try:
rel_path = instruction.file_path.relative_to(self.base_dir)
except ValueError:
rel_path = instruction.file_path
rel_path = portable_relpath(instruction.file_path, self.base_dir)

sections.append(f"<!-- Source: {source} {rel_path} -->")

Expand Down Expand Up @@ -612,7 +610,7 @@ def _find_orphaned_agents_files(self, generated_paths: List[Path]) -> List[Path]
for agents_file in self.base_dir.rglob("AGENTS.md"):
# Skip files that are outside our project or in special directories
try:
relative_path = agents_file.relative_to(self.base_dir)
relative_path = agents_file.resolve().relative_to(self.base_dir.resolve())

# Skip files in certain directories that shouldn't be cleaned
skip_dirs = {".git", ".apm", "node_modules", "__pycache__", ".pytest_cache", "apm_modules"}
Expand Down Expand Up @@ -645,13 +643,13 @@ def _generate_orphan_warnings(self, orphaned_files: List[Path]) -> List[str]:

# Professional warning format with readable list for multiple files
if len(orphaned_files) == 1:
rel_path = orphaned_files[0].relative_to(self.base_dir)
rel_path = portable_relpath(orphaned_files[0], self.base_dir)
warning_messages.append(f"Orphaned AGENTS.md found: {rel_path} - run 'apm compile --clean' to remove")
else:
# For multiple files, create a single multi-line warning message
file_list = []
for file_path in orphaned_files[:5]: # Show first 5
rel_path = file_path.relative_to(self.base_dir)
rel_path = portable_relpath(file_path, self.base_dir)
file_list.append(f" * {rel_path}")
if len(orphaned_files) > 5:
file_list.append(f" * ...and {len(orphaned_files) - 5} more")
Expand Down Expand Up @@ -681,14 +679,14 @@ def _cleanup_orphaned_files(self, orphaned_files: List[Path], dry_run: bool = Fa
# In dry-run mode, just report what would be cleaned
cleanup_messages.append(f"Would clean up {len(orphaned_files)} orphaned AGENTS.md files")
for file_path in orphaned_files:
rel_path = file_path.relative_to(self.base_dir)
rel_path = portable_relpath(file_path, self.base_dir)
cleanup_messages.append(f" * {rel_path}")
else:
# Actually perform the cleanup
cleanup_messages.append(f"Cleaning up {len(orphaned_files)} orphaned AGENTS.md files")
for file_path in orphaned_files:
try:
rel_path = file_path.relative_to(self.base_dir)
rel_path = portable_relpath(file_path, self.base_dir)
file_path.unlink()
cleanup_messages.append(f" + Removed {rel_path}")
except Exception as e:
Expand Down
Loading
Loading