diff --git a/CHANGELOG.md b/CHANGELOG.md index dae332aa7..ce689a600 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Fixed `suppressWarnings` being ignored in settled build, build-run, and test output. The flag was honored only while streaming, so warnings still reached the final MCP tool response ([#447](https://github.com/getsentry/XcodeBuildMCP/issues/447)). - Fixed iOS scaffold orientation and device-family settings, LLDB command isolation and argument escaping, run-destination parsing without an active scheme, concurrent working-directory mutations, blocking physical-device name lookup, and unverified `xcodemake` downloads ([#459](https://github.com/getsentry/XcodeBuildMCP/issues/459)). - Fixed simulator UI launching and keyboard controls to prefer Xcode 27's Device Hub when available, with Simulator.app as the legacy fallback. +- Fixed incremental `xcodemake` builds when DerivedData uses an absolute path by updating the pinned wrapper and delegating Makefile reuse to it so its argument and freshness checks are always applied ([#466](https://github.com/getsentry/XcodeBuildMCP/issues/466)). ## [2.6.2] diff --git a/src/utils/__tests__/build-utils-xcodemake.test.ts b/src/utils/__tests__/build-utils-xcodemake.test.ts new file mode 100644 index 000000000..5d7b0a484 --- /dev/null +++ b/src/utils/__tests__/build-utils-xcodemake.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; + +const { executeXcodemakeCommandMock } = vi.hoisted(() => ({ + executeXcodemakeCommandMock: vi.fn(), +})); + +vi.mock('../xcodemake.ts', () => ({ + isXcodemakeEnabled: () => true, + isXcodemakeAvailable: () => Promise.resolve(true), + executeXcodemakeCommand: executeXcodemakeCommandMock, +})); + +import { executeXcodeBuildCommand } from '../build-utils.ts'; +import { XcodePlatform } from '../xcode.ts'; + +describe('build-utils xcodemake lifecycle', () => { + let projectDirectory: string; + + beforeEach(() => { + projectDirectory = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-xcodemake-')); + writeFileSync(path.join(projectDirectory, 'Makefile'), 'all:\n\t@true\n'); + executeXcodemakeCommandMock.mockResolvedValue({ success: true, output: 'BUILD SUCCEEDED' }); + }); + + afterEach(() => { + rmSync(projectDirectory, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + it('delegates existing Makefile validation to xcodemake for external DerivedData', async () => { + const workspacePath = path.join(projectDirectory, 'MyWorkspace.xcworkspace'); + const derivedDataPath = + '/Users/developer/Library/Developer/XcodeBuildMCP/DerivedData/MyWorkspace-57a542dedf16'; + const executorCall = vi.fn(); + const executor = createMockExecutor({ onExecute: executorCall }); + + const result = await executeXcodeBuildCommand( + { + scheme: 'MyScheme', + configuration: 'Debug', + workspacePath, + derivedDataPath, + }, + { + platform: XcodePlatform.iOSSimulator, + simulatorId: 'SIMULATOR-UDID', + logPrefix: 'iOS Simulator Build', + }, + false, + 'build', + executor, + ); + + expect(result.isError).toBeFalsy(); + expect(executorCall).not.toHaveBeenCalled(); + expect(executeXcodemakeCommandMock).toHaveBeenCalledWith( + projectDirectory, + [ + '-workspace', + workspacePath, + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=iOS Simulator,id=SIMULATOR-UDID', + '-collect-test-diagnostics', + 'never', + '-derivedDataPath', + derivedDataPath, + 'build', + ], + 'iOS Simulator Build', + ); + }); + + it('uses the current working directory when no project or workspace path is provided', async () => { + const derivedDataPath = + '/Users/developer/Library/Developer/XcodeBuildMCP/DerivedData/MyScheme-57a542dedf16'; + const executorCall = vi.fn(); + const executor = createMockExecutor({ onExecute: executorCall }); + + const result = await executeXcodeBuildCommand( + { + scheme: 'MyScheme', + derivedDataPath, + }, + { + platform: XcodePlatform.iOSSimulator, + simulatorId: 'SIMULATOR-UDID', + logPrefix: 'iOS Simulator Build', + }, + false, + 'build', + executor, + ); + + expect(result.isError).toBeFalsy(); + expect(executorCall).not.toHaveBeenCalled(); + expect(executeXcodemakeCommandMock).toHaveBeenCalledWith( + process.cwd(), + expect.arrayContaining(['-derivedDataPath', derivedDataPath]), + 'iOS Simulator Build', + ); + }); +}); diff --git a/src/utils/__tests__/fixtures/xcodemake/README.md b/src/utils/__tests__/fixtures/xcodemake/README.md new file mode 100644 index 000000000..f230d46da --- /dev/null +++ b/src/utils/__tests__/fixtures/xcodemake/README.md @@ -0,0 +1,9 @@ +# Pinned xcodemake test data + +`xcodemake-75f47d4b69c1604cb886ab37d348c2c245d18329` is an exact, unmodified copy of +[`cameroncooke/xcodemake` at commit `75f47d4b69c1604cb886ab37d348c2c245d18329`](https://github.com/cameroncooke/xcodemake/blob/75f47d4b69c1604cb886ab37d348c2c245d18329/xcodemake). + +SHA-256: `0934f784661f8b295f51064b8e94659c986ca25affc5062f20d5210ae0e25201` + +The wrapper regression test verifies this checksum against the production pin before execution. Do +not edit the fixture independently of `XCODEMAKE_COMMIT` and `XCODEMAKE_SHA256`. diff --git a/src/utils/__tests__/fixtures/xcodemake/xcodemake-75f47d4b69c1604cb886ab37d348c2c245d18329 b/src/utils/__tests__/fixtures/xcodemake/xcodemake-75f47d4b69c1604cb886ab37d348c2c245d18329 new file mode 100644 index 000000000..3e0e09326 --- /dev/null +++ b/src/utils/__tests__/fixtures/xcodemake/xcodemake-75f47d4b69c1604cb886ab37d348c2c245d18329 @@ -0,0 +1,605 @@ +#!/usr/bin/env perl -w +# +# "xcodemake" +# +# Derived from: https://github.com/johnno1962/xcodemake +# +# A short script to convert xcodebuild output into a Makefile. +# Once a Makefile has been generated you can use the much +# faster "make" command for most builds instead of launching +# the more ponderous xcodebuild for each iteration. Sure, +# this could be rewritten in python and use ninja instead. +# +# xcodebuild re-run if its arguments change or make fails or project modified. +# Makefile re-generated if script modified or xcodebuild recaptured. +# + +use IO::File; +use strict; +use JSON::PP; +use Digest::MD5 qw(md5_hex); + +my @original_ARGV = @ARGV; + +# --- Global Utility Functions --- +sub escape { + # Escape characters in $_[1] listed in $_[0] with a backslash + $_[1] =~ s/([$_[0]])/\\$1/g; +} +sub unescape { + # Unescape characters in $_[1] listed in $_[0] preceded by a backslash + $_[1] =~ s/\\([$_[0]])/$1/g; +} + +# escape literal '$' for make --> '$$' +sub dollarEscape { + # $_[0] is the variable passed by reference implicitly + $_[0] =~ s/\$/\$\$/g; +} +# escape characters sensitive to the shell/make for file paths +sub shellEscape { + # $_[0] is the variable passed by reference implicitly + escape("()#&\$", $_[0]); # Escape shell metacharacters & Make '$' + # Escape spaces for shell commands separately + $_[0] =~ s/ /\\ /g; +} + +sub hasConfigurationArgument { + return scalar grep { /^--?(?:config|configuration)$/ } @_; +} + +sub fileContainsLiteral { + my ($file, $literal) = @_; + open my $fh, "<", $file or return 0; + while (my $line = <$fh>) { + if (index($line, $literal) != -1) { + close $fh; + return 1; + } + } + close $fh; + return 0; +} + +# --- Global Variables --- +my $log_tag = join "_", ("xcodemake", @original_ARGV); +$log_tag =~ s{[/:]+}{_}g; +$log_tag =~ s{[^[:alnum:]._+=,@ -]+}{_}g; +$log_tag =~ s{\s+}{_}g; +$log_tag =~ s{_+}{_}g; +$log_tag =~ s{^_+|_+$}{}g; +$log_tag ||= "xcodemake"; +my $max_log_name_length = 150; +my $log_suffix = "-" . md5_hex(join "\0", @original_ARGV) . ".log"; +$log_tag = substr($log_tag, 0, $max_log_name_length - length($log_suffix)); +$log_tag =~ s{_+$}{}; +my $log = ($log_tag || "xcodemake") . $log_suffix; +my $make = "Makefile"; + +my $xcodebuild = ($ENV{DEVELOPER_BIN_DIR}||'/usr/bin')."/xcodebuild"; +my $archs = $ENV{ARCHS}||'arm64'; +my $has_config_in_original = hasConfigurationArgument(@original_ARGV); + +my $variant = "$xcodebuild ARCHS=$archs @original_ARGV"; +$variant .= " -config Debug" unless $has_config_in_original; + +my $builddb = ($ENV{OBJROOT}||'/tmp')."/XCBuildData/build.db"; + +my $EXIT_SUCCESS = 0; +my ($LOG, $fileArg); # $LOG will be lexical to generateMakefile + +# regex for file path argument (handles spaces escaped with backslash) +my $notSpace = "[^\\\\\\s]"; +$fileArg = "$notSpace+(?:\\\\.$notSpace*)*"; + +# --- Main Logic --- + +# Recapture xcodebuild output if paramaters change or project has been edited +captureXcodebuild() if ! -f $log || `find . -name project.pbxproj -newer '$log'`; + +# Regenerate Makefile if the script, xcodebuild path, or arguments changed. +generateMakefile() if ! -f $make || -M $0 < -M $make || !fileContainsLiteral($make, $variant); + +exit $EXIT_SUCCESS if $EXIT_SUCCESS == system "make"; + +rename $builddb, $builddb.".save" if -f $builddb; + +# If make failed, try running xcodebuild directly. If it succeeds, regenerate and try make again. +# Prepare command array for direct xcodebuild run (needs env) +my @direct_build_cmd_parts = ($xcodebuild); +# Use the original arguments captured at the start +my @direct_build_args = @original_ARGV; +push @direct_build_args, ('-config', 'Debug') unless $has_config_in_original; +my @direct_build_cmd = (@direct_build_cmd_parts, @direct_build_args); + +my $direct_build_status; +{ + local $ENV{ARCHS} = $archs; # Set environment for direct build too + $direct_build_status = system(@direct_build_cmd); +} + +if ($direct_build_status == $EXIT_SUCCESS) { + captureXcodebuild(); # Recapture if direct build worked + generateMakefile(); + exit $EXIT_SUCCESS if $EXIT_SUCCESS == system "make"; # Try make again +} + +rename $builddb.".save", $builddb if -f $builddb.".save"; +exit !$EXIT_SUCCESS; # Exit with failure if direct build or second make failed + +# --- Subroutines --- + +sub captureXcodebuild { + # Move Xcode's build database to one side + rename $builddb, $builddb.".save" if -f $builddb; + + # --- Prepare Arguments for List system() --- + # Simply use xcodebuild directly, inheriting the environment + my @base_cmd_parts = ($xcodebuild); + + # Use the *original* @ARGV captured at the script start + my @cmd_args = @original_ARGV; + # Add default -config Debug if necessary *to the list* + # Check original args for all possible configuration argument formats + my $has_config = hasConfigurationArgument(@original_ARGV); + push @cmd_args, ('-config', 'Debug') unless $has_config; + + + # Handle -resultBundlePath removal for the command array + my $rm_cmd; + # Use the potentially modified $variant string for matching here + if (my ($bundle) = $variant =~ /-resultBundlePath ($fileArg)/) { + my $rm_bundle = $bundle; + unescape("'\\\\'", $rm_bundle); + $rm_cmd = ['rm', '-rf', $rm_bundle]; + } + + # --- Execute Commands --- + # 1. Optional: Remove result bundle + if ($rm_cmd) { + warn "Executing: @$rm_cmd\n"; + system(@$rm_cmd) == 0 or warn "Warning: rm command failed: \$? = $?\n"; + } + + # --- Environment for xcodebuild --- + # We need ARCHS=$archs in the environment for the child process + my %child_env; + $child_env{ARCHS} = $archs; + + # 2. Execute Clean Command + my @clean_cmd = (@base_cmd_parts, @cmd_args, 'clean'); + warn "Executing clean: @clean_cmd\n"; + my $clean_status; + { + # Set environment locally for this block + local $ENV{ARCHS} = $child_env{ARCHS}; + $clean_status = system(@clean_cmd); + } + if ($clean_status != $EXIT_SUCCESS) { + # Exit status calculation needs $? which was set by system() + my $clean_raw_status = $?; + my $clean_exit_code = $clean_raw_status >> 8; + my $clean_signal = $clean_raw_status & 0x7F; + warn "Error: xcodebuild clean failed: exit code=$clean_exit_code, signal=$clean_signal, raw(\$?)=$clean_raw_status\n"; + rename $builddb.".save", $builddb if -f $builddb.".save"; + exit !$EXIT_SUCCESS; # Exit with a non-zero status + } + + # 3. Execute Build Command and Capture Output + my @build_cmd = (@base_cmd_parts, @cmd_args); + warn "Executing build and capturing output: @build_cmd\n"; + + my $build_pid; + my $build_fh; + { + # Set environment locally for the fork/exec done by open() + local $ENV{ARCHS} = $child_env{ARCHS}; + $build_pid = open($build_fh, "-|", @build_cmd); + } + unless (defined $build_pid) { # Check if open succeeded + warn "Error: Cannot fork for xcodebuild: $!"; + rename $builddb.".save", $builddb if -f $builddb.".save"; + exit !$EXIT_SUCCESS; # Exit with a non-zero status + } + + # Open log file for writing + my $log_fh; + unless (open $log_fh, ">", $log) { + warn "Error: Cannot open log file $log for writing: $!"; + # Need to ensure we don't leave the child process running + eval { close($build_fh); }; # Try to close the pipe + waitpid($build_pid, 0) if $build_pid > 0; # Wait if pid is valid + rename $builddb.".save", $builddb if -f $builddb.".save"; + exit !$EXIT_SUCCESS; # Exit with a non-zero status + } + + # Read from build output, write to log and stderr (simulate tee) + while (my $output_line = <$build_fh>) { + print $log_fh $output_line; + print STDERR $output_line; # Print to terminal like tee does + } + + close $log_fh; + # Important: Close the file handle *before* checking status + # Closing the pipe sends EOF to the reader and waits for the process + unless (close($build_fh)) { + # close returns false if the process exited non-zero or the pipe had errors + # $? is set by close() on the pipe! + my $build_status = $?; + my $build_exit_code = $build_status >> 8; + my $build_signal = $build_status & 0x7F; + warn "Error: xcodebuild build command failed (detected by close pipe): exit code=$build_exit_code, signal=$build_signal, raw(\$?)=$build_status\n"; + unlink $log if -f $log; # Remove potentially incomplete log + rename $builddb.".save", $builddb if -f $builddb.".save"; + exit !$EXIT_SUCCESS; # Exit with a non-zero status + } + + # If close($build_fh) succeeded, $? should reflect the child's exit status + # (0 for success, or potentially > 0 if xcodebuild reported success but exited non-zero) + my $build_status = $?; + my $build_exit_code = $build_status >> 8; + my $build_signal = $build_status & 0x7F; + + # Explicitly check for exit code 0 and no signal for success + if ($build_exit_code != $EXIT_SUCCESS || $build_signal != 0) { + warn "Error: xcodebuild build command finished but indicated failure: exit code=$build_exit_code, signal=$build_signal, raw(\$?)=$build_status\n"; + unlink $log if -f $log; # Remove potentially incomplete log + rename $builddb.".save", $builddb if -f $builddb.".save"; + exit !$EXIT_SUCCESS; # Exit with a non-zero status + } + + # Build succeeded! + unlink $make; # Force Makefile regeneration + + rename $builddb.".save", $builddb if -f $builddb.".save"; +} + + +sub generateMakefile { + # Use lexical filehandle for safety + my $log_fh; + unless (open $log_fh, "<", $log) { + die "Could not open $log: $!"; + } + my $make_fh; + unless (open $make_fh, ">", $make) { + die "Could not create $make: $!"; + } + + # Get next line from log + # Using closure to capture $log_fh + my $nextLine = sub { + my $line = <$log_fh>; + return undef unless defined $line; # Return undef on EOF + chomp $line; + return $line; + }; + + # Get next 'cd' line and prepare prefix for make recipe + # Using closure to capture $nextLine + my $nextCD = sub { + my $line = &$nextLine(); + # Check if it's actually a cd line before trying to process + return undef unless defined $line && $line =~ s/^\s*cd //; + # Handle potential '/usr/bin/time' prefix added by the script itself if re-parsing + $line =~ s{^\s*/usr/bin/time\s+}{}; + my $cd = $line; + unescape("^\$'()&", $cd); + escape(" ", $cd); + dollarEscape($cd); + return "\t\tcd $cd\n\t\t"; # Just return the cd line and tabs + }; + + # Extract values for the option (e.g., -o file, -primary-file file) from a command line + # Using closure to capture $fileArg + my $extractOption = sub { + my ($line, $option) = @_; + # Match " option path" where path is captured by $fileArg + my @values = $line =~ m/ $option\s+($fileArg)/g; + # Unescape paths extracted via regex + for (@values) { + unescape("'\\\\'", $_); + } + return @values; + }; + + # --- Makefile Generation --- + print $make_fh <new->utf8; # For decoding JSON output map + + while (defined(my $line = &$nextLine())) { + print $make_fh "# $line\n"; + + # --- CompileC Handling (Objective-C/C/C++) --- + if (my ($object, $source) = $line =~ + /^CompileC ($fileArg) ($fileArg) /) { + my $cd = &$nextCD(); + unless (defined $cd) { + warn "Warning: Expected 'cd' after CompileC line, but not found. Skipping rule for $object.\n"; + next; + } + my $compile_cmd_line; + do { + $compile_cmd_line = &$nextLine(); + last unless defined $compile_cmd_line; # Break if EOF + } while ($compile_cmd_line =~ /^\s*$|response file/); # Skip blank lines/response file info + + unless (defined $compile_cmd_line && $compile_cmd_line !~ /^\s*$/) { + warn "Warning: Expected compile command after 'cd' for $object, but not found. Skipping rule.\n"; + next; + } + + # Prepare paths for Make rule + my $make_target_object = $object; + my $make_dep_source = $source; + unescape("{}()", $make_target_object); + escape("\$& ", $make_target_object); + dollarEscape($make_dep_source); + unescape("'", $make_dep_source); + unescape(" ", $make_dep_source); + escape(" ", $make_dep_source); + + # Add rule to Makefile + print $make_fh "\n$make_target_object: $make_dep_source\n"; + + # Prepare command for recipe + dollarEscape($compile_cmd_line); + + # Touch command needs escaped object path suitable for shell + my $shell_touch_object = $object; + shellEscape($shell_touch_object); + + print $make_fh "$cd$compile_cmd_line && touch $shell_touch_object\n\n"; + $rules{$make_target_object} = 1; # Mark object as known (using Make-escaped form) + + # --- SwiftDriver Handling (Xcode 16.3+) --- + } elsif ($line =~ /^SwiftDriver .* com\.apple\.xcode\.tools\.swift\.compiler/) { + my $cd = &$nextCD(); + unless (defined $cd) { + warn "Warning: Expected 'cd' after SwiftDriver line, but not found. Skipping Swift rules.\n"; + next; + } + + # Get the line containing the actual command invocation + my $build_invocation_line = &$nextLine(); + unless (defined $build_invocation_line) { + warn "Warning: Expected command line after 'cd' for SwiftDriver, but found EOF.\nSkipping Swift rules.\n"; + next; + } + + # Extract the actual command (e.g., /path/to/swiftc ...) by removing the prefix + my $swift_actual_cmd = $build_invocation_line; + unless ($swift_actual_cmd =~ s/^\s*builtin-SwiftDriver\s+--\s+//) { + warn "Warning: Expected 'builtin-SwiftDriver -- ' prefix, but found: $build_invocation_line\nSkipping Swift rules.\n"; + next; + } + # Now $swift_actual_cmd contains "/Applications/.../swiftc ..." + + $swift_actual_cmd =~ s/\s+-use-frontend-parseable-output//; + + # Extract the output file map path *from the original build invocation line* + my ($output_map_file) = $build_invocation_line =~ /-output-file-map\s+($fileArg)/; + unless (defined $output_map_file) { + # Fallback: check the extracted swiftc command itself? Less likely needed. + ($output_map_file) = $swift_actual_cmd =~ /-output-file-map\s+($fileArg)/; + } + unless (defined $output_map_file) { + warn "Warning: Could not find -output-file-map in SwiftDriver line: $build_invocation_line\nSkipping Swift rules.\n"; + next; + } + unescape("'\\\\'", $output_map_file); + + # Read and parse the JSON Output File Map + my $map_content; + my $MAP_FH; + eval { + $MAP_FH = IO::File->new("< $output_map_file") or die "Cannot open output map $output_map_file: $!"; + local $/; # Slurp mode + $map_content = <$MAP_FH>; + close $MAP_FH; + }; + if ($@ || !defined $map_content) { + warn "Warning: Could not read output map file $output_map_file: $@\nSkipping Swift rules.\n"; + next; + } + + my $output_map; + eval { + $output_map = $json_decoder->decode($map_content); + }; + if ($@ || ref $output_map ne 'HASH') { + warn "Warning: Could not decode JSON from output map file $output_map_file: $@\nSkipping Swift rules.\n"; + next; + } + + # Prepare the actual command for the Makefile recipe + my $make_swift_cmd = $swift_actual_cmd; # Use the extracted command + dollarEscape($make_swift_cmd); + + # Generate rules for each source file in the map + foreach my $source (keys %{$output_map}) { + next unless $source =~ /\.swift$/; # Process only Swift source entries + next unless exists $output_map->{$source}{object}; # Ensure object mapping exists + + my $object = $output_map->{$source}{object}; + + # Prepare paths for Make rule + my $make_target_object = $object; + my $make_dep_source = $source; + unescape("{}()", $make_target_object); + escape("\$& ", $make_target_object); + dollarEscape($make_dep_source); + unescape("'", $make_dep_source); + unescape(" ", $make_dep_source); + escape(" ", $make_dep_source); + + # Use make-escaped version for the hash key check + next if $rules{$make_target_object}++; + + print $make_fh "\n$make_target_object: $make_dep_source\n"; + + # Touch command needs object path escaped for shell + my $shell_touch_object = $object; + shellEscape($shell_touch_object); + + # Use the extracted swiftc command for the recipe + print $make_fh "$cd$make_swift_cmd && touch $shell_touch_object\n\n"; + } + + # --- SwiftCompile Handling (Older Xcode / Fallback) --- + } elsif ($line =~ /^SwiftCompile \w+ \w+/) { + my $cd = &$nextCD(); + unless (defined $cd) { + # Might be an informational line without a command, skip silently + next; + } + my $compile_cmd_line = &$nextLine(); + # Check if it looks like a real frontend command + unless (defined $compile_cmd_line && $compile_cmd_line =~ m/swift-frontend.*-c /) { + # Likely an informational line or unexpected format + next; + } + + # Attempt to extract options like the original script + my @sources = &$extractOption($compile_cmd_line, "-primary-file"); + my @objects = &$extractOption($compile_cmd_line, "-o"); + + unless (@sources && @objects && $#sources == $#objects) { + warn "Warning: Could not extract matching -primary-file/-o pairs from SwiftCompile command. This might be an unsupported format or whole module optimization.\nCMD: $compile_cmd_line\n"; + next; # Skip if we can't get source/object pairs + } + + # Prepare command for recipe + dollarEscape($compile_cmd_line); + + foreach my $i (0..$#sources) { + # Prepare paths for Make rule + my $make_target_object = $objects[$i]; + my $make_dep_source = $sources[$i]; + unescape("{}()", $make_target_object); + escape("\$& ", $make_target_object); + dollarEscape($make_dep_source); + unescape("'", $make_dep_source); + unescape(" ", $make_dep_source); + escape(" ", $make_dep_source); + + # Use make-escaped version for hash key check + next if $rules{$make_target_object}++; + + print $make_fh "\n$make_target_object: $make_dep_source\n"; + + # Touch command needs object path escaped for shell + my $shell_touch_object = $objects[$i]; # Use original path here + shellEscape($shell_touch_object); + + print $make_fh "$cd$compile_cmd_line && touch $shell_touch_object\n\n"; + } + + # --- Ld Handling (Linking) --- + } elsif (my ($executable) = $line =~ /^Ld ($fileArg) /) { + my $cd = &$nextCD(); + unless (defined $cd) { + warn "Warning: Expected 'cd' after Ld line for $executable, but not found. Trying to continue.\n"; + $cd = "\t\t"; # Use empty cd prefix + } + + my $link_cmd_line = &$nextLine(); + unless (defined $link_cmd_line && $link_cmd_line !~ /^\s*$/) { + warn "Warning: Expected link command after 'cd' for $executable, but not found. Skipping rule.\n"; + next; + } + + # Use lookbehind to avoid matching -Xlinker immediately before -filelist + my ($linkfile) = $link_cmd_line =~ /(?new("< $linkfile") or die "Cannot open link file list $linkfile: $!"; + }; + unless (defined $OBJS_FH) { + warn "Warning: Could not open link file list $linkfile for $executable: $@. Linking without specific object dependencies.\n"; + } else { + # Add object file dependencies if linkfile was read + while (my $object = <$OBJS_FH>) { + chomp $object; + + # Create the canonical, Make-escaped version for hash lookup + my $canonical_object = $object; + unescape("{}()", $canonical_object); + escape("\$& ", $canonical_object); + + if ($rules{$canonical_object}) { # Check if we know how to build this object + # Escape object path for dependency list (spaces are separators) + my $dep_object = $object; + $dep_object =~ s/ /\\ /g; + escape("'", $dep_object); + print $make_fh " $dep_object"; + } else { + # Also check if it's a module object file that might have a rule + # Some objects might not be in %rules if they're from packages + if ($object =~ /\.o$/) { + # Escape object path for dependency list anyway + my $dep_object = $object; + $dep_object =~ s/ /\\ /g; + escape("'", $dep_object); + print $make_fh " $dep_object"; + } + # Optional: Warn about objects in link list not generated by known rules? + # warn "Object $object listed in $linkfile but no rule found to build it."; + } + } + close $OBJS_FH; + } + } + # Finish rule definition and add recipe + print $make_fh "\n$cd$make_link_cmd_line\n\n"; + + # Add executable/dylib to the list for the 'main' target + if ($executable !~ /\.o$/) { + # Use the Make-escaped version for the @linked array + push @linked, $make_target_executable; + } + + # --- codesign / touch Handling --- + } elsif ($line =~ m@^\s+/usr/bin/(codesign|touch)@) { + dollarEscape($line); + push @codesigns, $line; + } + } + + # --- Final 'main' Target --- + print $make_fh "main: @linked\n"; + print $make_fh join "\n", map {"\t$_"} @codesigns; # Use stored codesign/touch lines + print $make_fh "\n"; + + close $make_fh; + close $log_fh; +} diff --git a/src/utils/__tests__/xcodemake-wrapper.test.ts b/src/utils/__tests__/xcodemake-wrapper.test.ts new file mode 100644 index 000000000..30f1aad21 --- /dev/null +++ b/src/utils/__tests__/xcodemake-wrapper.test.ts @@ -0,0 +1,195 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { XCODEMAKE_COMMIT, XCODEMAKE_SHA256 } from '../xcodemake.ts'; + +const FILESYSTEM_TIMESTAMP_TOLERANCE_MS = 1_000; +const PINNED_FIXTURE_PATH = fileURLToPath( + new URL(`./fixtures/xcodemake/xcodemake-${XCODEMAKE_COMMIT}`, import.meta.url), +); + +function writeExecutable(filePath: string, contents: string): void { + writeFileSync(filePath, contents); + chmodSync(filePath, 0o755); +} + +function readLines(filePath: string): string[] { + if (!existsSync(filePath)) { + return []; + } + + const contents = readFileSync(filePath, 'utf8').trim(); + return contents.length > 0 ? contents.split('\n') : []; +} + +describe('pinned xcodemake wrapper lifecycle', () => { + let temporaryDirectory: string; + let projectDirectory: string; + let projectFile: string; + let fakeBinDirectory: string; + let xcodebuildInvocationLog: string; + let makeInvocationLog: string; + + beforeEach(() => { + temporaryDirectory = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-xcodemake-wrapper-')); + projectDirectory = path.join(temporaryDirectory, 'project'); + fakeBinDirectory = path.join(temporaryDirectory, 'bin'); + xcodebuildInvocationLog = path.join(temporaryDirectory, 'xcodebuild-invocations.log'); + makeInvocationLog = path.join(temporaryDirectory, 'make-invocations.log'); + + mkdirSync(path.join(projectDirectory, 'MyWorkspace.xcworkspace'), { recursive: true }); + mkdirSync(fakeBinDirectory, { recursive: true }); + projectFile = path.join(projectDirectory, 'MyWorkspace.xcodeproj', 'project.pbxproj'); + mkdirSync(path.dirname(projectFile), { recursive: true }); + writeFileSync(projectFile, '// test project\n'); + + writeExecutable( + path.join(fakeBinDirectory, 'xcodebuild'), + '#!/bin/sh\nprintf \'%s\\n\' "$*" >> "$XCODEMAKE_TEST_XCODEBUILD_LOG"\n', + ); + writeExecutable( + path.join(fakeBinDirectory, 'make'), + '#!/bin/sh\nprintf \'make\\n\' >> "$XCODEMAKE_TEST_MAKE_LOG"\nattempts=0\nwhile IFS= read -r _; do attempts=$((attempts + 1)); done < "$XCODEMAKE_TEST_MAKE_LOG"\nif [ "${XCODEMAKE_TEST_FAIL_FIRST_MAKE:-0}" = "1" ] && [ "$attempts" -eq 1 ]; then\n exit 1\nfi\n', + ); + }); + + afterEach(() => { + rmSync(temporaryDirectory, { recursive: true, force: true }); + }); + + function runWrapper(arguments_: string[], environment: Record = {}): void { + execFileSync('perl', [PINNED_FIXTURE_PATH, ...arguments_], { + cwd: projectDirectory, + encoding: 'utf8', + stdio: 'pipe', + env: { + ...process.env, + PATH: `${fakeBinDirectory}${path.delimiter}${process.env.PATH ?? ''}`, + DEVELOPER_BIN_DIR: fakeBinDirectory, + OBJROOT: path.join(temporaryDirectory, 'objroot'), + XCODEMAKE_TEST_XCODEBUILD_LOG: xcodebuildInvocationLog, + XCODEMAKE_TEST_MAKE_LOG: makeInvocationLog, + ...environment, + }, + }); + } + + function captureLogs(): string[] { + return readdirSync(projectDirectory).filter( + (fileName) => fileName.startsWith('xcodemake') && fileName.endsWith('.log'), + ); + } + + it('captures and reuses builds while invalidating changed or stale state', () => { + const fixtureChecksum = createHash('sha256') + .update(readFileSync(PINNED_FIXTURE_PATH)) + .digest('hex'); + expect(fixtureChecksum).toBe(XCODEMAKE_SHA256); + + const derivedDataPath = + '/Users/developer/Library/Developer/XcodeBuildMCP/DerivedData/MyWorkspace-57a542dedf16'; + const initialArguments = [ + '-workspace', + 'MyWorkspace.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-derivedDataPath', + derivedDataPath, + 'build', + ]; + + runWrapper(initialArguments); + + const initialXcodebuildInvocations = readLines(xcodebuildInvocationLog); + expect(initialXcodebuildInvocations).toHaveLength(2); + expect(initialXcodebuildInvocations).not.toContainEqual( + expect.stringContaining('-config Debug'), + ); + expect(initialXcodebuildInvocations[0]).toContain(derivedDataPath); + expect(initialXcodebuildInvocations[0]).toMatch(/ clean$/); + expect(initialXcodebuildInvocations[1]).not.toMatch(/ clean$/); + expect(readLines(makeInvocationLog)).toHaveLength(1); + expect(captureLogs()).toHaveLength(1); + expect(readFileSync(path.join(projectDirectory, 'Makefile'), 'utf8')).toContain( + derivedDataPath, + ); + + const makefilePath = path.join(projectDirectory, 'Makefile'); + const futureMakefileTime = new Date(Date.now() + 60_000); + utimesSync(makefilePath, futureMakefileTime, futureMakefileTime); + + runWrapper(initialArguments); + + expect(readLines(xcodebuildInvocationLog)).toHaveLength(2); + expect(readLines(makeInvocationLog)).toHaveLength(2); + expect(captureLogs()).toHaveLength(1); + expect( + Math.abs(statSync(makefilePath).mtimeMs - futureMakefileTime.getTime()), + ).toBeLessThanOrEqual(FILESYSTEM_TIMESTAMP_TOLERANCE_MS); + + const changedArguments = initialArguments.map((argument) => + argument === 'Debug' ? 'Release' : argument, + ); + runWrapper(changedArguments); + + expect(readLines(xcodebuildInvocationLog)).toHaveLength(4); + expect(readLines(makeInvocationLog)).toHaveLength(3); + expect(captureLogs()).toHaveLength(2); + expect(readFileSync(path.join(projectDirectory, 'Makefile'), 'utf8')).toContain( + '-configuration Release', + ); + + const newestCaptureTime = Math.max( + ...captureLogs().map((fileName) => statSync(path.join(projectDirectory, fileName)).mtimeMs), + ); + const newerProjectTime = new Date(newestCaptureTime + 5_000); + utimesSync(projectFile, newerProjectTime, newerProjectTime); + + runWrapper(changedArguments); + + expect(readLines(xcodebuildInvocationLog)).toHaveLength(6); + expect(readLines(makeInvocationLog)).toHaveLength(4); + expect(captureLogs()).toHaveLength(2); + }); + + it('preserves the long configuration option when direct xcodebuild fallback is required', () => { + runWrapper( + [ + '-workspace', + 'MyWorkspace.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Release', + 'build', + ], + { XCODEMAKE_TEST_FAIL_FIRST_MAKE: '1' }, + ); + + const xcodebuildInvocations = readLines(xcodebuildInvocationLog); + expect(xcodebuildInvocations).toHaveLength(5); + expect( + xcodebuildInvocations.every((invocation) => invocation.includes('-configuration Release')), + ).toBe(true); + expect(xcodebuildInvocations).not.toContainEqual(expect.stringContaining('-config Debug')); + expect(xcodebuildInvocations[2]).not.toMatch(/ clean$/); + expect(readLines(makeInvocationLog)).toHaveLength(2); + }); +}); diff --git a/src/utils/__tests__/xcodemake.test.ts b/src/utils/__tests__/xcodemake.test.ts index 796c40640..2142a7dde 100644 --- a/src/utils/__tests__/xcodemake.test.ts +++ b/src/utils/__tests__/xcodemake.test.ts @@ -13,7 +13,6 @@ import { XCODEMAKE_COMMIT, XCODEMAKE_DOWNLOAD_URL, XCODEMAKE_SHA256, - doesMakeLogFileExist, executeXcodemakeCommand, installXcodemake, verifyXcodemakeScript, @@ -57,23 +56,6 @@ describe('executeXcodemakeCommand', () => { }); }); -describe('doesMakeLogFileExist', () => { - it('reads the project directory without mutating process cwd', () => { - const originalCwd = process.cwd(); - const readDirectory = vi.fn(() => ['xcodemake -scheme App.log']); - - const exists = doesMakeLogFileExist( - '/tmp/project', - ['xcodebuild', '-scheme', 'App'], - readDirectory, - ); - - expect(exists).toBe(true); - expect(readDirectory).toHaveBeenCalledWith('/tmp/project'); - expect(process.cwd()).toBe(originalCwd); - }); -}); - describe('xcodemake installer integrity', () => { it('pins the download URL to an exact commit and checksum', () => { expect(XCODEMAKE_COMMIT).toMatch(/^[a-f0-9]{40}$/); diff --git a/src/utils/build-utils.ts b/src/utils/build-utils.ts index 42faba86e..3618a19a0 100644 --- a/src/utils/build-utils.ts +++ b/src/utils/build-utils.ts @@ -2,14 +2,7 @@ import { log } from './logger.ts'; import { XcodePlatform, constructDestinationString } from './xcode.ts'; import type { CommandExecutor, CommandExecOptions } from './command.ts'; import type { SharedBuildParams, PlatformBuildOptions } from '../types/common.ts'; -import { - isXcodemakeEnabled, - isXcodemakeAvailable, - executeXcodemakeCommand, - executeMakeCommand, - doesMakefileExist, - doesMakeLogFileExist, -} from './xcodemake.ts'; +import { isXcodemakeEnabled, isXcodemakeAvailable, executeXcodemakeCommand } from './xcodemake.ts'; import path from 'path'; import os from 'node:os'; import { resolveEffectiveDerivedDataPath } from './derived-data-path.ts'; @@ -89,7 +82,7 @@ export async function executeXcodeBuildCommand( projectPath, }); - let projectDir = ''; + let projectDir = process.cwd(); if (workspacePath) { projectDir = path.dirname(workspacePath); command.push('-workspace', workspacePath); @@ -184,23 +177,12 @@ export async function executeXcodeBuildCommand( let result; if (useXcodemake) { - const makefileExists = doesMakefileExist(projectDir); - log('debug', 'Makefile exists: ' + makefileExists); - - const makeLogFileExists = doesMakeLogFileExist(projectDir, command); - log('debug', 'Makefile log exists: ' + makeLogFileExists); - - if (makefileExists && makeLogFileExists) { - addBuildMessage('ℹ️ Using make for incremental build'); - result = await executeMakeCommand(projectDir, platformOptions.logPrefix); - } else { - addBuildMessage('ℹ️ Generating Makefile with xcodemake (first build may take longer)'); - result = await executeXcodemakeCommand( - projectDir, - command.slice(1), - platformOptions.logPrefix, - ); - } + addBuildMessage('ℹ️ Running incremental build with xcodemake'); + result = await executeXcodemakeCommand( + projectDir, + command.slice(1), + platformOptions.logPrefix, + ); } else { const streamHandlers = pipeline ? { diff --git a/src/utils/xcodemake.ts b/src/utils/xcodemake.ts index 7df6d481e..dce4b4751 100644 --- a/src/utils/xcodemake.ts +++ b/src/utils/xcodemake.ts @@ -1,7 +1,7 @@ import { log } from './logger.ts'; import type { CommandResponse } from './command.ts'; import { getDefaultCommandExecutor } from './command.ts'; -import { existsSync, readdirSync, statSync } from 'fs'; +import { existsSync, statSync } from 'fs'; import { createHash, randomUUID } from 'node:crypto'; import * as path from 'path'; import * as os from 'os'; @@ -10,8 +10,8 @@ import { getConfig } from './config-store.ts'; let overriddenXcodemakePath: string | null = null; -export const XCODEMAKE_COMMIT = '1749682a99794edc257010b3eaa35c39f0f91c50'; -export const XCODEMAKE_SHA256 = 'b84f2e58326a1c009e5349e28895817d2968f2cb655b6a2a7c7c719971007db7'; +export const XCODEMAKE_COMMIT = '75f47d4b69c1604cb886ab37d348c2c245d18329'; +export const XCODEMAKE_SHA256 = '0934f784661f8b295f51064b8e94659c986ca25affc5062f20d5210ae0e25201'; export const XCODEMAKE_DOWNLOAD_URL = `https://raw.githubusercontent.com/cameroncooke/xcodemake/${XCODEMAKE_COMMIT}/xcodemake`; interface XcodemakeInstallerDependencies { @@ -172,40 +172,6 @@ export function doesMakefileExist(projectDir: string): boolean { return existsSync(`${projectDir}/Makefile`); } -type ReadDirectory = (path: string) => string[]; - -export function doesMakeLogFileExist( - projectDir: string, - command: string[], - readDirectory: ReadDirectory = (directory) => readdirSync(directory), -): boolean { - try { - const xcodemakeCommand = ['xcodemake', ...command.slice(1)]; - const escapedCommand = xcodemakeCommand.map((arg) => { - // Remove projectDir from arguments if present at the start - const prefix = projectDir + '/'; - if (arg.startsWith(prefix)) { - return arg.substring(prefix.length); - } - return arg; - }); - const commandString = escapedCommand.join(' '); - const logFileName = `${commandString}.log`; - log('debug', `Checking for Makefile log: ${logFileName} in directory: ${projectDir}`); - - const files = readDirectory(projectDir); - const exists = files.includes(logFileName); - log('debug', `Makefile log ${exists ? 'exists' : 'does not exist'}: ${logFileName}`); - return exists; - } catch (error) { - log( - 'error', - `Error checking for Makefile log: ${error instanceof Error ? error.message : String(error)}`, - ); - return false; - } -} - export async function executeXcodemakeCommand( projectDir: string, buildArgs: string[], @@ -222,11 +188,3 @@ export async function executeXcodemakeCommand( return getDefaultCommandExecutor()(command, logPrefix, false, { cwd: projectDir }); } - -export async function executeMakeCommand( - projectDir: string, - logPrefix: string, -): Promise { - const command = ['make']; - return getDefaultCommandExecutor()(command, logPrefix, false, { cwd: projectDir }); -}