Skip to content

Commit b54cbb4

Browse files
authored
Merge pull request #80 from koriym/issue-79-xcoverage-ergonomics
Fix xcoverage / xback ergonomics for external projects (#79)
2 parents 88787c7 + 2e051ec commit b54cbb4

7 files changed

Lines changed: 486 additions & 15 deletions

File tree

bin/xback

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ function showHelp(): void
1919
echo " --break=SPEC Breakpoint (file.php:line)\n";
2020
echo " --depth=N Maximum stack depth to return (default: 10)\n";
2121
echo " --context=TEXT Context description for AI analysis\n";
22+
echo " --cwd=PATH Run from PATH (target project root)\n";
23+
echo " --php=PATH Use this PHP binary instead of the default 'php'\n";
2224
echo " --help, -h Show this help\n";
2325
echo "\n";
2426
echo "OUTPUT: Structured JSON (schema: xback.json)\n";
@@ -112,6 +114,8 @@ function outputBacktraceJson(string $script, ?string $breakpointStr, string $con
112114
$breakpointStr = null;
113115
$depth = 10;
114116
$context = '';
117+
$cwdOption = null;
118+
$phpOverride = null;
115119
$command = [];
116120
$parseOptions = true;
117121

@@ -140,6 +144,16 @@ function outputBacktraceJson(string $script, ?string $breakpointStr, string $con
140144
continue;
141145
}
142146

147+
if ($parseOptions && str_starts_with($arg, '--cwd=')) {
148+
$cwdOption = substr($arg, 6);
149+
continue;
150+
}
151+
152+
if ($parseOptions && str_starts_with($arg, '--php=')) {
153+
$phpOverride = substr($arg, 6);
154+
continue;
155+
}
156+
143157
if ($parseOptions && ($arg === '--help' || $arg === '-h')) {
144158
showHelp();
145159
}
@@ -153,9 +167,38 @@ function outputBacktraceJson(string $script, ?string $breakpointStr, string $con
153167
showHelp();
154168
}
155169

170+
// Resolve --php against original cwd before chdir.
171+
if ($phpOverride !== null && $phpOverride !== '') {
172+
$resolved = realpath($phpOverride);
173+
if ($resolved === false || !is_executable($resolved)) {
174+
fwrite(STDERR, "Error: --php=$phpOverride not found or not executable\n");
175+
exit(1);
176+
}
177+
$phpOverride = $resolved;
178+
}
179+
180+
if ($cwdOption !== null && $cwdOption !== '') {
181+
if (!is_dir($cwdOption) || !chdir($cwdOption)) {
182+
fwrite(STDERR, "Error: --cwd=$cwdOption is not a directory or chdir failed\n");
183+
exit(1);
184+
}
185+
}
186+
156187
// Detect Docker/Podman/Kubectl commands
157188
$isDockerCommand = \Koriym\XdebugMcp\ContainerHelper::isContainerCommand($command);
158189

190+
// Decide whether the --php override should apply to the local command.
191+
// Keep $command[0] as the literal 'php' so DebugServer's strict-equality
192+
// check still selects the local-PHP execution branch; the actual override
193+
// path is plumbed through DebugServer's `phpBinary` option below.
194+
$applyPhpOverride = (
195+
!$isDockerCommand
196+
&& $phpOverride !== null
197+
&& $phpOverride !== ''
198+
&& !empty($command)
199+
&& \Koriym\XdebugMcp\XdebugRunner::isPhpBinary($command[0])
200+
);
201+
159202
// Parse breakpoint now that we know if it's a Docker command
160203
if ($breakpointStr !== null) {
161204
$breakpoint = parseBreakpoint($breakpointStr, $isDockerCommand);
@@ -206,6 +249,10 @@ function outputBacktraceJson(string $script, ?string $breakpointStr, string $con
206249
'maxSteps' => 1,
207250
];
208251

252+
if ($applyPhpOverride) {
253+
$options['phpBinary'] = $phpOverride;
254+
}
255+
209256
// Capture DebugServer output
210257
ob_start();
211258
$server = new DebugServer($targetScript, Constants::XDEBUG_DEBUG_PORT, null, $options, true);

bin/xcoverage

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,17 @@ if (in_array('--help', $argv) || in_array('-h', $argv)) {
3232
echo " --raw Use raw Xdebug coverage (ignores @codeCoverageIgnore)\n";
3333
echo " --branch-coverage Enable branch/path coverage (--raw mode only)\n";
3434
echo " --include-vendor=PATTERNS Include vendor packages (--raw mode only)\n";
35+
echo " --source=PATH[,PATH...] Restrict raw coverage to these source paths\n";
36+
echo " (--raw mode only; takes precedence over --include-vendor)\n";
37+
echo " --cwd=PATH Run from PATH (target project root)\n";
38+
echo " --php=PATH Use this PHP binary instead of " . PHP_BINARY . "\n";
3539
echo " --help, -h Show this help\n";
3640
echo "\n";
41+
echo "NOTES:\n";
42+
echo " --raw mode does not honor non-executable line metadata, so closing braces and\n";
43+
echo " declarations may appear as uncovered. Use the default PHPUnit mode for parity\n";
44+
echo " with php-code-coverage / Codecov.\n";
45+
echo "\n";
3746
echo "COMMON USAGE PATTERNS:\n";
3847
echo " # Default: PHPUnit mode with @codeCoverageIgnore support\n";
3948
echo " " . $argv[0] . "\n";
@@ -55,11 +64,35 @@ if (in_array('--help', $argv) || in_array('-h', $argv)) {
5564

5665
// Parse options
5766
$optind = 0;
58-
$options = getopt('', ['include-vendor::', 'branch-coverage', 'raw'], $optind);
67+
$options = getopt('', ['include-vendor::', 'branch-coverage', 'raw', 'cwd:', 'php:', 'source:'], $optind);
5968

6069
$includeVendor = $options['include-vendor'] ?? null;
6170
$branchCoverage = isset($options['branch-coverage']);
6271
$rawMode = isset($options['raw']);
72+
$cwdOption = $options['cwd'] ?? null;
73+
$phpOverride = $options['php'] ?? null;
74+
$sourceOption = $options['source'] ?? null;
75+
76+
// Resolve --php against the original cwd before chdir.
77+
if ($phpOverride !== null && $phpOverride !== '') {
78+
$resolved = realpath($phpOverride);
79+
if ($resolved === false || !is_executable($resolved)) {
80+
fwrite(STDERR, "Error: --php=$phpOverride not found or not executable\n");
81+
exit(1);
82+
}
83+
$phpOverride = $resolved;
84+
}
85+
86+
if ($cwdOption !== null && $cwdOption !== '') {
87+
if (!is_dir($cwdOption) || !chdir($cwdOption)) {
88+
fwrite(STDERR, "Error: --cwd=$cwdOption is not a directory or chdir failed\n");
89+
exit(1);
90+
}
91+
}
92+
93+
if ($sourceOption !== null && $sourceOption !== '' && $includeVendor !== null) {
94+
fwrite(STDERR, "Note: --source overrides --include-vendor (only one path filter is active at a time).\n");
95+
}
6396

6497
// Get remaining arguments
6598
$args = $optind !== null ? array_slice($argv, $optind) : [];
@@ -149,7 +182,7 @@ if (!$rawMode) {
149182

150183
// Get Xdebug flag for explicit loading
151184
$xdebugFlag = XdebugFinder::getXdebugFlag();
152-
$phpPath = PHP_BINARY;
185+
$phpPath = $phpOverride ?? PHP_BINARY;
153186

154187
// Build PHPUnit command with coverage-clover (stable XML format)
155188
$phpunitArgs = array_merge(
@@ -300,17 +333,48 @@ $args = normalizeRawArguments($args);
300333
$shutdownScript = tempnam(sys_get_temp_dir(), 'coverage_end_');
301334
$branchFlag = $branchCoverage ? 'true' : 'false';
302335
$includeVendorRequire = '';
303-
if ($includeVendor !== null) {
336+
$sourceFilterEnabled = $sourceOption !== null && $sourceOption !== '';
337+
if ($includeVendor !== null && !$sourceFilterEnabled) {
304338
$filterPath = __DIR__ . '/../prepend_filter.php';
305339
$includeVendorRequire = 'require_once ' . var_export($filterPath, true) . ';';
306340
putenv('COVERAGE_INCLUDE_VENDOR=' . $includeVendor);
307341
}
308342

343+
if ($sourceFilterEnabled) {
344+
$resolvedSources = [];
345+
foreach (explode(',', $sourceOption) as $candidate) {
346+
$candidate = trim($candidate);
347+
if ($candidate === '') {
348+
continue;
349+
}
350+
$real = realpath($candidate);
351+
if ($real === false) {
352+
fwrite(STDERR, "Error: --source path not found: $candidate\n");
353+
exit(1);
354+
}
355+
$resolvedSources[] = $real;
356+
}
357+
if ($resolvedSources === []) {
358+
fwrite(STDERR, "Error: --source produced no valid paths\n");
359+
exit(1);
360+
}
361+
$sourceLiteral = var_export($resolvedSources, true);
362+
} else {
363+
$sourceLiteral = 'null';
364+
}
365+
309366
file_put_contents($shutdownScript, '<?php
310367
' . $includeVendorRequire . '
311368
$tempDir = sys_get_temp_dir();
312369
$branchCoverage = ' . $branchFlag . ';
313-
if ($branchCoverage) {
370+
$sourcePaths = ' . $sourceLiteral . ';
371+
if (is_array($sourcePaths) && $sourcePaths !== []) {
372+
xdebug_set_filter(XDEBUG_FILTER_CODE_COVERAGE, XDEBUG_PATH_INCLUDE, $sourcePaths);
373+
$coverageFlags = $branchCoverage
374+
? (XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE | XDEBUG_CC_BRANCH_CHECK)
375+
: (XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE);
376+
xdebug_start_code_coverage($coverageFlags);
377+
} elseif ($branchCoverage) {
314378
xdebug_set_filter(XDEBUG_FILTER_CODE_COVERAGE, XDEBUG_PATH_EXCLUDE, [getcwd() . "/vendor/", $tempDir]);
315379
xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE | XDEBUG_CC_BRANCH_CHECK);
316380
} else {
@@ -425,7 +489,7 @@ register_shutdown_function(function() use ($tempDir, $branchCoverage) {
425489
});
426490
');
427491

428-
$phpPath = PHP_BINARY;
492+
$phpPath = $phpOverride ?? PHP_BINARY;
429493
$xdebugFlag = XdebugFinder::getXdebugFlag();
430494
$escapedPrependFile = escapeshellarg($shutdownScript);
431495

src/DebugServer.php

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ final class DebugServer
166166
/** @var array{id: string, label: string, file: string, line: int, condition?: string}|null */
167167
private array|null $activeBreakpoint = null;
168168

169-
/** @param array{command?: list<string>, context?: string, breakpoint?: string, steps?: int, connectionTimeout?: float, executionTimeout?: float, traceOnly?: bool, maxSteps?: int, jsonOutput?: bool, breakpoints?: list<array{file: string, line: int|string, condition?: string}>, readTimeout?: float, watches?: list<string>, pretty?: bool, maxValueBytes?: int|null, maxDepth?: int|null} $options */
169+
/** @param array{command?: list<string>, context?: string, breakpoint?: string, steps?: int, connectionTimeout?: float, executionTimeout?: float, traceOnly?: bool, maxSteps?: int, jsonOutput?: bool, breakpoints?: list<array{file: string, line: int|string, condition?: string}>, readTimeout?: float, watches?: list<string>, pretty?: bool, maxValueBytes?: int|null, maxDepth?: int|null, phpBinary?: string} $options */
170170
public function __construct(
171171
private readonly string $targetScript,
172172
private readonly int $debugPort,
@@ -354,8 +354,14 @@ private function executeTargetScript(): void
354354
$xdebugFlag = XdebugFinder::getXdebugFlag();
355355
$xdebugPart = $xdebugFlag !== '' ? $xdebugFlag . ' ' : '';
356356

357+
// Allow callers (e.g. xback --php=...) to override the spawned
358+
// PHP binary while keeping $command[0] as the literal 'php'.
359+
$phpBinary = ($this->options['phpBinary'] ?? '') !== ''
360+
? (string) $this->options['phpBinary']
361+
: 'php';
362+
357363
$cmd = sprintf(
358-
'XDEBUG_SESSION=xdebug-mcp php %s'
364+
'XDEBUG_SESSION=xdebug-mcp %s %s'
359365
. '-dxdebug.mode=debug,trace '
360366
. '-dxdebug.start_with_request=yes '
361367
. '-dxdebug.client_host=127.0.0.1 '
@@ -372,6 +378,7 @@ private function executeTargetScript(): void
372378
. '-derror_log=/tmp/php.log '
373379
. '-dauto_prepend_file=%s '
374380
. '%s',
381+
escapeshellarg($phpBinary),
375382
$xdebugPart,
376383
$this->debugPort,
377384
escapeshellarg($prependFilter),
@@ -391,8 +398,13 @@ private function executeTargetScript(): void
391398
$xdebugFlag = XdebugFinder::getXdebugFlag();
392399
$xdebugPart = $xdebugFlag !== '' ? $xdebugFlag . ' ' : '';
393400

401+
// Honor an optional PHP-binary override.
402+
$phpBinary = ($this->options['phpBinary'] ?? '') !== ''
403+
? (string) $this->options['phpBinary']
404+
: 'php';
405+
394406
$cmd = sprintf(
395-
'XDEBUG_SESSION=xdebug-mcp php %s'
407+
'XDEBUG_SESSION=xdebug-mcp %s %s'
396408
. '-dxdebug.mode=debug,trace '
397409
. '-dxdebug.start_with_request=yes '
398410
. '-dxdebug.client_host=127.0.0.1 '
@@ -405,6 +417,7 @@ private function executeTargetScript(): void
405417
. '-dxdebug.connect_timeout_ms=5000 '
406418
. '-dauto_prepend_file=%s '
407419
. '%s',
420+
escapeshellarg($phpBinary),
408421
$xdebugPart,
409422
$this->debugPort,
410423
escapeshellarg($prependFilter),
@@ -2305,6 +2318,18 @@ private function formatVariableValue(string $value, string $type): string
23052318
};
23062319
}
23072320

2321+
/**
2322+
* Strip XML 1.0 illegal control characters from a DBGp byte stream.
2323+
*
2324+
* Xdebug emits raw bytes (e.g., NUL inside anonymous-class names from PHP 8.3+)
2325+
* that libxml's strict parser rejects. We delete every byte that is illegal in
2326+
* XML 1.0 while preserving tab/LF/CR and any high-bit bytes (UTF-8 sequences).
2327+
*/
2328+
private static function sanitizeDbgpXml(string $xml): string
2329+
{
2330+
return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $xml) ?? $xml;
2331+
}
2332+
23082333
/**
23092334
* Parse XML response safely without error suppression
23102335
*/
@@ -2318,7 +2343,7 @@ private function parseXmlResponse(string $xmlString): SimpleXMLElement|null
23182343
$useErrors = libxml_use_internal_errors(true);
23192344
libxml_clear_errors();
23202345

2321-
$xml = simplexml_load_string($xmlString);
2346+
$xml = simplexml_load_string(self::sanitizeDbgpXml($xmlString));
23222347

23232348
// Get any errors that occurred
23242349
$errors = libxml_get_errors();
@@ -2805,7 +2830,11 @@ public function getCurrentVariables(): array
28052830
}
28062831

28072832
$variables = [];
2808-
$xml = simplexml_load_string($response);
2833+
$useErrors = libxml_use_internal_errors(true);
2834+
libxml_clear_errors();
2835+
$xml = simplexml_load_string(self::sanitizeDbgpXml($response));
2836+
libxml_clear_errors();
2837+
libxml_use_internal_errors($useErrors);
28092838
if ($xml && (property_exists($xml, 'property') && $xml->property !== null)) {
28102839
foreach ($xml->property as $prop) {
28112840
$name = (string) $prop['name'];
@@ -2944,7 +2973,11 @@ private function getJsonEncodeOutput(string $varName): string|null
29442973
return null;
29452974
}
29462975

2947-
$xml = simplexml_load_string($response);
2976+
$useErrors = libxml_use_internal_errors(true);
2977+
libxml_clear_errors();
2978+
$xml = simplexml_load_string(self::sanitizeDbgpXml($response));
2979+
libxml_clear_errors();
2980+
libxml_use_internal_errors($useErrors);
29482981
if (! $xml || (! property_exists($xml, 'property') || $xml->property === null)) {
29492982
return null;
29502983
}
@@ -3448,7 +3481,11 @@ private function captureCurrentDebugState(int $breakNumber, array|null $breakpoi
34483481
private function parseStackFrames(string $stackXml): array
34493482
{
34503483
try {
3451-
$xml = simplexml_load_string($stackXml);
3484+
$useErrors = libxml_use_internal_errors(true);
3485+
libxml_clear_errors();
3486+
$xml = simplexml_load_string(self::sanitizeDbgpXml($stackXml));
3487+
libxml_clear_errors();
3488+
libxml_use_internal_errors($useErrors);
34523489
if (! $xml || (! property_exists($xml, 'stack') || $xml->stack === null)) {
34533490
return [];
34543491
}
@@ -3547,7 +3584,11 @@ private function extractLocationFromBreakResponse(string $response): string
35473584
private function extractLocationDataFromBreakResponse(string $response): array|null
35483585
{
35493586
try {
3550-
$xml = simplexml_load_string($response);
3587+
$useErrors = libxml_use_internal_errors(true);
3588+
libxml_clear_errors();
3589+
$xml = simplexml_load_string(self::sanitizeDbgpXml($response));
3590+
libxml_clear_errors();
3591+
libxml_use_internal_errors($useErrors);
35513592
if ($xml) {
35523593
// Register xdebug namespace
35533594
$xml->registerXPathNamespace('xdebug', 'https://xdebug.org/dbgp/xdebug');

0 commit comments

Comments
 (0)