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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Changed

- `Origin` now accepts `$appDir` and `$vendorDir` parameters directly, instead of `Root` object. This makes
application detection more explicit and reliable, without depending on additional packages.
- `Fingerprint` now accepts an `$appDir` parameter, instead of `Root` object. This makes relative path fingerprinting
more explicitly opt-in.

## [0.1.0] - 2026-10-05

### Added
Expand Down
17 changes: 17 additions & 0 deletions docs/problems.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,23 @@ followed by extension members in insertion order.

The HTTP problem documents described below will also throw when attempting to overwrite `title` or `status`.

## ProblemMap

`Errata\ProblemMap` maps a class name to a factory `Closure(object): Problem`:

```php
use Errata\Problem;
use Errata\ProblemMap;

$map = new ProblemMap([
ValidationError::class => new ValidationProblemFactory(),
]);
```

Lookup checks the exact class first, then parent classes nearest-first, then implemented interfaces, and returns
the first factory found. When nothing matches, `get()` returns `null` and the transformer falls back to an
`InternalServerError`.

## HTTP problems

The `Errata\Http` namespace ships fixed-status problem documents. `Errata\Http\HttpProblem` is the abstract base;
Expand Down
18 changes: 7 additions & 11 deletions docs/throwables.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,16 @@ and an `ExtensionList` whose extensions enrich the resulting problem.

## ProblemMap

`Errata\ProblemMap` maps a throwable class name to a factory `Closure(object): Problem`.
`Errata\ProblemMap` maps a class name to a factory `Closure(object): Problem`. This can be used for throwables:

```php
use Errata\Http\Client\UnprocessableContent;
use Errata\ProblemMap;

$map = new ProblemMap([
ValidationFailed::class => static fn(ValidationFailed $e): UnprocessableContent
=> new UnprocessableContent(detail: $e->getMessage()),
ValidationFailed::class => static fn(ValidationFailed $e): UnprocessableContent => new UnprocessableContent(detail: $e->getMessage()),
// Or, if you prefer an object with __invoke:
ValidationFailed::class => new UnprocessableContentFactory(),
]);
```

Expand Down Expand Up @@ -98,17 +99,12 @@ Additional constructor options:
- `Fingerprint` takes `algo`, which defaults to `xxh64` and accepts any `hash()` algorithm, and a `Root`. The hash
input joins the throwable class, code, and the file path made relative to the application root, so fingerprints
are stable across systems.
- `Origin` and `Fingerprint` both take a `Root`.
- `Origin` takes `appDir` and `vendorDir` to allow picking an application-specific origin by finding the first
`Location` that is inside the `appDir` and *not* inside the `vendorDir`.
- `Fingerprint` takes `appDir` to make the file path relative for increased fingerprint stability.

### Trace locations

`Errata\Trace\Location` represents one entry in a throwable's stack trace. A location list starts with the
throwable's own file and line, followed by the trace frames, skipping entries without a file. Locations serialize
to JSON as `"file:line"` strings.

## Root

`Errata\Root` identifies the application directory and the vendor directory beneath it. When no directory is
given, it is detected from Composer. `isApp()` reports whether a path is inside the application but not the vendor
tree, and `relative()` makes a path relative to the application root while leaving paths outside it untouched.
`Origin` and `Fingerprint` use both.
26 changes: 21 additions & 5 deletions src/Extension/Fingerprint.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,50 @@
namespace Errata\Extension;

use Errata\Problem;
use Errata\Root;
use Override;
use Throwable;

use function hash;
use function Psl\Filesystem\canonicalize;
use function Psl\Str\Byte\after;
use function Psl\Str\join;

use const Psl\Filesystem\SEPARATOR;

/**
* @api
*/
final readonly class Fingerprint implements Extension
{
/** @var non-empty-string|null */
private ?string $appDir;

/**
* @param string|null $appDir
*/
public function __construct(
private Root $root = new Root(),
/** @var non-empty-string */
private string $key = 'fingerprint',
/** @var non-empty-string */
private string $algo = 'xxh64',
) {}
?string $appDir = null,
) {
$this->appDir = $appDir ? canonicalize($appDir) : null;
}

#[Override]
public function extend(Problem $problem, Throwable $throwable): void
{
$file = $throwable->getFile();

if ($this->appDir) {
$file = after($file, $this->appDir . SEPARATOR) ?? $file;
}

$elements = [
$throwable::class,
(string) $throwable->getCode(),
// File path MUST be made relative for stability across systems.
$this->root->relative($throwable->getFile()),
$file,
];

$problem->extend($this->key, hash($this->algo, join($elements, glue: '|')));
Expand Down
45 changes: 40 additions & 5 deletions src/Extension/Origin.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,38 +5,73 @@
namespace Errata\Extension;

use Errata\Problem;
use Errata\Root;
use Errata\Trace\Location;
use Override;
use Throwable;

use function Psl\Filesystem\canonicalize;
use function Psl\Str\Byte\starts_with;

/**
* @api
*/
final readonly class Origin implements Extension
{
/** @var non-empty-string|null */
private ?string $appDir;

/** @var non-empty-string|null */
private ?string $vendorDir;

/**
* @param string|null $appDir
* @param string|null $vendorDir
*/
public function __construct(
private Root $root = new Root(),
/** @var non-empty-string */
private string $key = 'origin',
) {}
?string $appDir = null,
?string $vendorDir = null,
) {
$this->appDir = $appDir ? canonicalize($appDir) : null;
$this->vendorDir = $vendorDir ? canonicalize($vendorDir) : null;
}

#[Override]
public function extend(Problem $problem, Throwable $throwable): void
{
$problem->extend($this->key, $this->findOrigin($throwable));
$origin = $this->findOrigin($throwable);

if ($this->appDir) {
$origin = $origin->withoutDirectory($this->appDir);
}

$problem->extend($this->key, $origin);
}

private function findOrigin(Throwable $throwable): Location
{
$locations = Location::trace($throwable);

foreach ($locations as $location) {
if ($this->root->isApp($location->file)) {
if ($this->isApp($location)) {
return $location;
}
}

return $locations[0] ?? new Location('(internal)');
}

private function isApp(Location $location): bool
{
if ($this->appDir === null) {
return false;
}

if ($this->vendorDir && starts_with($location->file, $this->vendorDir)) {
return false;
}

return starts_with($location->file, $this->appDir);
}
}
28 changes: 7 additions & 21 deletions src/Problem.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,27 +78,13 @@ final public function extend(string $name, mixed $value): self
**/
final public function toArray(): array
{
$members = [];

if ($this->type !== null) {
$members['type'] = $this->type;
}

if ($this->title !== null) {
$members['title'] = $this->title;
}

if ($this->detail !== null) {
$members['detail'] = $this->detail;
}

if ($this->status !== null) {
$members['status'] = $this->status;
}

if ($this->instance !== null) {
$members['instance'] = $this->instance;
}
$members = [
'type' => $this->type,
'title' => $this->title,
'detail' => $this->detail,
'status' => $this->status,
'instance' => $this->instance,
];

return filter_nulls($members + $this->extensions);
}
Expand Down
7 changes: 3 additions & 4 deletions src/ProblemMap.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,14 @@

namespace Errata;

use Closure;

use function class_implements;
use function class_parents;

/**
* @api
* @template T of object
* @template Tk of class-string
* @template Tv of Closure(T):Problem
* @template Tv of callable(T):Problem
*/
final readonly class ProblemMap
{
Expand All @@ -23,9 +21,10 @@ public function __construct(
) {}

/**
* @param T $instance
* @return Tv|null
*/
public function get(object $instance): ?Closure
public function get(object $instance): ?callable
{
$classes = [
$instance::class,
Expand Down
75 changes: 0 additions & 75 deletions src/Root.php

This file was deleted.

19 changes: 19 additions & 0 deletions src/Trace/Location.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
use Stringable;
use Throwable;

use function Psl\Str\Byte\after;
use function Psl\Str\Byte\starts_with;

use const Psl\Filesystem\SEPARATOR;

/**
* @internal
*/
Expand Down Expand Up @@ -47,6 +52,20 @@ public function __construct(
public int $line = 0,
) {}

/**
* @param non-empty-string $dir
*/
public function withoutDirectory(string $dir): self
{
if (!starts_with($this->file, $dir)) {
return $this;
}

// @mago-expect analysis:possibly-invalid-argument
// @mago-expect analysis:possibly-null-argument
return new self(after($this->file, $dir . SEPARATOR), $this->line);
}

public function toString(): string
{
if ($this->line < 1) {
Expand Down
Loading
Loading