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
31 changes: 22 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ Opt-in resolution tracing for [gosuperscript/axiom](https://github.com/gosupersc
composer require gosuperscript/axiom-tracing
```

Requires `gosuperscript/axiom ^0.4.0`.

## Usage

### Conditional tracing with callback

```php
use Superscript\Axiom\Context;
use Superscript\Axiom\Resolvers\DelegatingResolver;
use Superscript\Axiom\Tracing\Tracing;
use Superscript\Axiom\Tracing\TracedResult;
Expand All @@ -30,18 +33,28 @@ if ($resolver instanceof TracingResolver) {
});
}

// Calling code is unchanged
$result = $resolver->resolve($source);
// Calling code is unchanged — resolve takes a Source and a Context.
$result = $resolver->resolve($source, new Context());
```

### Tracing an Expression

```php
use Superscript\Axiom\Expression;

$expression = new Expression($source, $resolver);
$result = $expression(['radius' => 5]); // fires the trace callback
```

### Explicit trace retrieval

```php
use Superscript\Axiom\Context;
use Superscript\Axiom\Tracing\TracingResolver;

$tracer = new TracingResolver($delegatingResolver);

$traced = $tracer->traced($source);
$traced = $tracer->traced($source); // optionally pass a Context with bindings/definitions
$traced->result; // Result<Option<T>>
$traced->trace; // ResolutionTrace tree
$traced->dump(); // formatted string
Expand All @@ -61,17 +74,17 @@ foreach ($traced->trace->children() as $child) {

### Production use (flat inspector, no tracing)

For extracting resolver metadata without full tracing overhead:
For extracting resolver metadata without full tracing overhead, pass a flat
inspector via the `Context`:

```php
use Superscript\Axiom\Context;
use Superscript\Axiom\Tracing\ResolutionContext;
use Superscript\Axiom\ResolutionInspector;

$context = new ResolutionContext();
$resolver->instance(ResolutionInspector::class, $context);
$inspector = new ResolutionContext();

$result = $resolver->resolve($source);
$httpResponse = $context->get('http_response');
$result = $resolver->resolve($source, new Context(inspector: $inspector));
$httpResponse = $inspector->get('http_response');
```

## Components
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"license": "MIT",
"require": {
"php": "^8.4",
"gosuperscript/axiom": "^0.3.3",
"gosuperscript/axiom": "^0.4.0",
"gosuperscript/monads": "^1.0"
},
"require-dev": {
Expand Down
37 changes: 23 additions & 14 deletions src/TracingResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace Superscript\Axiom\Tracing;

use Superscript\Axiom\ResolutionInspector;
use Superscript\Axiom\Context;
use Superscript\Axiom\Resolvers\BindableResolver;
use Superscript\Axiom\Resolvers\Resolver;
use Superscript\Axiom\Source;
Expand All @@ -29,17 +29,13 @@ public function __construct(
//
// DelegatingResolver::resolve() does NOT go through the IoC
// Resolver binding for its own dispatch — it calls
// $this->container->make($resolverClass)->resolve($source)
// $this->container->make($resolverClass)->resolve($source, $context)
// directly. The IoC Resolver binding is only injected into
// child resolvers (InfixResolver, ValueResolver, etc.) when
// they are constructed via the container. Those resolvers then
// call $this->resolver->resolve() for recursive resolution,
// which hits this TracingResolver. No infinite recursion.
$this->inner->instance(Resolver::class, $this);

// Replace any existing ResolutionInspector with our tree-aware
// version. Resolver annotations now land on trace nodes.
$this->inner->instance(ResolutionInspector::class, $this->inspector);
}

/** @param class-string $key */
Expand Down Expand Up @@ -70,12 +66,23 @@ public function onTrace(callable $callback): void
$this->onTrace = $callback;
}

public function resolve(Source $source): Result
public function resolve(Source $source, Context $context): Result
{
$isRoot = $this->depth === 0;

if ($isRoot) {
$this->root = null;

// At the root, swap in our tree-aware inspector so resolver
// annotations land on trace nodes. Recursive calls will
// propagate this context down unchanged.
if ($context->inspector !== $this->inspector) {
$context = new Context(
bindings: $context->bindings,
definitions: $context->definitions,
inspector: $this->inspector,
);
}
}

$this->depth++;
Expand All @@ -99,13 +106,13 @@ public function resolve(Source $source): Result
$this->inspector->setCurrent($node);

// Delegate to the real resolver chain.
// $this->inner->resolve($source) is a direct method call on
// the DelegatingResolver instance — it does NOT go through
// the IoC binding (which points back to us). The IoC binding
// is only used when child resolvers internally call
// $this->inner->resolve($source, $context) is a direct method call
// on the DelegatingResolver instance — it does NOT go through the
// IoC binding (which points back to us). The IoC binding is only
// used when child resolvers internally call
// $this->resolver->resolve() for recursive resolution.
$start = microtime(true);
$result = $this->inner->resolve($source);
$result = $this->inner->resolve($source, $context);
$duration = (microtime(true) - $start) * 1000;

// Generic metadata — captured for every node automatically
Expand Down Expand Up @@ -149,8 +156,10 @@ public function resolve(Source $source): Result
/**
* Explicit entry point for when you want the trace returned directly.
*/
public function traced(Source $source): TracedResult
public function traced(Source $source, ?Context $context = null): TracedResult
{
$context ??= new Context();

// Temporarily capture the trace via onTrace
$captured = null;
$previousCallback = $this->onTrace;
Expand All @@ -159,7 +168,7 @@ public function traced(Source $source): TracedResult
$captured = $traced;
};

$result = $this->resolve($source);
$result = $this->resolve($source, $context);

$this->onTrace = $previousCallback;

Expand Down
66 changes: 39 additions & 27 deletions tests/TracingResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use PHPUnit\Framework\TestCase;
use RuntimeException;
use Superscript\Axiom\Context;
use Superscript\Axiom\Definitions;
use Superscript\Axiom\Operators\DefaultOverloader;
use Superscript\Axiom\Operators\OperatorOverloader;
use Superscript\Axiom\Resolvers\DelegatingResolver;
Expand All @@ -19,7 +21,6 @@
use Superscript\Axiom\Sources\StaticSource;
use Superscript\Axiom\Sources\SymbolSource;
use Superscript\Axiom\Sources\TypeDefinition;
use Superscript\Axiom\SymbolRegistry;
use Superscript\Axiom\Tracing\TracedResult;
use Superscript\Axiom\Tracing\TracingResolver;
use Superscript\Axiom\Tracing\ResolutionTrace;
Expand All @@ -28,7 +29,7 @@

final class TracingResolverTest extends TestCase
{
private function createResolver(array $symbols = []): TracingResolver
private function createResolver(): TracingResolver
{
$delegating = new DelegatingResolver([
StaticSource::class => StaticResolver::class,
Expand All @@ -39,13 +40,17 @@ private function createResolver(array $symbols = []): TracingResolver

$delegating->instance(OperatorOverloader::class, new DefaultOverloader());

if ($symbols !== []) {
$delegating->instance(SymbolRegistry::class, new SymbolRegistry($symbols));
}

return new TracingResolver($delegating);
}

/**
* @param array<string, Source> $definitions
*/
private function context(array $definitions = []): Context
{
return new Context(definitions: new Definitions($definitions));
}

// ---------------------------------------------------------------
// Tree structure tests
// ---------------------------------------------------------------
Expand Down Expand Up @@ -121,11 +126,12 @@ public function test_mixed_nesting_produces_correct_structure(): void

public function test_symbol_resolution_produces_correct_tree(): void
{
$tracer = $this->createResolver([
'rate' => new StaticSource(100),
]);
$tracer = $this->createResolver();

$traced = $tracer->traced(new SymbolSource('rate'));
$traced = $tracer->traced(
new SymbolSource('rate'),
$this->context(['rate' => new StaticSource(100)]),
);

$this->assertSame(SymbolSource::class, $traced->trace->sourceType);
$this->assertCount(1, $traced->trace->children());
Expand Down Expand Up @@ -336,11 +342,12 @@ public function test_value_resolver_annotates_label(): void

public function test_symbol_resolver_annotates_label(): void
{
$tracer = $this->createResolver([
'base_rate' => new StaticSource(100),
]);
$tracer = $this->createResolver();

$traced = $tracer->traced(new SymbolSource('base_rate'));
$traced = $tracer->traced(
new SymbolSource('base_rate'),
$this->context(['base_rate' => new StaticSource(100)]),
);

$this->assertSame('base_rate', $traced->trace->label());
}
Expand Down Expand Up @@ -373,8 +380,8 @@ public function test_on_trace_fires_once_per_top_level_resolve(): void
$traces[] = $traced;
});

$tracer->resolve(new StaticSource(1));
$tracer->resolve(new StaticSource(2));
$tracer->resolve(new StaticSource(1), new Context());
$tracer->resolve(new StaticSource(2), new Context());

$this->assertCount(2, $traces);
}
Expand All @@ -395,7 +402,8 @@ public function test_on_trace_does_not_fire_for_inner_resolutions(): void
new StaticSource(1),
'+',
new StaticSource(2),
)
),
new Context(),
);

$this->assertSame(1, $callCount);
Expand All @@ -415,7 +423,8 @@ public function test_on_trace_receives_complete_trace_tree(): void
new StaticSource(10),
'+',
new StaticSource(20),
)
),
new Context(),
);

$this->assertNotNull($captured);
Expand Down Expand Up @@ -449,7 +458,7 @@ public function test_traced_does_not_interfere_with_existing_on_trace(): void
$tracer->traced(new StaticSource(1));

// The original callback should be restored
$tracer->resolve(new StaticSource(2));
$tracer->resolve(new StaticSource(2), new Context());
$this->assertTrue($callbackFired);
}

Expand All @@ -461,7 +470,7 @@ public function test_resolve_returns_correct_result_for_static(): void
{
$tracer = $this->createResolver();

$result = $tracer->resolve(new StaticSource(42));
$result = $tracer->resolve(new StaticSource(42), new Context());

$this->assertTrue($result->isOk());
$this->assertSame(42, $result->unwrap()->unwrap());
Expand All @@ -476,7 +485,8 @@ public function test_resolve_returns_correct_result_for_infix(): void
new StaticSource(10),
'+',
new StaticSource(20),
)
),
new Context(),
);

$this->assertTrue($result->isOk());
Expand All @@ -488,7 +498,8 @@ public function test_resolve_returns_correct_result_for_type_definition(): void
$tracer = $this->createResolver();

$result = $tracer->resolve(
new TypeDefinition(new NumberType(), new StaticSource(42))
new TypeDefinition(new NumberType(), new StaticSource(42)),
new Context(),
);

$this->assertTrue($result->isOk());
Expand All @@ -497,11 +508,12 @@ public function test_resolve_returns_correct_result_for_type_definition(): void

public function test_resolve_returns_correct_result_for_symbol(): void
{
$tracer = $this->createResolver([
'price' => new StaticSource(99),
]);
$tracer = $this->createResolver();

$result = $tracer->resolve(new SymbolSource('price'));
$result = $tracer->resolve(
new SymbolSource('price'),
$this->context(['price' => new StaticSource(99)]),
);

$this->assertTrue($result->isOk());
$this->assertSame(99, $result->unwrap()->unwrap());
Expand All @@ -521,7 +533,7 @@ public function test_resolution_that_throws(): void

// Resolving a source type with no registered resolver should throw
$this->expectException(RuntimeException::class);
$tracer->resolve(new SymbolSource('unknown'));
$tracer->resolve(new SymbolSource('unknown'), new Context());
}

public function test_single_node_tree(): void
Expand Down
3 changes: 2 additions & 1 deletion tests/TracingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Superscript\Axiom\Tracing\Tests;

use PHPUnit\Framework\TestCase;
use Superscript\Axiom\Context;
use Superscript\Axiom\Resolvers\DelegatingResolver;
use Superscript\Axiom\Resolvers\StaticResolver;
use Superscript\Axiom\Sources\StaticSource;
Expand Down Expand Up @@ -56,7 +57,7 @@ public function test_zero_overhead_when_disabled(): void
$resolver = Tracing::wrap($delegating, enabled: false);

// Should work normally — no tracing overhead
$result = $resolver->resolve(new StaticSource(42));
$result = $resolver->resolve(new StaticSource(42), new Context());

$this->assertTrue($result->isOk());
$this->assertSame(42, $result->unwrap()->unwrap());
Expand Down
Loading