diff --git a/scripts/homeboy-dmc-provider.php b/scripts/homeboy-dmc-provider.php index fa8cced..b01c572 100644 --- a/scripts/homeboy-dmc-provider.php +++ b/scripts/homeboy-dmc-provider.php @@ -148,6 +148,34 @@ return array( 'status' => $status, 'stdout' => $stdout, 'stderr' => $stderr, 'failure' => $failure ); }; +$decode_json_output = static function ( string $stdout ): mixed { + try { + return json_decode(trim($stdout), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $original_error) { + $lines = preg_split('/\R/', $stdout); + if ( false === $lines ) { + throw $original_error; + } + $diagnostic_found = false; + while ( array() !== $lines ) { + $line = array_shift($lines); + if ( '' === trim($line) ) { + continue; + } + if ( preg_match('/^(?:PHP )?(?:Deprecated|Warning|Notice):\s/', ltrim($line)) ) { + $diagnostic_found = true; + continue; + } + array_unshift($lines, $line); + break; + } + if ( ! $diagnostic_found ) { + throw $original_error; + } + return json_decode(trim(implode("\n", $lines)), true, 512, JSON_THROW_ON_ERROR); + } +}; + $canonical_task_url = static function ( string $task_url ): string { $task_url = trim($task_url); $task_url = preg_split('/[?#]/', $task_url, 2)[0] ?? ''; @@ -210,7 +238,7 @@ static function ( array $matches ): string { exit($identity_capture['status'] > 0 && $identity_capture['status'] < 256 ? $identity_capture['status'] : 1); } try { - $identity = json_decode($identity_capture['stdout'], true, 512, JSON_THROW_ON_ERROR); + $identity = $decode_json_output($identity_capture['stdout']); } catch (Throwable $error) { fwrite(STDERR, "DMC tracker-attachment preview returned invalid identity JSON.\n"); exit(1); @@ -247,7 +275,7 @@ static function ( array $matches ): string { fwrite(STDERR, $safety_capture['stderr']); exit($safety_capture['status'] > 0 && $safety_capture['status'] < 256 ? $safety_capture['status'] : 1); } - $safety = json_decode($safety_capture['stdout'], true); + $safety = $decode_json_output($safety_capture['stdout']); if ( ! is_array($safety) || 'datamachine-code/worktree-safety/v1' !== ( $safety['schema'] ?? null ) || true !== ( $safety['fresh'] ?? null ) || false !== ( $safety['dirty'] ?? null ) ) { fwrite(STDERR, "DMC tracker-attachment preview requires a fresh clean worktree.\n"); exit(1); @@ -283,7 +311,7 @@ static function ( array $matches ): string { exit($capture['status'] > 0 && $capture['status'] < 256 ? $capture['status'] : 1); } try { - $attached = json_decode($capture['stdout'], true, 512, JSON_THROW_ON_ERROR); + $attached = $decode_json_output($capture['stdout']); } catch (Throwable $error) { fwrite(STDERR, "DMC tracker attachment returned invalid JSON.\n"); exit(1); @@ -331,7 +359,11 @@ static function ( array $matches ): string { $stderr = $capture['stderr']; $status = $capture['status']; if ( 0 !== $status ) { - $overflow = json_decode($stdout, true); + try { + $overflow = $decode_json_output($stdout); + } catch (Throwable $error) { + $overflow = null; + } if ( is_array($overflow) && 'worktree_task_candidates_overflow' === ( $overflow['error']['code'] ?? $overflow['code'] ?? null ) ) { fwrite(STDERR, "DMC task worktree execution exceeded its complete candidate bound.\n"); } else { @@ -340,7 +372,7 @@ static function ( array $matches ): string { exit($status > 0 && $status < 256 ? $status : 1); } try { - $rows = json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + $rows = $decode_json_output($stdout); } catch (Throwable $error) { fwrite(STDERR, 'DMC task worktree projection returned invalid JSON: ' . $error->getMessage() . "\n"); exit(1); @@ -421,7 +453,7 @@ static function ( array $matches ): string { exit($status > 0 && $status < 256 ? $status : 1); } try { - $plan = json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + $plan = $decode_json_output($stdout); } catch (Throwable $error) { fwrite(STDERR, 'DMC worktree plan returned invalid JSON: ' . $error->getMessage() . "\n"); exit(1); @@ -457,7 +489,7 @@ static function ( array $matches ): string { exit(2); } -$run_provider = static function ( string $provider_operation, string $provider_value, string $provider_base = '' ) use ( $provider, $workspace ): array { +$run_provider = static function ( string $provider_operation, string $provider_value, string $provider_base = '' ) use ( $provider, $workspace, $decode_json_output ): array { $command = array( PHP_BINARY, $provider, $provider_operation, $workspace, $provider_value ); if ( '' !== $provider_base ) { $command[] = $provider_base; @@ -474,7 +506,7 @@ static function ( array $matches ): string { if ( 0 !== $status ) { throw new RuntimeException('DMC worktree provider failed: ' . trim($stderr), $status); } - $payload = json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + $payload = $decode_json_output($stdout); if ( ! is_array($payload) ) { throw new RuntimeException('DMC worktree provider returned an invalid envelope.'); } diff --git a/tests/homeboy-dmc-provider.sh b/tests/homeboy-dmc-provider.sh index f415a7a..4f6d1b0 100644 --- a/tests/homeboy-dmc-provider.sh +++ b/tests/homeboy-dmc-provider.sh @@ -290,6 +290,12 @@ one = run("one") expected = [{"handle": "fixture@task-425", "path": os.environ["DMC_STATE"], "branch": "fix/425-resolve-task", "task_url": "https://github.com/Extra-Chill/wp-coding-agents/issues/425", "safety": {"dirty": False, "unpushed": False, "primary": False}}] if one.returncode or json.loads(one.stdout) != expected: raise SystemExit(f"FAIL: one task candidate did not retain its full identity: {one!r}") +diagnostic_prefix = run("diagnostic_prefix") +if diagnostic_prefix.returncode or json.loads(diagnostic_prefix.stdout) != expected: + raise SystemExit(f"FAIL: recognized PHP diagnostics must not contaminate typed task lookup JSON: {diagnostic_prefix!r}") +arbitrary_prefix = run("arbitrary_prefix") +if arbitrary_prefix.returncode == 0 or arbitrary_prefix.stdout or "invalid JSON" not in arbitrary_prefix.stderr: + raise SystemExit(f"FAIL: arbitrary task lookup prefixes must fail closed: {arbitrary_prefix!r}") ambiguous = run("ambiguous") if ambiguous.returncode or [item["handle"] for item in json.loads(ambiguous.stdout)] != ["fixture@task-425", "fixture@task-425-other"]: raise SystemExit(f"FAIL: ambiguous task candidates must retain DMC's complete bounded set: {ambiguous!r}") @@ -570,7 +576,7 @@ if [ "$1 $2 $3 $4 $5" = "wp datamachine-code workspace worktree list" ]; then zero) printf '{"success":true,"total":0,"returned":0,"next_cursor":null,"worktrees":[]}\n' ;; - one|ambiguous|canonical|mismatched_task|incomplete_safety|default_http|large_inventory) + one|diagnostic_prefix|arbitrary_prefix|ambiguous|canonical|mismatched_task|incomplete_safety|default_http|large_inventory) task='https://github.com/Extra-Chill/wp-coding-agents/issues/425' [ "${DMC_TASK_LOOKUP_MODE:-}" = mismatched_task ] && task='https://github.com/Extra-Chill/wp-coding-agents/issues/other' [ "${DMC_TASK_LOOKUP_MODE:-}" = canonical ] && task='HTTPS://GITHUB.COM/Extra-Chill/WP-Coding-Agents/issues/425/?query=value#fragment' @@ -581,6 +587,8 @@ if [ "$1 $2 $3 $4 $5" = "wp datamachine-code workspace worktree list" ]; then i=1 while [ "$i" -le 5000 ]; do i=$((i + 1)); done fi + [ "${DMC_TASK_LOOKUP_MODE:-}" = diagnostic_prefix ] && printf '\nDeprecated: Case statements followed by a semicolon are deprecated.\n' + [ "${DMC_TASK_LOOKUP_MODE:-}" = arbitrary_prefix ] && printf 'unexpected output\n' printf '{"success":true,"total":%s,"returned":%s,"next_cursor":null,"worktrees":[{"handle":"fixture@task-425","path":"%s","branch":"fix/425-resolve-task","task_full":{"task_url":"%s"},"safety":%s}' "$( [ "${DMC_TASK_LOOKUP_MODE:-}" = ambiguous ] && printf 2 || printf 1 )" "$( [ "${DMC_TASK_LOOKUP_MODE:-}" = ambiguous ] && printf 2 || printf 1 )" "$DMC_STATE" "$task" "$safety" if [ "${DMC_TASK_LOOKUP_MODE:-}" = ambiguous ]; then printf ',{"handle":"fixture@task-425-other","path":"%s-other","branch":"fix/425-resolve-task","task_full":{"task_url":"%s"},"safety":{"dirty":false,"unpushed":false,"primary":false}}' "$DMC_STATE" "$task"