Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
name: Benchmarks

permissions:
contents: read
pull-requests: write

on:
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
benchmark:
runs-on: ubuntu-latest
name: Benchmark regression

steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Setup PHP and Composer
uses: ./.github/actions/setup-php-composer
with:
php-version: 8.3

# Store a baseline from the PR's base branch on this same runner, so the
# comparison is hardware-consistent (baselines are never committed).
- name: Store baseline from base branch
run: |
git checkout ${{ github.event.pull_request.base.sha }}
composer install --prefer-dist --no-interaction --no-progress
vendor/bin/phpbench run --tag=base --progress=none

- name: Compare PR against baseline
id: compare
run: |
git checkout ${{ github.event.pull_request.head.sha }}
composer install --prefer-dist --no-interaction --no-progress
set +e
vendor/bin/phpbench run \
--ref=base \
--report=aggregate \
--progress=none \
--no-ansi \
--assert="mode(variant.time.avg) <= mode(baseline.time.avg) +/- 25%" \
> benchmark-report.txt 2>&1
echo "exit_code=$?" >> "$GITHUB_OUTPUT"
cat benchmark-report.txt

- name: Write job summary
if: always()
run: |
{
echo "## Benchmark results"
echo
echo '```'
cat benchmark-report.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

- name: Comment results on PR
if: always()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const report = fs.readFileSync('benchmark-report.txt', 'utf8');
const passed = '${{ steps.compare.outputs.exit_code }}' === '0';
const status = passed
? 'No performance regression detected (within 25% tolerance).'
: 'Possible performance regression detected (exceeds 25% tolerance).';
const marker = '<!-- phpbench-regression -->';
const body = [
marker,
'## Benchmark results',
'',
status,
'',
'<details>',
'<summary>Aggregate report vs. base</summary>',
'',
'```',
report,
'```',
'',
'</details>',
'',
`_Compared against base \`${context.payload.pull_request.base.sha.slice(0, 7)}\`._`,
].join('\n');
const { owner, repo } = context.repo;
const issue_number = context.payload.pull_request.number;
const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number });
const existing = comments.find((c) => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body });
}

- name: Fail on regression
if: always() && steps.compare.outputs.exit_code != '0'
run: |
echo "Benchmark regression exceeded the configured tolerance."
exit 1
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
vendor
composer.lock
.phpbench
.vscode
.env
.DS_Store
Expand Down
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
"scripts": {
"test": "pest",
"benchmark": "phpbench run",
"benchmark:base": "phpbench run --tag=base --progress=none",
"benchmark:ci": "phpbench run --ref=base --report=aggregate --progress=none --assert=\"mode(variant.time.avg) <= mode(baseline.time.avg) +/- 15%\"",
"ecs": "ecs check --fix",
"rector": "rector process",
"stan": "phpstan analyse",
Expand Down
70 changes: 70 additions & 0 deletions docs/json-repair/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ json_repair('{"complete": "value", "incomplete": "partial', omitIncompleteString
// {"complete": "value"}
```

## duplicateKeyPolicy

When repairing malformed JSON that repeats the same object key, choose which value to keep. Pass `null` (default) to leave duplicate keys as-is in the repaired string.

```php
use Cortex\JsonRepair\DuplicateKeyPolicy;
use Cortex\JsonRepair\JsonRepairer;

// keep-first: {"a": 1}
new JsonRepairer('{a: 1, a: 2}', duplicateKeyPolicy: DuplicateKeyPolicy::KeepFirst)->repair();

// keep-last: {"a": 2}
new JsonRepairer('{a: 1, a: 2}', duplicateKeyPolicy: DuplicateKeyPolicy::KeepLast)->repair();
```

## Combined Options

Use both options together for streaming LLM output:
Expand Down Expand Up @@ -92,3 +107,58 @@ $repairer = new JsonRepairer(
$repaired = $repairer->repair();
// {"key1": "v1"}
```

## Advanced APIs

### Structured repair report

`repairWithDetails()` returns a `RepairResult` with the repaired JSON, whether input was already valid, and a list of fixes applied (same messages as debug logging):

```php
use Cortex\JsonRepair\JsonRepairer;

$result = (new JsonRepairer("{'key': 'value'}"))->repairWithDetails();
// $result->json, $result->wasAlreadyValid, $result->fixes
```

### Multiple values / NDJSON

`repairAll()` repairs each top-level JSON value separately:
Comment thread
tymondesigns marked this conversation as resolved.

```php
$results = (new JsonRepairer("{'a':1}\n{'b':2}"))->repairAll();
// ['{"a": 1}', '{"b": 2}']
```

### Streaming repair

`StreamingJsonRepairer` accumulates chunks and returns the best-effort repair so far:

```php
use Cortex\JsonRepair\StreamingJsonRepairer;

$stream = new StreamingJsonRepairer();
$stream->feed('{"name": "');
$partial = $stream->current(); // closes incomplete string/braces
```

### Scalar decode

`decode()` returns `mixed` — top-level scalars (strings, numbers, booleans, null) decode directly without being cast to arrays:

```php
(new JsonRepairer('true'))->decode(); // true
json_repair_decode('null'); // null
```

## Invalid numbers and non-finite values

The repairer normalizes common invalid numeric literals:

| Input | Repaired |
|-------|----------|
| `+123` | `123` |
| `007` | `7` |
| `.5` | `0.5` |
| `5.` | `5` |
| `Infinity`, `-Infinity`, `NaN` | `null` |
2 changes: 2 additions & 0 deletions phpbench.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
"runner.iterations": 10,
"runner.revs": 100,
"runner.warmup": 2,
"runner.retry_threshold": 5,
"runner.time_unit": "microseconds",
"storage.xml_storage_path": ".phpbench/storage",
"report.generators": {
"compare": {
"generator": "table",
Expand Down
74 changes: 74 additions & 0 deletions src/Concerns/InputSanitization.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ trait InputSanitization
*/
private function extractJsonFromMarkdown(string $input): string
{
if (! str_contains($input, '```')) {
return $input;
}

$matchCount = preg_match_all('/```json\s*([\s\S]*?)\s*```/', $input, $matches);

if ($matchCount > 0) {
Expand Down Expand Up @@ -352,4 +356,74 @@ private function extractFirstValidJson(string $input): string

return $bestMatch ?? $input;
}

/**
* Extract all top-level JSON objects or arrays from the input.
*
* @return list<string>
*/
private function extractAllTopLevelJson(string $input): array
{
if (json_validate($input)) {
return [$input];
}

$length = strlen($input);
$results = [];
$depth = 0;
$start = -1;
$inString = false;
$stringDelimiter = '';
$escapeNext = false;

for ($i = 0; $i < $length; $i++) {
$char = $input[$i];

if ($escapeNext) {
$escapeNext = false;
continue;
}

if ($inString) {
if ($char === '\\') {
$escapeNext = true;
continue;
}

if ($char === $stringDelimiter) {
$inString = false;
$stringDelimiter = '';
}

continue;
}

if ($char === '"' || $char === "'") {
$inString = true;
$stringDelimiter = $char;
continue;
}

if ($char === '{' || $char === '[') {
if ($depth === 0) {
$start = $i;
}

$depth++;
} elseif (($char === '}' || $char === ']') && $depth > 0) {
$depth--;

if ($depth === 0 && $start !== -1) {
$results[] = substr($input, $start, $i - $start + 1);
$start = -1;
}
}
}

if ($results === []) {
return [$input];
}

return $results;
}
}
78 changes: 78 additions & 0 deletions src/Concerns/OutputTracking.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

namespace Cortex\JsonRepair\Concerns;

/**
* @mixin \Cortex\JsonRepair\JsonRepairer
*/
trait OutputTracking
{
private ?string $lastNonWhitespaceChar = null;

private int $outputSyncedLength = 0;

private function resetOutputTracking(): void
{
$this->lastNonWhitespaceChar = null;
$this->outputSyncedLength = 0;
}

private function syncOutputTail(): void
{
$length = strlen($this->output);

if ($length === $this->outputSyncedLength) {
return;
}

$trimmed = rtrim(substr($this->output, $this->outputSyncedLength), " \t\n\r\f\v");

if ($trimmed !== '') {
$this->lastNonWhitespaceChar = $trimmed[strlen($trimmed) - 1];
}

$this->outputSyncedLength = $length;
}

private function recalculateLastNonWhitespaceChar(): void
{
$trimmed = rtrim($this->output);
$this->lastNonWhitespaceChar = $trimmed === '' ? null : $trimmed[strlen($trimmed) - 1];
$this->outputSyncedLength = strlen($this->output);
}

private function outputEndsWithNonWhitespace(string $char): bool
{
$this->syncOutputTail();

return $this->lastNonWhitespaceChar === $char;
}

private function trimOutputTrailingWhitespace(): void
{
if ($this->output === '') {
return;
}

$lastChar = $this->output[strlen($this->output) - 1];

if (in_array($lastChar, [' ', "\t", "\n", "\r", "\f", "\v"], true)) {
$this->output = rtrim($this->output);
$this->recalculateLastNonWhitespaceChar();
}
}

private function truncateOutput(int $length): void
{
$this->output = substr($this->output, 0, $length);
$this->recalculateLastNonWhitespaceChar();
}

private function setOutput(string $output): void
{
$this->output = $output;
$this->recalculateLastNonWhitespaceChar();
}
}
Loading
Loading