diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25a1d436..f0d8a9c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bf9a253..03a4bbcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/apm_cli/commands/uninstall/engine.py b/src/apm_cli/commands/uninstall/engine.py index 134f4a79..ed20129b 100644 --- a/src/apm_cli/commands/uninstall/engine.py +++ b/src/apm_cli/commands/uninstall/engine.py @@ -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 @@ -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: @@ -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: diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index 00ae1828..a1619648 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -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 @@ -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( @@ -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( @@ -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 @@ -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}") @@ -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))}") @@ -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", "") @@ -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))}") @@ -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([ diff --git a/src/apm_cli/compilation/claude_formatter.py b/src/apm_cli/compilation/claude_formatter.py index 4c2f7079..f06e5ef3 100644 --- a/src/apm_cli/compilation/claude_formatter.py +++ b/src/apm_cli/compilation/claude_formatter.py @@ -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 @@ -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"") diff --git a/src/apm_cli/compilation/context_optimizer.py b/src/apm_cli/compilation/context_optimizer.py index 2e827634..4fc4181d 100644 --- a/src/apm_cli/compilation/context_optimizer.py +++ b/src/apm_cli/compilation/context_optimizer.py @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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) @@ -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 @@ -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: @@ -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 @@ -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 diff --git a/src/apm_cli/compilation/distributed_compiler.py b/src/apm_cli/compilation/distributed_compiler.py index 686e92e6..48c3214b 100644 --- a/src/apm_cli/compilation/distributed_compiler.py +++ b/src/apm_cli/compilation/distributed_compiler.py @@ -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 @@ -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: @@ -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"") @@ -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"} @@ -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") @@ -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: diff --git a/src/apm_cli/compilation/template_builder.py b/src/apm_cli/compilation/template_builder.py index 8aa20267..e9b945d2 100644 --- a/src/apm_cli/compilation/template_builder.py +++ b/src/apm_cli/compilation/template_builder.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import List, Dict, Optional, Tuple from ..primitives.models import Instruction, Chatmode +from ..utils.paths import portable_relpath @dataclass @@ -45,12 +46,12 @@ def build_conditional_sections(instructions: List[Instruction]) -> str: try: # Try to get relative path for cleaner display if instruction.file_path.is_absolute(): - relative_path = instruction.file_path.relative_to(Path.cwd()) + relative_path = portable_relpath(instruction.file_path, Path.cwd()) else: - relative_path = instruction.file_path + relative_path = str(instruction.file_path) except (ValueError, OSError): # Fall back to absolute or given path if relative fails - relative_path = instruction.file_path + relative_path = instruction.file_path.as_posix() sections.append(f"") sections.append(content) diff --git a/src/apm_cli/integration/agent_integrator.py b/src/apm_cli/integration/agent_integrator.py index a98eafc1..fa6c86da 100644 --- a/src/apm_cli/integration/agent_integrator.py +++ b/src/apm_cli/integration/agent_integrator.py @@ -9,6 +9,7 @@ from typing import List, Dict from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult +from apm_cli.utils.paths import portable_relpath class AgentIntegrator(BaseIntegrator): @@ -161,7 +162,7 @@ def integrate_package_agents(self, package_info, project_root: Path, for source_file in agent_files: target_filename = self.get_target_filename(source_file, package_info.package.name) target_path = agents_dir / target_filename - rel_path = str(target_path.relative_to(project_root)) + rel_path = portable_relpath(target_path, project_root) if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): files_skipped += 1 @@ -176,7 +177,7 @@ def integrate_package_agents(self, package_info, project_root: Path, if claude_agents_dir: claude_filename = self.get_target_filename_claude(source_file, package_info.package.name) claude_target = claude_agents_dir / claude_filename - claude_rel = str(claude_target.relative_to(project_root)) + claude_rel = portable_relpath(claude_target, project_root) if not self.check_collision(claude_target, claude_rel, managed_files, force, diagnostics=diagnostics): self.copy_agent(source_file, claude_target) target_paths.append(claude_target) @@ -185,7 +186,7 @@ def integrate_package_agents(self, package_info, project_root: Path, if cursor_agents_dir: cursor_filename = self.get_target_filename_cursor(source_file, package_info.package.name) cursor_target = cursor_agents_dir / cursor_filename - cursor_rel = str(cursor_target.relative_to(project_root)) + cursor_rel = portable_relpath(cursor_target, project_root) if not self.check_collision(cursor_target, cursor_rel, managed_files, force, diagnostics=diagnostics): self.copy_agent(source_file, cursor_target) target_paths.append(cursor_target) @@ -263,7 +264,7 @@ def integrate_package_agents_claude(self, package_info, project_root: Path, for source_file in agent_files: target_filename = self.get_target_filename_claude(source_file, package_info.package.name) target_path = agents_dir / target_filename - rel_path = str(target_path.relative_to(project_root)) + rel_path = portable_relpath(target_path, project_root) if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): files_skipped += 1 @@ -382,7 +383,7 @@ def integrate_package_agents_cursor(self, package_info, project_root: Path, for source_file in agent_files: target_filename = self.get_target_filename_cursor(source_file, package_info.package.name) target_path = agents_dir / target_filename - rel_path = str(target_path.relative_to(project_root)) + rel_path = portable_relpath(target_path, project_root) if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): files_skipped += 1 @@ -452,7 +453,7 @@ def integrate_package_agents_opencode(self, package_info, project_root: Path, source_file, package_info.package.name ) target_path = agents_dir / target_filename - rel_path = str(target_path.relative_to(project_root)) + rel_path = portable_relpath(target_path, project_root) if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): files_skipped += 1 diff --git a/src/apm_cli/integration/command_integrator.py b/src/apm_cli/integration/command_integrator.py index 6cac15c6..883f7ef5 100644 --- a/src/apm_cli/integration/command_integrator.py +++ b/src/apm_cli/integration/command_integrator.py @@ -9,6 +9,7 @@ import frontmatter from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult +from apm_cli.utils.paths import portable_relpath # Re-export for backward compat (tests import CommandIntegrationResult) CommandIntegrationResult = IntegrationResult @@ -136,7 +137,7 @@ def integrate_package_commands(self, package_info, project_root: Path, base_name = prompt_file.stem target_path = commands_dir / f"{base_name}.md" - rel_path = str(target_path.relative_to(project_root)) + rel_path = portable_relpath(target_path, project_root) if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): files_skipped += 1 @@ -224,7 +225,7 @@ def integrate_package_commands_opencode(self, package_info, project_root: Path, base_name = prompt_file.stem target_path = commands_dir / f"{base_name}.md" - rel_path = str(target_path.relative_to(project_root)) + rel_path = portable_relpath(target_path, project_root) if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): files_skipped += 1 diff --git a/src/apm_cli/integration/hook_integrator.py b/src/apm_cli/integration/hook_integrator.py index 0ace6ade..2d122c27 100644 --- a/src/apm_cli/integration/hook_integrator.py +++ b/src/apm_cli/integration/hook_integrator.py @@ -50,6 +50,7 @@ from dataclasses import dataclass, field from apm_cli.integration.base_integrator import BaseIntegrator +from apm_cli.utils.paths import portable_relpath @dataclass @@ -313,7 +314,7 @@ def integrate_package_hooks(self, package_info, project_root: Path, stem = hook_file.stem target_filename = f"{package_name}-{stem}.json" target_path = hooks_dir / target_filename - rel_path = str(target_path.relative_to(project_root)) + rel_path = portable_relpath(target_path, project_root) if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): continue diff --git a/src/apm_cli/integration/instruction_integrator.py b/src/apm_cli/integration/instruction_integrator.py index fb1166f4..2d6f36c1 100644 --- a/src/apm_cli/integration/instruction_integrator.py +++ b/src/apm_cli/integration/instruction_integrator.py @@ -12,6 +12,7 @@ from typing import Dict, List, Optional, Set from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult +from apm_cli.utils.paths import portable_relpath class InstructionIntegrator(BaseIntegrator): @@ -78,7 +79,7 @@ def integrate_package_instructions( for source_file in instruction_files: target_path = instructions_dir / source_file.name - rel_path = str(target_path.relative_to(project_root)) + rel_path = portable_relpath(target_path, project_root) if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): files_skipped += 1 @@ -227,7 +228,7 @@ def integrate_package_instructions_cursor( mdc_name = f"{stem}.mdc" target_path = rules_dir / mdc_name - rel_path = str(target_path.relative_to(project_root)) + rel_path = portable_relpath(target_path, project_root) if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): files_skipped += 1 diff --git a/src/apm_cli/integration/prompt_integrator.py b/src/apm_cli/integration/prompt_integrator.py index 0d33ff58..444831df 100644 --- a/src/apm_cli/integration/prompt_integrator.py +++ b/src/apm_cli/integration/prompt_integrator.py @@ -4,6 +4,7 @@ from typing import List, Dict from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult +from apm_cli.utils.paths import portable_relpath class PromptIntegrator(BaseIntegrator): @@ -111,7 +112,7 @@ def integrate_package_prompts(self, package_info, project_root: Path, for source_file in prompt_files: target_filename = self.get_target_filename(source_file, package_info.package.name) target_path = prompts_dir / target_filename - rel_path = str(target_path.relative_to(project_root)) + rel_path = portable_relpath(target_path, project_root) if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): files_skipped += 1 diff --git a/src/apm_cli/security/gate.py b/src/apm_cli/security/gate.py index ee71663d..e8ac9f96 100644 --- a/src/apm_cli/security/gate.py +++ b/src/apm_cli/security/gate.py @@ -13,6 +13,7 @@ from typing import Dict, List, Literal, Optional from .content_scanner import ContentScanner, ScanFinding +from ..utils.paths import portable_relpath # --------------------------------------------------------------------------- @@ -102,7 +103,7 @@ def scan_files( except OSError: continue if file_findings: - rel = str(fpath.relative_to(root)) + rel = portable_relpath(fpath, root) findings_by_file[rel] = file_findings return SecurityGate._build_verdict( diff --git a/src/apm_cli/utils/__init__.py b/src/apm_cli/utils/__init__.py index dd6df7e8..d397b8d1 100644 --- a/src/apm_cli/utils/__init__.py +++ b/src/apm_cli/utils/__init__.py @@ -21,6 +21,8 @@ CATEGORY_ERROR, ) +from .paths import portable_relpath + __all__ = [ '_rich_success', '_rich_error', @@ -37,4 +39,5 @@ 'CATEGORY_OVERWRITE', 'CATEGORY_WARNING', 'CATEGORY_ERROR', + 'portable_relpath', ] \ No newline at end of file diff --git a/src/apm_cli/utils/paths.py b/src/apm_cli/utils/paths.py new file mode 100644 index 00000000..f084bf6c --- /dev/null +++ b/src/apm_cli/utils/paths.py @@ -0,0 +1,27 @@ +"""Cross-platform path utilities for APM CLI. + +Centralises the resolve-then-relativise-then-posixify pattern so every +call site gets Windows-safe, forward-slash relative paths by default. +""" + +from __future__ import annotations + +from pathlib import Path + + +def portable_relpath(path: Path, base: Path) -> str: + """Return a forward-slash relative path, resolving both sides first. + + Handles Windows 8.3 short names (e.g. ``RUNNER~1`` vs ``runneradmin``) + and ensures consistent POSIX output on every platform. + + When *path* is not under *base* (or resolution fails), falls back to + a resolved absolute POSIX path. + """ + try: + return path.resolve().relative_to(base.resolve()).as_posix() + except (ValueError, OSError, RuntimeError): + try: + return path.resolve().as_posix() + except (OSError, RuntimeError): + return path.as_posix() diff --git a/tests/unit/test_portable_relpath.py b/tests/unit/test_portable_relpath.py new file mode 100644 index 00000000..946a17f5 --- /dev/null +++ b/tests/unit/test_portable_relpath.py @@ -0,0 +1,96 @@ +"""Unit tests for apm_cli.utils.paths.portable_relpath(). + +Covers: +- Basic relative path computation with forward slashes +- .resolve() on both sides (handles Windows 8.3 short-name mismatches) +- Fallback to POSIX path when path is not under base +- Edge cases: same directory, deeply nested, trailing slashes +""" + +import pytest + +from apm_cli.utils.paths import portable_relpath + + +class TestPortableRelpath: + """Tests for portable_relpath().""" + + def test_simple_relative(self, tmp_path): + """Basic child path returns forward-slash relative string.""" + child = tmp_path / "sub" / "file.md" + child.parent.mkdir(parents=True, exist_ok=True) + child.touch() + result = portable_relpath(child, tmp_path) + assert result == "sub/file.md" + + def test_deeply_nested(self, tmp_path): + """Deeply nested paths use forward slashes throughout.""" + child = tmp_path / "a" / "b" / "c" / "d.txt" + child.parent.mkdir(parents=True, exist_ok=True) + child.touch() + result = portable_relpath(child, tmp_path) + assert result == "a/b/c/d.txt" + + def test_same_directory(self, tmp_path): + """Path equal to base returns '.'.""" + result = portable_relpath(tmp_path, tmp_path) + assert result == "." + + def test_file_in_base(self, tmp_path): + """File directly in base returns just the filename.""" + child = tmp_path / "README.md" + child.touch() + result = portable_relpath(child, tmp_path) + assert result == "README.md" + + def test_fallback_when_not_under_base(self, tmp_path): + """Returns POSIX-style resolved path when path is not under base.""" + other = tmp_path / "other" + other.mkdir() + base = tmp_path / "base" + base.mkdir() + result = portable_relpath(other, base) + # Should fall back to resolved absolute POSIX path + assert result == other.resolve().as_posix() + assert "\\" not in result + + def test_result_never_contains_backslash(self, tmp_path): + """Returned string never contains backslashes (Windows safety).""" + child = tmp_path / "src" / "apm_cli" / "utils" / "paths.py" + child.parent.mkdir(parents=True, exist_ok=True) + child.touch() + result = portable_relpath(child, tmp_path) + assert "\\" not in result + assert result == "src/apm_cli/utils/paths.py" + + def test_resolve_handles_symlinks(self, tmp_path): + """Symlinked paths resolve to the same result.""" + real_dir = tmp_path / "real" + real_dir.mkdir() + child = real_dir / "file.txt" + child.touch() + + link_dir = tmp_path / "link" + try: + link_dir.symlink_to(real_dir) + except OSError: + pytest.skip("symlinks not supported on this platform") + + linked_child = link_dir / "file.txt" + # Both should resolve to the same thing + result_real = portable_relpath(child, tmp_path) + result_link = portable_relpath(linked_child, tmp_path) + assert result_real == result_link + + def test_nonexistent_path_still_works(self, tmp_path): + """Works even if the path doesn't exist on disk (resolve still works).""" + fake = tmp_path / "does" / "not" / "exist.md" + result = portable_relpath(fake, tmp_path) + assert result == "does/not/exist.md" + + def test_return_type_is_str(self, tmp_path): + """Always returns a str, never a Path.""" + child = tmp_path / "file.txt" + child.touch() + result = portable_relpath(child, tmp_path) + assert isinstance(result, str)