mirror of
https://github.com/ksyasuda/dotfiles.git
synced 2026-02-27 12:22:43 -08:00
update
This commit is contained in:
13
.agents/skills/ios-simulator-skill/scripts/xcode/__init__.py
Normal file
13
.agents/skills/ios-simulator-skill/scripts/xcode/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Xcode build automation module.
|
||||
|
||||
Provides structured, modular access to xcodebuild and xcresult functionality.
|
||||
"""
|
||||
|
||||
from .builder import BuildRunner
|
||||
from .cache import XCResultCache
|
||||
from .config import Config
|
||||
from .reporter import OutputFormatter
|
||||
from .xcresult import XCResultParser
|
||||
|
||||
__all__ = ["BuildRunner", "Config", "OutputFormatter", "XCResultCache", "XCResultParser"]
|
||||
381
.agents/skills/ios-simulator-skill/scripts/xcode/builder.py
Normal file
381
.agents/skills/ios-simulator-skill/scripts/xcode/builder.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
Xcode build execution.
|
||||
|
||||
Handles xcodebuild command construction and execution with xcresult generation.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .cache import XCResultCache
|
||||
from .config import Config
|
||||
|
||||
|
||||
class BuildRunner:
|
||||
"""
|
||||
Execute xcodebuild commands with xcresult bundle generation.
|
||||
|
||||
Handles scheme auto-detection, command construction, and build/test execution.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_path: str | None = None,
|
||||
workspace_path: str | None = None,
|
||||
scheme: str | None = None,
|
||||
configuration: str = "Debug",
|
||||
simulator: str | None = None,
|
||||
cache: XCResultCache | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize build runner.
|
||||
|
||||
Args:
|
||||
project_path: Path to .xcodeproj
|
||||
workspace_path: Path to .xcworkspace
|
||||
scheme: Build scheme (auto-detected if not provided)
|
||||
configuration: Build configuration (Debug/Release)
|
||||
simulator: Simulator name
|
||||
cache: XCResult cache (creates default if not provided)
|
||||
"""
|
||||
self.project_path = project_path
|
||||
self.workspace_path = workspace_path
|
||||
self.scheme = scheme
|
||||
self.configuration = configuration
|
||||
self.simulator = simulator
|
||||
self.cache = cache or XCResultCache()
|
||||
|
||||
def auto_detect_scheme(self) -> str | None:
|
||||
"""
|
||||
Auto-detect build scheme from project/workspace.
|
||||
|
||||
Returns:
|
||||
Detected scheme name or None
|
||||
"""
|
||||
cmd = ["xcodebuild", "-list"]
|
||||
|
||||
if self.workspace_path:
|
||||
cmd.extend(["-workspace", self.workspace_path])
|
||||
elif self.project_path:
|
||||
cmd.extend(["-project", self.project_path])
|
||||
else:
|
||||
return None
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
|
||||
# Parse schemes from output
|
||||
in_schemes_section = False
|
||||
for line in result.stdout.split("\n"):
|
||||
line = line.strip()
|
||||
|
||||
if "Schemes:" in line:
|
||||
in_schemes_section = True
|
||||
continue
|
||||
|
||||
if in_schemes_section and line and not line.startswith("Build"):
|
||||
# First scheme in list
|
||||
return line
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error auto-detecting scheme: {e}", file=sys.stderr)
|
||||
|
||||
return None
|
||||
|
||||
def get_simulator_destination(self) -> str:
|
||||
"""
|
||||
Get xcodebuild destination string.
|
||||
|
||||
Uses config preferences with fallback to auto-detection.
|
||||
|
||||
Priority:
|
||||
1. --simulator CLI flag (self.simulator)
|
||||
2. Config preferred_simulator
|
||||
3. Config last_used_simulator
|
||||
4. Auto-detect first iPhone
|
||||
5. Generic iOS Simulator
|
||||
|
||||
Returns:
|
||||
Destination string for -destination flag
|
||||
"""
|
||||
# Priority 1: CLI flag
|
||||
if self.simulator:
|
||||
return f"platform=iOS Simulator,name={self.simulator}"
|
||||
|
||||
# Priority 2-3: Config preferences
|
||||
try:
|
||||
# Determine project directory from project/workspace path
|
||||
project_dir = None
|
||||
if self.project_path:
|
||||
project_dir = Path(self.project_path).parent
|
||||
elif self.workspace_path:
|
||||
project_dir = Path(self.workspace_path).parent
|
||||
|
||||
config = Config.load(project_dir=project_dir)
|
||||
preferred = config.get_preferred_simulator()
|
||||
|
||||
if preferred:
|
||||
# Check if preferred simulator exists
|
||||
if self._simulator_exists(preferred):
|
||||
return f"platform=iOS Simulator,name={preferred}"
|
||||
print(f"Warning: Preferred simulator '{preferred}' not available", file=sys.stderr)
|
||||
if config.should_fallback_to_any_iphone():
|
||||
print("Falling back to auto-detection...", file=sys.stderr)
|
||||
else:
|
||||
# Strict mode: don't fallback
|
||||
return f"platform=iOS Simulator,name={preferred}"
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load config: {e}", file=sys.stderr)
|
||||
|
||||
# Priority 4-5: Auto-detect
|
||||
return self._auto_detect_simulator()
|
||||
|
||||
def _simulator_exists(self, name: str) -> bool:
|
||||
"""
|
||||
Check if simulator with given name exists and is available.
|
||||
|
||||
Args:
|
||||
name: Simulator name (e.g., "iPhone 16 Pro")
|
||||
|
||||
Returns:
|
||||
True if simulator exists and is available
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["xcrun", "simctl", "list", "devices", "available", "iOS"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Check if simulator name appears in available devices
|
||||
return any(name in line and "(" in line for line in result.stdout.split("\n"))
|
||||
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def _extract_simulator_name_from_destination(self, destination: str) -> str | None:
|
||||
"""
|
||||
Extract simulator name from destination string.
|
||||
|
||||
Args:
|
||||
destination: Destination string (e.g., "platform=iOS Simulator,name=iPhone 16 Pro")
|
||||
|
||||
Returns:
|
||||
Simulator name or None
|
||||
"""
|
||||
# Pattern: name=<simulator name>
|
||||
match = re.search(r"name=([^,]+)", destination)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return None
|
||||
|
||||
def _auto_detect_simulator(self) -> str:
|
||||
"""
|
||||
Auto-detect best available iOS simulator.
|
||||
|
||||
Returns:
|
||||
Destination string for -destination flag
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["xcrun", "simctl", "list", "devices", "available", "iOS"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Parse available simulators, prefer latest iPhone
|
||||
# Looking for lines like: "iPhone 16 Pro (12345678-1234-1234-1234-123456789012) (Shutdown)"
|
||||
for line in result.stdout.split("\n"):
|
||||
if "iPhone" in line and "(" in line:
|
||||
# Extract device name
|
||||
name = line.split("(")[0].strip()
|
||||
if name:
|
||||
return f"platform=iOS Simulator,name={name}"
|
||||
|
||||
# Fallback to generic iOS Simulator if no iPhone found
|
||||
return "generic/platform=iOS Simulator"
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Warning: Could not auto-detect simulator: {e}", file=sys.stderr)
|
||||
return "generic/platform=iOS Simulator"
|
||||
|
||||
def build(self, clean: bool = False) -> tuple[bool, str, str]:
|
||||
"""
|
||||
Build the project.
|
||||
|
||||
Args:
|
||||
clean: Perform clean build
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, xcresult_id: str, stderr: str)
|
||||
"""
|
||||
# Auto-detect scheme if needed
|
||||
if not self.scheme:
|
||||
self.scheme = self.auto_detect_scheme()
|
||||
if not self.scheme:
|
||||
print("Error: Could not auto-detect scheme. Use --scheme", file=sys.stderr)
|
||||
return (False, "", "")
|
||||
|
||||
# Generate xcresult ID and path
|
||||
xcresult_id = self.cache.generate_id()
|
||||
xcresult_path = self.cache.get_path(xcresult_id)
|
||||
|
||||
# Build command
|
||||
cmd = ["xcodebuild", "-quiet"] # Suppress verbose output
|
||||
|
||||
if clean:
|
||||
cmd.append("clean")
|
||||
|
||||
cmd.append("build")
|
||||
|
||||
if self.workspace_path:
|
||||
cmd.extend(["-workspace", self.workspace_path])
|
||||
elif self.project_path:
|
||||
cmd.extend(["-project", self.project_path])
|
||||
else:
|
||||
print("Error: No project or workspace specified", file=sys.stderr)
|
||||
return (False, "", "")
|
||||
|
||||
cmd.extend(
|
||||
[
|
||||
"-scheme",
|
||||
self.scheme,
|
||||
"-configuration",
|
||||
self.configuration,
|
||||
"-destination",
|
||||
self.get_simulator_destination(),
|
||||
"-resultBundlePath",
|
||||
str(xcresult_path),
|
||||
]
|
||||
)
|
||||
|
||||
# Execute build
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, check=False # Don't raise on non-zero exit
|
||||
)
|
||||
|
||||
success = result.returncode == 0
|
||||
|
||||
# xcresult bundle should be created even on failure
|
||||
if not xcresult_path.exists():
|
||||
print("Warning: xcresult bundle was not created", file=sys.stderr)
|
||||
return (success, "", result.stderr)
|
||||
|
||||
# Auto-update config with last used simulator (on success only)
|
||||
if success:
|
||||
try:
|
||||
# Determine project directory from project/workspace path
|
||||
project_dir = None
|
||||
if self.project_path:
|
||||
project_dir = Path(self.project_path).parent
|
||||
elif self.workspace_path:
|
||||
project_dir = Path(self.workspace_path).parent
|
||||
|
||||
config = Config.load(project_dir=project_dir)
|
||||
destination = self.get_simulator_destination()
|
||||
simulator_name = self._extract_simulator_name_from_destination(destination)
|
||||
|
||||
if simulator_name:
|
||||
config.update_last_used_simulator(simulator_name)
|
||||
config.save()
|
||||
|
||||
except Exception as e:
|
||||
# Don't fail build if config update fails
|
||||
print(f"Warning: Could not update config: {e}", file=sys.stderr)
|
||||
|
||||
return (success, xcresult_id, result.stderr)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error executing build: {e}", file=sys.stderr)
|
||||
return (False, "", str(e))
|
||||
|
||||
def test(self, test_suite: str | None = None) -> tuple[bool, str, str]:
|
||||
"""
|
||||
Run tests.
|
||||
|
||||
Args:
|
||||
test_suite: Specific test suite to run
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, xcresult_id: str, stderr: str)
|
||||
"""
|
||||
# Auto-detect scheme if needed
|
||||
if not self.scheme:
|
||||
self.scheme = self.auto_detect_scheme()
|
||||
if not self.scheme:
|
||||
print("Error: Could not auto-detect scheme. Use --scheme", file=sys.stderr)
|
||||
return (False, "", "")
|
||||
|
||||
# Generate xcresult ID and path
|
||||
xcresult_id = self.cache.generate_id()
|
||||
xcresult_path = self.cache.get_path(xcresult_id)
|
||||
|
||||
# Build command
|
||||
cmd = ["xcodebuild", "-quiet", "test"]
|
||||
|
||||
if self.workspace_path:
|
||||
cmd.extend(["-workspace", self.workspace_path])
|
||||
elif self.project_path:
|
||||
cmd.extend(["-project", self.project_path])
|
||||
else:
|
||||
print("Error: No project or workspace specified", file=sys.stderr)
|
||||
return (False, "", "")
|
||||
|
||||
cmd.extend(
|
||||
[
|
||||
"-scheme",
|
||||
self.scheme,
|
||||
"-destination",
|
||||
self.get_simulator_destination(),
|
||||
"-resultBundlePath",
|
||||
str(xcresult_path),
|
||||
]
|
||||
)
|
||||
|
||||
if test_suite:
|
||||
cmd.extend(["-only-testing", test_suite])
|
||||
|
||||
# Execute tests
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
|
||||
success = result.returncode == 0
|
||||
|
||||
# xcresult bundle should be created even on failure
|
||||
if not xcresult_path.exists():
|
||||
print("Warning: xcresult bundle was not created", file=sys.stderr)
|
||||
return (success, "", result.stderr)
|
||||
|
||||
# Auto-update config with last used simulator (on success only)
|
||||
if success:
|
||||
try:
|
||||
# Determine project directory from project/workspace path
|
||||
project_dir = None
|
||||
if self.project_path:
|
||||
project_dir = Path(self.project_path).parent
|
||||
elif self.workspace_path:
|
||||
project_dir = Path(self.workspace_path).parent
|
||||
|
||||
config = Config.load(project_dir=project_dir)
|
||||
destination = self.get_simulator_destination()
|
||||
simulator_name = self._extract_simulator_name_from_destination(destination)
|
||||
|
||||
if simulator_name:
|
||||
config.update_last_used_simulator(simulator_name)
|
||||
config.save()
|
||||
|
||||
except Exception as e:
|
||||
# Don't fail test if config update fails
|
||||
print(f"Warning: Could not update config: {e}", file=sys.stderr)
|
||||
|
||||
return (success, xcresult_id, result.stderr)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error executing tests: {e}", file=sys.stderr)
|
||||
return (False, "", str(e))
|
||||
204
.agents/skills/ios-simulator-skill/scripts/xcode/cache.py
Normal file
204
.agents/skills/ios-simulator-skill/scripts/xcode/cache.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
XCResult cache management.
|
||||
|
||||
Handles storage, retrieval, and lifecycle of xcresult bundles for progressive disclosure.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class XCResultCache:
|
||||
"""
|
||||
Manage xcresult bundle cache for progressive disclosure.
|
||||
|
||||
Stores xcresult bundles with timestamp-based IDs and provides
|
||||
retrieval and cleanup operations.
|
||||
"""
|
||||
|
||||
# Default cache directory
|
||||
DEFAULT_CACHE_DIR = Path.home() / ".ios-simulator-skill" / "xcresults"
|
||||
|
||||
def __init__(self, cache_dir: Path | None = None):
|
||||
"""
|
||||
Initialize cache manager.
|
||||
|
||||
Args:
|
||||
cache_dir: Custom cache directory (uses default if not specified)
|
||||
"""
|
||||
self.cache_dir = cache_dir or self.DEFAULT_CACHE_DIR
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def generate_id(self, prefix: str = "xcresult") -> str:
|
||||
"""
|
||||
Generate timestamped xcresult ID.
|
||||
|
||||
Args:
|
||||
prefix: ID prefix (default: "xcresult")
|
||||
|
||||
Returns:
|
||||
ID string like "xcresult-20251018-143052"
|
||||
"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
return f"{prefix}-{timestamp}"
|
||||
|
||||
def get_path(self, xcresult_id: str) -> Path:
|
||||
"""
|
||||
Get full path for xcresult ID.
|
||||
|
||||
Args:
|
||||
xcresult_id: XCResult ID
|
||||
|
||||
Returns:
|
||||
Path to xcresult bundle
|
||||
"""
|
||||
# Handle both with and without .xcresult extension
|
||||
if xcresult_id.endswith(".xcresult"):
|
||||
return self.cache_dir / xcresult_id
|
||||
return self.cache_dir / f"{xcresult_id}.xcresult"
|
||||
|
||||
def exists(self, xcresult_id: str) -> bool:
|
||||
"""
|
||||
Check if xcresult bundle exists.
|
||||
|
||||
Args:
|
||||
xcresult_id: XCResult ID
|
||||
|
||||
Returns:
|
||||
True if bundle exists
|
||||
"""
|
||||
return self.get_path(xcresult_id).exists()
|
||||
|
||||
def save(self, source_path: Path, xcresult_id: str | None = None) -> str:
|
||||
"""
|
||||
Save xcresult bundle to cache.
|
||||
|
||||
Args:
|
||||
source_path: Source xcresult bundle path
|
||||
xcresult_id: Optional custom ID (generates if not provided)
|
||||
|
||||
Returns:
|
||||
xcresult ID
|
||||
"""
|
||||
if not source_path.exists():
|
||||
raise FileNotFoundError(f"Source xcresult not found: {source_path}")
|
||||
|
||||
# Generate ID if not provided
|
||||
if not xcresult_id:
|
||||
xcresult_id = self.generate_id()
|
||||
|
||||
# Get destination path
|
||||
dest_path = self.get_path(xcresult_id)
|
||||
|
||||
# Copy xcresult bundle (it's a directory)
|
||||
if dest_path.exists():
|
||||
shutil.rmtree(dest_path)
|
||||
|
||||
shutil.copytree(source_path, dest_path)
|
||||
|
||||
return xcresult_id
|
||||
|
||||
def list(self, limit: int = 10) -> list[dict]:
|
||||
"""
|
||||
List recent xcresult bundles.
|
||||
|
||||
Args:
|
||||
limit: Maximum number to return
|
||||
|
||||
Returns:
|
||||
List of xcresult metadata dicts
|
||||
"""
|
||||
if not self.cache_dir.exists():
|
||||
return []
|
||||
|
||||
results = []
|
||||
for path in sorted(
|
||||
self.cache_dir.glob("*.xcresult"), key=lambda p: p.stat().st_mtime, reverse=True
|
||||
)[:limit]:
|
||||
# Calculate bundle size
|
||||
size_bytes = sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
||||
|
||||
results.append(
|
||||
{
|
||||
"id": path.stem,
|
||||
"path": str(path),
|
||||
"created": datetime.fromtimestamp(path.stat().st_mtime).isoformat(),
|
||||
"size_mb": round(size_bytes / (1024 * 1024), 2),
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def cleanup(self, keep_recent: int = 20) -> int:
|
||||
"""
|
||||
Clean up old xcresult bundles.
|
||||
|
||||
Args:
|
||||
keep_recent: Number of recent bundles to keep
|
||||
|
||||
Returns:
|
||||
Number of bundles removed
|
||||
"""
|
||||
if not self.cache_dir.exists():
|
||||
return 0
|
||||
|
||||
# Get all bundles sorted by modification time
|
||||
all_bundles = sorted(
|
||||
self.cache_dir.glob("*.xcresult"), key=lambda p: p.stat().st_mtime, reverse=True
|
||||
)
|
||||
|
||||
# Remove old bundles
|
||||
removed = 0
|
||||
for bundle_path in all_bundles[keep_recent:]:
|
||||
shutil.rmtree(bundle_path)
|
||||
removed += 1
|
||||
|
||||
return removed
|
||||
|
||||
def get_size_mb(self, xcresult_id: str) -> float:
|
||||
"""
|
||||
Get size of xcresult bundle in MB.
|
||||
|
||||
Args:
|
||||
xcresult_id: XCResult ID
|
||||
|
||||
Returns:
|
||||
Size in MB
|
||||
"""
|
||||
path = self.get_path(xcresult_id)
|
||||
if not path.exists():
|
||||
return 0.0
|
||||
|
||||
size_bytes = sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
||||
return round(size_bytes / (1024 * 1024), 2)
|
||||
|
||||
def save_stderr(self, xcresult_id: str, stderr: str) -> None:
|
||||
"""
|
||||
Save stderr output alongside xcresult bundle.
|
||||
|
||||
Args:
|
||||
xcresult_id: XCResult ID
|
||||
stderr: stderr output from xcodebuild
|
||||
"""
|
||||
if not stderr:
|
||||
return
|
||||
|
||||
stderr_path = self.cache_dir / f"{xcresult_id}.stderr"
|
||||
stderr_path.write_text(stderr, encoding="utf-8")
|
||||
|
||||
def get_stderr(self, xcresult_id: str) -> str:
|
||||
"""
|
||||
Retrieve cached stderr output.
|
||||
|
||||
Args:
|
||||
xcresult_id: XCResult ID
|
||||
|
||||
Returns:
|
||||
stderr content or empty string if not found
|
||||
"""
|
||||
stderr_path = self.cache_dir / f"{xcresult_id}.stderr"
|
||||
if not stderr_path.exists():
|
||||
return ""
|
||||
|
||||
return stderr_path.read_text(encoding="utf-8")
|
||||
178
.agents/skills/ios-simulator-skill/scripts/xcode/config.py
Normal file
178
.agents/skills/ios-simulator-skill/scripts/xcode/config.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Configuration management for iOS Simulator Skill.
|
||||
|
||||
Handles loading, validation, and auto-updating of project-local config files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Config:
|
||||
"""
|
||||
Project-local configuration with auto-learning.
|
||||
|
||||
Config file location: .claude/skills/<skill-directory-name>/config.json
|
||||
|
||||
The skill directory name is auto-detected from the installation location,
|
||||
so configs work regardless of what users name the skill directory.
|
||||
|
||||
Auto-updates last_used_simulator after successful builds.
|
||||
"""
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"device": {
|
||||
"preferred_simulator": None,
|
||||
"preferred_os_version": None,
|
||||
"fallback_to_any_iphone": True,
|
||||
"last_used_simulator": None,
|
||||
"last_used_at": None,
|
||||
}
|
||||
}
|
||||
|
||||
def __init__(self, data: dict[str, Any], config_path: Path):
|
||||
"""
|
||||
Initialize config.
|
||||
|
||||
Args:
|
||||
data: Config data dict
|
||||
config_path: Path to config file
|
||||
"""
|
||||
self.data = data
|
||||
self.config_path = config_path
|
||||
|
||||
@staticmethod
|
||||
def load(project_dir: Path | None = None) -> "Config":
|
||||
"""
|
||||
Load config from project directory.
|
||||
|
||||
Args:
|
||||
project_dir: Project root (defaults to cwd)
|
||||
|
||||
Returns:
|
||||
Config instance (creates default if not found)
|
||||
|
||||
Note:
|
||||
The skill directory name is auto-detected from the installation location,
|
||||
so configs work regardless of what users name the skill directory.
|
||||
"""
|
||||
if project_dir is None:
|
||||
project_dir = Path.cwd()
|
||||
|
||||
# Auto-detect skill directory name from actual installation location
|
||||
# This file is at: skill/scripts/xcode/config.py
|
||||
# Navigate up to skill/ directory and use its name
|
||||
skill_root = Path(__file__).parent.parent.parent # xcode/ -> scripts/ -> skill/
|
||||
skill_name = skill_root.name
|
||||
|
||||
config_path = project_dir / ".claude" / "skills" / skill_name / "config.json"
|
||||
|
||||
# Load existing config
|
||||
if config_path.exists():
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Merge with defaults (in case new fields added)
|
||||
merged = Config._merge_with_defaults(data)
|
||||
return Config(merged, config_path)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Warning: Invalid JSON in {config_path}: {e}", file=sys.stderr)
|
||||
print("Using default config", file=sys.stderr)
|
||||
return Config(Config.DEFAULT_CONFIG.copy(), config_path)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load config: {e}", file=sys.stderr)
|
||||
return Config(Config.DEFAULT_CONFIG.copy(), config_path)
|
||||
|
||||
# Return default config (will be created on first save)
|
||||
return Config(Config.DEFAULT_CONFIG.copy(), config_path)
|
||||
|
||||
@staticmethod
|
||||
def _merge_with_defaults(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Merge user config with defaults.
|
||||
|
||||
Args:
|
||||
data: User config data
|
||||
|
||||
Returns:
|
||||
Merged config with all default fields
|
||||
"""
|
||||
merged = Config.DEFAULT_CONFIG.copy()
|
||||
|
||||
# Deep merge device section
|
||||
if "device" in data:
|
||||
merged["device"].update(data["device"])
|
||||
|
||||
return merged
|
||||
|
||||
def save(self) -> None:
|
||||
"""
|
||||
Save config to file atomically.
|
||||
|
||||
Uses temp file + rename for atomic writes.
|
||||
Creates parent directories if needed.
|
||||
"""
|
||||
try:
|
||||
# Create parent directories
|
||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Atomic write: temp file + rename
|
||||
temp_path = self.config_path.with_suffix(".tmp")
|
||||
|
||||
with open(temp_path, "w") as f:
|
||||
json.dump(self.data, f, indent=2)
|
||||
f.write("\n") # Trailing newline
|
||||
|
||||
# Atomic rename
|
||||
temp_path.replace(self.config_path)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not save config: {e}", file=sys.stderr)
|
||||
|
||||
def update_last_used_simulator(self, name: str) -> None:
|
||||
"""
|
||||
Update last used simulator and timestamp.
|
||||
|
||||
Args:
|
||||
name: Simulator name (e.g., "iPhone 16 Pro")
|
||||
"""
|
||||
self.data["device"]["last_used_simulator"] = name
|
||||
self.data["device"]["last_used_at"] = datetime.utcnow().isoformat() + "Z"
|
||||
|
||||
def get_preferred_simulator(self) -> str | None:
|
||||
"""
|
||||
Get preferred simulator.
|
||||
|
||||
Returns:
|
||||
Simulator name or None
|
||||
|
||||
Priority:
|
||||
1. preferred_simulator (manual preference)
|
||||
2. last_used_simulator (auto-learned)
|
||||
3. None (use auto-detection)
|
||||
"""
|
||||
device = self.data.get("device", {})
|
||||
|
||||
# Manual preference takes priority
|
||||
if device.get("preferred_simulator"):
|
||||
return device["preferred_simulator"]
|
||||
|
||||
# Auto-learned preference
|
||||
if device.get("last_used_simulator"):
|
||||
return device["last_used_simulator"]
|
||||
|
||||
return None
|
||||
|
||||
def should_fallback_to_any_iphone(self) -> bool:
|
||||
"""
|
||||
Check if fallback to any iPhone is enabled.
|
||||
|
||||
Returns:
|
||||
True if should fallback, False otherwise
|
||||
"""
|
||||
return self.data.get("device", {}).get("fallback_to_any_iphone", True)
|
||||
291
.agents/skills/ios-simulator-skill/scripts/xcode/reporter.py
Normal file
291
.agents/skills/ios-simulator-skill/scripts/xcode/reporter.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
Build/test output formatting.
|
||||
|
||||
Provides multiple output formats with progressive disclosure support.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
class OutputFormatter:
|
||||
"""
|
||||
Format build/test results for display.
|
||||
|
||||
Supports ultra-minimal default output, verbose mode, and JSON output.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def format_minimal(
|
||||
status: str,
|
||||
error_count: int,
|
||||
warning_count: int,
|
||||
xcresult_id: str,
|
||||
test_info: dict | None = None,
|
||||
hints: list[str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Format ultra-minimal output (5-10 tokens).
|
||||
|
||||
Args:
|
||||
status: Build status (SUCCESS/FAILED)
|
||||
error_count: Number of errors
|
||||
warning_count: Number of warnings
|
||||
xcresult_id: XCResult bundle ID
|
||||
test_info: Optional test results dict
|
||||
hints: Optional list of actionable hints
|
||||
|
||||
Returns:
|
||||
Minimal formatted string
|
||||
|
||||
Example:
|
||||
Build: SUCCESS (0 errors, 3 warnings) [xcresult-20251018-143052]
|
||||
Tests: PASS (12/12 passed, 4.2s) [xcresult-20251018-143052]
|
||||
"""
|
||||
lines = []
|
||||
|
||||
if test_info:
|
||||
# Test mode
|
||||
total = test_info.get("total", 0)
|
||||
passed = test_info.get("passed", 0)
|
||||
failed = test_info.get("failed", 0)
|
||||
duration = test_info.get("duration", 0.0)
|
||||
|
||||
test_status = "PASS" if failed == 0 else "FAIL"
|
||||
lines.append(
|
||||
f"Tests: {test_status} ({passed}/{total} passed, {duration:.1f}s) [{xcresult_id}]"
|
||||
)
|
||||
else:
|
||||
# Build mode
|
||||
lines.append(
|
||||
f"Build: {status} ({error_count} errors, {warning_count} warnings) [{xcresult_id}]"
|
||||
)
|
||||
|
||||
# Add hints if provided and build failed
|
||||
if hints and status == "FAILED":
|
||||
lines.append("")
|
||||
lines.extend(hints)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def format_errors(errors: list[dict], limit: int = 10) -> str:
|
||||
"""
|
||||
Format error details.
|
||||
|
||||
Args:
|
||||
errors: List of error dicts
|
||||
limit: Maximum errors to show
|
||||
|
||||
Returns:
|
||||
Formatted error list
|
||||
"""
|
||||
if not errors:
|
||||
return "No errors found."
|
||||
|
||||
lines = [f"Errors ({len(errors)}):"]
|
||||
lines.append("")
|
||||
|
||||
for i, error in enumerate(errors[:limit], 1):
|
||||
message = error.get("message", "Unknown error")
|
||||
location = error.get("location", {})
|
||||
|
||||
# Format location
|
||||
loc_parts = []
|
||||
if location.get("file"):
|
||||
file_path = location["file"].replace("file://", "")
|
||||
loc_parts.append(file_path)
|
||||
if location.get("line"):
|
||||
loc_parts.append(f"line {location['line']}")
|
||||
|
||||
location_str = ":".join(loc_parts) if loc_parts else "unknown location"
|
||||
|
||||
lines.append(f"{i}. {message}")
|
||||
lines.append(f" Location: {location_str}")
|
||||
lines.append("")
|
||||
|
||||
if len(errors) > limit:
|
||||
lines.append(f"... and {len(errors) - limit} more errors")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def format_warnings(warnings: list[dict], limit: int = 10) -> str:
|
||||
"""
|
||||
Format warning details.
|
||||
|
||||
Args:
|
||||
warnings: List of warning dicts
|
||||
limit: Maximum warnings to show
|
||||
|
||||
Returns:
|
||||
Formatted warning list
|
||||
"""
|
||||
if not warnings:
|
||||
return "No warnings found."
|
||||
|
||||
lines = [f"Warnings ({len(warnings)}):"]
|
||||
lines.append("")
|
||||
|
||||
for i, warning in enumerate(warnings[:limit], 1):
|
||||
message = warning.get("message", "Unknown warning")
|
||||
location = warning.get("location", {})
|
||||
|
||||
# Format location
|
||||
loc_parts = []
|
||||
if location.get("file"):
|
||||
file_path = location["file"].replace("file://", "")
|
||||
loc_parts.append(file_path)
|
||||
if location.get("line"):
|
||||
loc_parts.append(f"line {location['line']}")
|
||||
|
||||
location_str = ":".join(loc_parts) if loc_parts else "unknown location"
|
||||
|
||||
lines.append(f"{i}. {message}")
|
||||
lines.append(f" Location: {location_str}")
|
||||
lines.append("")
|
||||
|
||||
if len(warnings) > limit:
|
||||
lines.append(f"... and {len(warnings) - limit} more warnings")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def format_log(log: str, lines: int = 50) -> str:
|
||||
"""
|
||||
Format build log (show last N lines).
|
||||
|
||||
Args:
|
||||
log: Full build log
|
||||
lines: Number of lines to show
|
||||
|
||||
Returns:
|
||||
Formatted log excerpt
|
||||
"""
|
||||
if not log:
|
||||
return "No build log available."
|
||||
|
||||
log_lines = log.strip().split("\n")
|
||||
|
||||
if len(log_lines) <= lines:
|
||||
return log
|
||||
|
||||
# Show last N lines
|
||||
excerpt = log_lines[-lines:]
|
||||
return f"... (showing last {lines} lines of {len(log_lines)})\n\n" + "\n".join(excerpt)
|
||||
|
||||
@staticmethod
|
||||
def format_json(data: dict) -> str:
|
||||
"""
|
||||
Format data as JSON.
|
||||
|
||||
Args:
|
||||
data: Data to format
|
||||
|
||||
Returns:
|
||||
Pretty-printed JSON string
|
||||
"""
|
||||
return json.dumps(data, indent=2)
|
||||
|
||||
@staticmethod
|
||||
def generate_hints(errors: list[dict]) -> list[str]:
|
||||
"""
|
||||
Generate actionable hints based on error types.
|
||||
|
||||
Args:
|
||||
errors: List of error dicts
|
||||
|
||||
Returns:
|
||||
List of hint strings
|
||||
"""
|
||||
hints = []
|
||||
error_types: set[str] = set()
|
||||
|
||||
# Collect error types
|
||||
for error in errors:
|
||||
error_type = error.get("type", "unknown")
|
||||
error_types.add(error_type)
|
||||
|
||||
# Generate hints based on error types
|
||||
if "provisioning" in error_types:
|
||||
hints.append("Provisioning profile issue detected:")
|
||||
hints.append(" • Ensure you have a valid provisioning profile for iOS Simulator")
|
||||
hints.append(
|
||||
' • For simulator builds, use CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO'
|
||||
)
|
||||
hints.append(" • Or specify simulator explicitly: --simulator 'iPhone 16 Pro'")
|
||||
|
||||
if "signing" in error_types:
|
||||
hints.append("Code signing issue detected:")
|
||||
hints.append(" • For simulator builds, code signing is not required")
|
||||
hints.append(" • Ensure build settings target iOS Simulator, not physical device")
|
||||
hints.append(" • Check destination: platform=iOS Simulator,name=<device>")
|
||||
|
||||
if not error_types or "build" in error_types:
|
||||
# Generic hints when error type is unknown
|
||||
if any("destination" in error.get("message", "").lower() for error in errors):
|
||||
hints.append("Device selection issue detected:")
|
||||
hints.append(" • List available simulators: xcrun simctl list devices available")
|
||||
hints.append(" • Specify simulator: --simulator 'iPhone 16 Pro'")
|
||||
|
||||
return hints
|
||||
|
||||
@staticmethod
|
||||
def format_verbose(
|
||||
status: str,
|
||||
error_count: int,
|
||||
warning_count: int,
|
||||
xcresult_id: str,
|
||||
errors: list[dict] | None = None,
|
||||
warnings: list[dict] | None = None,
|
||||
test_info: dict | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Format verbose output with error/warning details.
|
||||
|
||||
Args:
|
||||
status: Build status
|
||||
error_count: Error count
|
||||
warning_count: Warning count
|
||||
xcresult_id: XCResult ID
|
||||
errors: Optional error list
|
||||
warnings: Optional warning list
|
||||
test_info: Optional test results
|
||||
|
||||
Returns:
|
||||
Verbose formatted output
|
||||
"""
|
||||
lines = []
|
||||
|
||||
# Header
|
||||
if test_info:
|
||||
total = test_info.get("total", 0)
|
||||
passed = test_info.get("passed", 0)
|
||||
failed = test_info.get("failed", 0)
|
||||
duration = test_info.get("duration", 0.0)
|
||||
|
||||
test_status = "PASS" if failed == 0 else "FAIL"
|
||||
lines.append(f"Tests: {test_status}")
|
||||
lines.append(f" Total: {total}")
|
||||
lines.append(f" Passed: {passed}")
|
||||
lines.append(f" Failed: {failed}")
|
||||
lines.append(f" Duration: {duration:.1f}s")
|
||||
else:
|
||||
lines.append(f"Build: {status}")
|
||||
|
||||
lines.append(f"XCResult: {xcresult_id}")
|
||||
lines.append("")
|
||||
|
||||
# Errors
|
||||
if errors and len(errors) > 0:
|
||||
lines.append(OutputFormatter.format_errors(errors, limit=5))
|
||||
lines.append("")
|
||||
|
||||
# Warnings
|
||||
if warnings and len(warnings) > 0:
|
||||
lines.append(OutputFormatter.format_warnings(warnings, limit=5))
|
||||
lines.append("")
|
||||
|
||||
# Summary
|
||||
lines.append(f"Summary: {error_count} errors, {warning_count} warnings")
|
||||
|
||||
return "\n".join(lines)
|
||||
404
.agents/skills/ios-simulator-skill/scripts/xcode/xcresult.py
Normal file
404
.agents/skills/ios-simulator-skill/scripts/xcode/xcresult.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
XCResult bundle parser.
|
||||
|
||||
Extracts structured data from xcresult bundles using xcresulttool.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class XCResultParser:
|
||||
"""
|
||||
Parse xcresult bundles to extract build/test data.
|
||||
|
||||
Uses xcresulttool to extract structured JSON data from Apple's
|
||||
xcresult bundle format.
|
||||
"""
|
||||
|
||||
def __init__(self, xcresult_path: Path, stderr: str = ""):
|
||||
"""
|
||||
Initialize parser.
|
||||
|
||||
Args:
|
||||
xcresult_path: Path to xcresult bundle
|
||||
stderr: Optional stderr output for fallback parsing
|
||||
"""
|
||||
self.xcresult_path = xcresult_path
|
||||
self.stderr = stderr
|
||||
|
||||
if xcresult_path and not xcresult_path.exists():
|
||||
raise FileNotFoundError(f"XCResult bundle not found: {xcresult_path}")
|
||||
|
||||
def get_build_results(self) -> dict | None:
|
||||
"""
|
||||
Get build results as JSON.
|
||||
|
||||
Returns:
|
||||
Parsed JSON dict or None on error
|
||||
"""
|
||||
return self._run_xcresulttool(["get", "build-results"])
|
||||
|
||||
def get_test_results(self) -> dict | None:
|
||||
"""
|
||||
Get test results summary as JSON.
|
||||
|
||||
Returns:
|
||||
Parsed JSON dict or None on error
|
||||
"""
|
||||
return self._run_xcresulttool(["get", "test-results", "summary"])
|
||||
|
||||
def get_build_log(self) -> str | None:
|
||||
"""
|
||||
Get build log as plain text.
|
||||
|
||||
Returns:
|
||||
Build log string or None on error
|
||||
"""
|
||||
result = self._run_xcresulttool(["get", "log", "--type", "build"], parse_json=False)
|
||||
return result if result else None
|
||||
|
||||
def count_issues(self) -> tuple[int, int]:
|
||||
"""
|
||||
Count errors and warnings from build results.
|
||||
|
||||
Returns:
|
||||
Tuple of (error_count, warning_count)
|
||||
"""
|
||||
error_count = 0
|
||||
warning_count = 0
|
||||
|
||||
build_results = self.get_build_results()
|
||||
|
||||
if build_results:
|
||||
try:
|
||||
# Try top-level errors/warnings first (newer xcresult format)
|
||||
if "errors" in build_results and isinstance(build_results.get("errors"), list):
|
||||
error_count = len(build_results["errors"])
|
||||
if "warnings" in build_results and isinstance(build_results.get("warnings"), list):
|
||||
warning_count = len(build_results["warnings"])
|
||||
|
||||
# If not found, try legacy format: actions[0].buildResult.issues
|
||||
if error_count == 0 and warning_count == 0:
|
||||
actions = build_results.get("actions", {}).get("_values", [])
|
||||
if actions:
|
||||
build_result = actions[0].get("buildResult", {})
|
||||
issues = build_result.get("issues", {})
|
||||
|
||||
# Count errors
|
||||
error_summaries = issues.get("errorSummaries", {}).get("_values", [])
|
||||
error_count = len(error_summaries)
|
||||
|
||||
# Count warnings
|
||||
warning_summaries = issues.get("warningSummaries", {}).get("_values", [])
|
||||
warning_count = len(warning_summaries)
|
||||
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
print(f"Warning: Could not parse issue counts from xcresult: {e}", file=sys.stderr)
|
||||
|
||||
# If no errors found in xcresult but stderr available, count stderr errors
|
||||
if error_count == 0 and self.stderr:
|
||||
stderr_errors = self._parse_stderr_errors()
|
||||
error_count = len(stderr_errors)
|
||||
|
||||
return (error_count, warning_count)
|
||||
|
||||
def get_errors(self) -> list[dict]:
|
||||
"""
|
||||
Get detailed error information.
|
||||
|
||||
Returns:
|
||||
List of error dicts with message, file, line info
|
||||
"""
|
||||
build_results = self.get_build_results()
|
||||
errors = []
|
||||
|
||||
# Try to get errors from xcresult
|
||||
if build_results:
|
||||
try:
|
||||
# Try top-level errors first (newer xcresult format)
|
||||
if "errors" in build_results and isinstance(build_results.get("errors"), list):
|
||||
for error in build_results["errors"]:
|
||||
errors.append(
|
||||
{
|
||||
"message": error.get("message", "Unknown error"),
|
||||
"type": error.get("issueType", "error"),
|
||||
"location": self._extract_location_from_url(error.get("sourceURL")),
|
||||
}
|
||||
)
|
||||
|
||||
# If not found, try legacy format: actions[0].buildResult.issues
|
||||
if not errors:
|
||||
actions = build_results.get("actions", {}).get("_values", [])
|
||||
if actions:
|
||||
build_result = actions[0].get("buildResult", {})
|
||||
issues = build_result.get("issues", {})
|
||||
error_summaries = issues.get("errorSummaries", {}).get("_values", [])
|
||||
|
||||
for error in error_summaries:
|
||||
errors.append(
|
||||
{
|
||||
"message": error.get("message", {}).get(
|
||||
"_value", "Unknown error"
|
||||
),
|
||||
"type": error.get("issueType", {}).get("_value", "error"),
|
||||
"location": self._extract_location(error),
|
||||
}
|
||||
)
|
||||
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
print(f"Warning: Could not parse errors from xcresult: {e}", file=sys.stderr)
|
||||
|
||||
# If no errors found in xcresult but stderr available, parse stderr
|
||||
if not errors and self.stderr:
|
||||
errors = self._parse_stderr_errors()
|
||||
|
||||
return errors
|
||||
|
||||
def get_warnings(self) -> list[dict]:
|
||||
"""
|
||||
Get detailed warning information.
|
||||
|
||||
Returns:
|
||||
List of warning dicts with message, file, line info
|
||||
"""
|
||||
build_results = self.get_build_results()
|
||||
if not build_results:
|
||||
return []
|
||||
|
||||
warnings = []
|
||||
|
||||
try:
|
||||
# Try top-level warnings first (newer xcresult format)
|
||||
if "warnings" in build_results and isinstance(build_results.get("warnings"), list):
|
||||
for warning in build_results["warnings"]:
|
||||
warnings.append(
|
||||
{
|
||||
"message": warning.get("message", "Unknown warning"),
|
||||
"type": warning.get("issueType", "warning"),
|
||||
"location": self._extract_location_from_url(warning.get("sourceURL")),
|
||||
}
|
||||
)
|
||||
|
||||
# If not found, try legacy format: actions[0].buildResult.issues
|
||||
if not warnings:
|
||||
actions = build_results.get("actions", {}).get("_values", [])
|
||||
if not actions:
|
||||
return []
|
||||
|
||||
build_result = actions[0].get("buildResult", {})
|
||||
issues = build_result.get("issues", {})
|
||||
warning_summaries = issues.get("warningSummaries", {}).get("_values", [])
|
||||
|
||||
for warning in warning_summaries:
|
||||
warnings.append(
|
||||
{
|
||||
"message": warning.get("message", {}).get("_value", "Unknown warning"),
|
||||
"type": warning.get("issueType", {}).get("_value", "warning"),
|
||||
"location": self._extract_location(warning),
|
||||
}
|
||||
)
|
||||
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
print(f"Warning: Could not parse warnings: {e}", file=sys.stderr)
|
||||
|
||||
return warnings
|
||||
|
||||
def _extract_location(self, issue: dict) -> dict:
|
||||
"""
|
||||
Extract file location from issue.
|
||||
|
||||
Args:
|
||||
issue: Issue dict from xcresult
|
||||
|
||||
Returns:
|
||||
Location dict with file, line, column
|
||||
"""
|
||||
location = {"file": None, "line": None, "column": None}
|
||||
|
||||
try:
|
||||
doc_location = issue.get("documentLocationInCreatingWorkspace", {})
|
||||
location["file"] = doc_location.get("url", {}).get("_value")
|
||||
location["line"] = doc_location.get("startingLineNumber", {}).get("_value")
|
||||
location["column"] = doc_location.get("startingColumnNumber", {}).get("_value")
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
|
||||
return location
|
||||
|
||||
def _extract_location_from_url(self, source_url: str | None) -> dict:
|
||||
"""
|
||||
Extract file location from sourceURL (newer xcresult format).
|
||||
|
||||
Args:
|
||||
source_url: Source URL like "file:///path/to/file.swift#StartingLineNumber=134&..."
|
||||
|
||||
Returns:
|
||||
Location dict with file, line, column
|
||||
"""
|
||||
location = {"file": None, "line": None, "column": None}
|
||||
|
||||
if not source_url:
|
||||
return location
|
||||
|
||||
try:
|
||||
# Split URL and fragment
|
||||
if "#" in source_url:
|
||||
file_part, fragment = source_url.split("#", 1)
|
||||
|
||||
# Extract file path
|
||||
location["file"] = file_part.replace("file://", "")
|
||||
|
||||
# Parse fragment parameters
|
||||
params = {}
|
||||
for param in fragment.split("&"):
|
||||
if "=" in param:
|
||||
key, value = param.split("=", 1)
|
||||
params[key] = value
|
||||
|
||||
# Extract line and column
|
||||
location["line"] = (
|
||||
int(params.get("StartingLineNumber", 0)) + 1
|
||||
if "StartingLineNumber" in params
|
||||
else None
|
||||
)
|
||||
location["column"] = (
|
||||
int(params.get("StartingColumnNumber", 0)) + 1
|
||||
if "StartingColumnNumber" in params
|
||||
else None
|
||||
)
|
||||
else:
|
||||
# No fragment, just file path
|
||||
location["file"] = source_url.replace("file://", "")
|
||||
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
return location
|
||||
|
||||
def _run_xcresulttool(self, args: list[str], parse_json: bool = True) -> Any | None:
|
||||
"""
|
||||
Run xcresulttool command.
|
||||
|
||||
Args:
|
||||
args: Command arguments (after 'xcresulttool')
|
||||
parse_json: Whether to parse output as JSON
|
||||
|
||||
Returns:
|
||||
Parsed JSON dict, plain text, or None on error
|
||||
"""
|
||||
if not self.xcresult_path:
|
||||
return None
|
||||
|
||||
cmd = ["xcrun", "xcresulttool"] + args + ["--path", str(self.xcresult_path)]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
|
||||
if parse_json:
|
||||
return json.loads(result.stdout)
|
||||
return result.stdout
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error running xcresulttool: {e}", file=sys.stderr)
|
||||
print(f"stderr: {e.stderr}", file=sys.stderr)
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error parsing JSON from xcresulttool: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
def _parse_stderr_errors(self) -> list[dict]:
|
||||
"""
|
||||
Parse common errors from stderr output as fallback.
|
||||
|
||||
Returns:
|
||||
List of error dicts parsed from stderr
|
||||
"""
|
||||
errors = []
|
||||
|
||||
if not self.stderr:
|
||||
return errors
|
||||
|
||||
# Pattern 0: Swift/Clang compilation errors (e.g., "/path/file.swift:135:59: error: message")
|
||||
compilation_error_pattern = (
|
||||
r"^(?P<file>[^:]+):(?P<line>\d+):(?P<column>\d+):\s*error:\s*(?P<message>.+?)$"
|
||||
)
|
||||
for match in re.finditer(compilation_error_pattern, self.stderr, re.MULTILINE):
|
||||
errors.append(
|
||||
{
|
||||
"message": match.group("message").strip(),
|
||||
"type": "compilation",
|
||||
"location": {
|
||||
"file": match.group("file"),
|
||||
"line": int(match.group("line")),
|
||||
"column": int(match.group("column")),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Pattern 1: xcodebuild top-level errors (e.g., "xcodebuild: error: Unable to find...")
|
||||
xcodebuild_error_pattern = r"xcodebuild:\s*error:\s*(?P<message>.*?)(?:\n\n|\Z)"
|
||||
for match in re.finditer(xcodebuild_error_pattern, self.stderr, re.DOTALL):
|
||||
message = match.group("message").strip()
|
||||
# Clean up multi-line messages
|
||||
message = " ".join(line.strip() for line in message.split("\n") if line.strip())
|
||||
errors.append(
|
||||
{
|
||||
"message": message,
|
||||
"type": "build",
|
||||
"location": {"file": None, "line": None, "column": None},
|
||||
}
|
||||
)
|
||||
|
||||
# Pattern 2: Provisioning profile errors
|
||||
provisioning_pattern = r"error:.*?provisioning profile.*?(?:doesn't|does not|cannot).*?(?P<message>.*?)(?:\n|$)"
|
||||
for match in re.finditer(provisioning_pattern, self.stderr, re.IGNORECASE):
|
||||
errors.append(
|
||||
{
|
||||
"message": f"Provisioning profile error: {match.group('message').strip()}",
|
||||
"type": "provisioning",
|
||||
"location": {"file": None, "line": None, "column": None},
|
||||
}
|
||||
)
|
||||
|
||||
# Pattern 3: Code signing errors
|
||||
signing_pattern = r"error:.*?(?:code sign|signing).*?(?P<message>.*?)(?:\n|$)"
|
||||
for match in re.finditer(signing_pattern, self.stderr, re.IGNORECASE):
|
||||
errors.append(
|
||||
{
|
||||
"message": f"Code signing error: {match.group('message').strip()}",
|
||||
"type": "signing",
|
||||
"location": {"file": None, "line": None, "column": None},
|
||||
}
|
||||
)
|
||||
|
||||
# Pattern 4: Generic compilation errors (but not if already captured)
|
||||
if not errors:
|
||||
generic_error_pattern = r"^(?:\*\*\s)?(?:error|❌):\s*(?P<message>.*?)(?:\n|$)"
|
||||
for match in re.finditer(generic_error_pattern, self.stderr, re.MULTILINE):
|
||||
message = match.group("message").strip()
|
||||
errors.append(
|
||||
{
|
||||
"message": message,
|
||||
"type": "build",
|
||||
"location": {"file": None, "line": None, "column": None},
|
||||
}
|
||||
)
|
||||
|
||||
# Pattern 5: Specific "No profiles" error
|
||||
if "No profiles for" in self.stderr:
|
||||
no_profile_pattern = r"No profiles for '(?P<bundle_id>.*?)' were found"
|
||||
for match in re.finditer(no_profile_pattern, self.stderr):
|
||||
errors.append(
|
||||
{
|
||||
"message": f"No provisioning profile found for bundle ID '{match.group('bundle_id')}'",
|
||||
"type": "provisioning",
|
||||
"location": {"file": None, "line": None, "column": None},
|
||||
}
|
||||
)
|
||||
|
||||
return errors
|
||||
Reference in New Issue
Block a user