Skip to content
Closed
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
157 changes: 157 additions & 0 deletions docs/adr/001-context-keys-and-annotation-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# ADR-001: Context Keys and Annotation Lifecycle

## Status

Accepted

## Context

The `Context` class uses dynamic properties (via `__get` with prototypical inheritance) to carry metadata about where an annotation was found or created. Understanding these keys is essential for writing processors that create, move, or remove annotations.

## Context Keys

### Source location keys

Set by the analyser, inherited via the parent chain.

| Key | Type | Meaning |
|-----|------|---------|
| `filename` | `string` | Absolute path to the PHP file |
| `line` | `int` | Line number |
| `character` | `int` | Column offset |
| `namespace` | `string` | PHP namespace |
| `uses` | `array` | Import aliases (`['Alias' => 'Full\\Class']`) |
| `class` | `string` | Enclosing class name |
| `interface` | `string` | Enclosing interface name |
| `trait` | `string` | Enclosing trait name |
| `enum` | `string` | Enclosing enum name |
| `method` | `string` | Enclosing method name |
| `property` | `string` | Enclosing property name |
| `static` | `bool` | Whether the method/property is static |
| `extends` | `string\|array` | Parent class or interfaces extended |
| `implements` | `array` | Interfaces implemented |
| `comment` | `string` | The raw PHP DocComment |
| `reflector` | `\Reflector` | Reflection object for the element |
| `scanned` | `array` | Details from file scanner (ReflectionAnalyser) |

### Annotation relationship keys

| Key | Type | Meaning |
|-----|------|---------|
| `nested` | `AbstractAnnotation\|null` | The parent annotation this one is nested inside. `null` means explicitly not nested (top-level for merge purposes). Absent (not set) means the same as null but via inheritance — `is('nested')` returns `false`. |
| `annotations` | `list<AbstractAnnotation>` | All annotations registered on this context. Shared by annotations at the same source location. |

### Processing keys

| Key | Type | Meaning |
|-----|------|---------|
| `generated` | `bool` | The annotation/context was created by a processor, type resolver, or serializer (not from source scan). |
| `version` | `string` | The OpenAPI version in use (set on root context). |
| `logger` | `LoggerInterface` | PSR logger (guaranteed set when using Generator). |
| `other` | `list<AbstractAnnotation>` | Non-OpenApi annotations found at this location. |

## The `nested` Key

### How `is('nested')` works

`Context::is()` calls `property_exists()` — it checks whether the property is set directly on this context instance (not inherited from parent). This distinction drives processor behaviour:

- `is('nested') === true`: The property exists on this context. The annotation has an explicit nesting declaration.
- `is('nested') === false`: The property is not set. MergeIntoOpenApi/MergeIntoComponents treat this as "top-level, merge into root".

### Values

| Value | `is('nested')` | Meaning |
|-------|----------------|---------|
| `AbstractAnnotation` instance | `true` | This annotation is a child of that parent |
| `null` | `true` | Explicitly marked as having no parent (e.g. parameter-level attributes that should not be merged into root) |
| *(not set)* | `false` | Top-level — eligible for merge into OpenApi/Components |

### Where `nested` is set

1. **`AbstractAnnotation::__construct()`** (line 110): Creates a child context `['nested' => $this]` for annotations passed as constructor properties.

2. **`AbstractAnnotation::merge()`** (line 156): Same pattern for annotations merged into `_unmerged`.

3. **`AttributeAnnotationFactory`** (line 76): Sets `'nested' => null` for parameter-level attributes (`#[Property]`, `#[Parameter]`, `#[RequestBody]` on method parameters) that should not be merged into root.

4. **Processors** (e.g. MergeJsonContent): Should update `_context` when relocating an annotation to reflect its new parent.

### How processors use `nested`

- **MergeJsonContent/MergeXmlContent**: Read `$annotation->_context->nested` to find the parent (Response/RequestBody/Parameter) and check `instanceof`.
- **MergeIntoOpenApi/MergeIntoComponents**: Check `$annotation->_context->is('nested') === false` to find top-level annotations eligible for merging into root.

## Annotation Lifecycle

### Registration

`Analysis::addAnnotation($annotation, $context)` registers in two places:
1. `$this->annotations` (`SplObjectStorage`) — keyed by annotation, value is context
2. `$context->annotations[]` — array on the context object

### Removal

`Analysis::removeAnnotation($annotation)` removes from both:
1. The `SplObjectStorage`
2. The context's annotations array (context is retrieved from the SplObjectStorage before removal)

### Creating annotations in a processor

When a processor creates a new annotation, it must register it with the analysis via `addAnnotation()`. This ensures the annotation is discoverable by subsequent processors (via `getAnnotationsOfType()`) and that its context is properly linked into the tree.

```php
$context = new Context(['nested' => $parent, 'generated' => true], $parent->_context);
$annotation = new OA\Schema(['_context' => $context, ...]);
$analysis->addAnnotation($annotation, $context);
```

Key points:

- **Create a new `Context` for each annotation** — never share a single context instance across multiple annotations. Each annotation needs its own context because `addAnnotation()` appends to `$context->annotations[]`. Sharing a context causes unrelated annotations to appear in each other's context, which confuses validation and cleanup. The context's parent chain provides inheritance of location keys (file, class, method), so per-annotation contexts are lightweight.
- **Always pass `_context`** with `'generated' => true` and `'nested' => $parent` so the annotation is correctly positioned for validation.
- **Call `addAnnotation()`** — this registers in both the `SplObjectStorage` (making it findable by type) and `$context->annotations` (linking it to its source location).
- **Use `$parent->merge([$annotation])`** if the annotation should be nested into the parent's `$_nested` mapping or end up in `_unmerged`. This is the normal path for annotations that the parent "owns".
- **Call `addAnnotation()` directly** (without merge) when the annotation will be consumed by a later processor (e.g., creating a `JsonContent` that `MergeJsonContent` will transform). The later processor is responsible for cleanup.

If you only call `addAnnotation()` without placing the annotation into the tree (i.e., it's not reachable from the root `OpenApi` object via properties or `_unmerged`), it will be findable via `getAnnotationsOfType()` but won't be validated or serialized.

### When processors relocate annotations

When a processor transforms an annotation (e.g., JsonContent becomes a Schema inside a MediaType), it must:

1. **Update `_context`** — set a new context with `nested` pointing to the new parent, so tree-walking validation sees it in the correct location.
2. **Remove from parent's `_unmerged`** — so the old parent's validation doesn't warn about unexpected children.
3. **Remove from analysis registry** — via `$analysis->removeAnnotation()` so it's no longer discoverable via `getAnnotationsOfType()` and the old context's annotations array is cleaned up.

Example (from MergeJsonContent):
```php
// Create the new parent
$mediaType = new OA\MediaType(['schema' => $jsonContent, ...]);

// Update context to reflect new position in tree
$jsonContent->_context = new Context(['nested' => $mediaType, 'generated' => true], $mediaType->_context);

// Remove from old parent's _unmerged
array_splice($parent->_unmerged, $index, 1);

// Remove from analysis registry (cleans up SplObjectStorage + old context->annotations)
$analysis->removeAnnotation($jsonContent);
```

## Validation and Tree Walking

`Analysis::validate()` does NOT iterate the `SplObjectStorage`. It calls `collectAnnotations()` which walks the annotation tree starting from `$this->openapi`, following all non-blacklisted object properties recursively. This means:

- An annotation removed from the registry but still reachable via the tree **will** be validated.
- The `_context->nested` value determines whether validation considers the annotation correctly placed.
- Annotations with `$_parents = []` (like `JsonContent`) have no valid parent — if still reachable during tree walking, their context must point to a valid parent or they should be transformed into a type that has valid parents.

## Decision

Processors that consume/transform annotations must perform full cleanup:
1. Update `_context` to reflect the annotation's new position in the tree
2. Remove from old parent's `_unmerged`
3. Remove from analysis registry via `removeAnnotation()`

The `nested` context key should use `null` (not `false`) to indicate "explicitly no parent" — this matches the declared `@property OA\AbstractAnnotation|null` type.
21 changes: 21 additions & 0 deletions src/Analysis.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,27 @@ public function addAnnotation(object $annotation, Context $context): void
}
}

<<<<<<< HEAD
=======
public function removeAnnotation(OA\AbstractAnnotation $annotation): void
{
if ($this->annotations->offsetExists($annotation)) {
$context = $this->annotations->offsetGet($annotation);
$this->annotations->offsetUnset($annotation);

if ($context->is('annotations') !== false) {
$index = array_search($annotation, $context->annotations, true);
if ($index !== false) {
array_splice($context->annotations, $index, 1);
}
}
}
}

/**
* @param list<OA\AbstractAnnotation> $annotations
*/
>>>>>>> 060af3b (fix: remove consumed annotations from analysis registry (#2024))
public function addAnnotations(array $annotations, Context $context): void
{
foreach ($annotations as $annotation) {
Expand Down
2 changes: 1 addition & 1 deletion src/Processors/AugmentRefs.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ protected function removeDuplicateRefs(Analysis $analysis): void
if (!Generator::isDefault($allOfSchema->ref)) {
if (in_array($allOfSchema->ref, $refs)) {
$dupes[] = $allOfSchema->ref;
$analysis->annotations->offsetUnset($allOfSchema);
$analysis->removeAnnotation($allOfSchema);
unset($schema->allOf[$ii]);
continue;
}
Expand Down
2 changes: 1 addition & 1 deletion src/Processors/AugmentTags.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ private function removeUnusedTags(array $usedTagNames, array $declaredTags, Anal
foreach ($declaredTags as $tag) {
if (!in_array($tag->name, $tagsToKeep)) {
if (false !== $index = array_search($tag, $analysis->openapi->tags, true)) {
$analysis->annotations->offsetUnset($tag);
$analysis->removeAnnotation($tag);
unset($analysis->openapi->tags[$index]);
$analysis->openapi->tags = array_values($analysis->openapi->tags);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Processors/BuildPaths.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public function __invoke(Analysis $analysis): void
$annotation->_context->logger->warning($annotation->identity() . ' is missing required property "path" in ' . $annotation->_context);
} elseif (isset($paths[$annotation->path])) {
$paths[$annotation->path]->mergeProperties($annotation);
$analysis->annotations->offsetUnset($annotation);
$analysis->removeAnnotation($annotation);
} else {
$paths[$annotation->path] = $annotation;
}
Expand Down
2 changes: 1 addition & 1 deletion src/Processors/MergeIntoOpenApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ public function __invoke(Analysis $analysis): void
}
}

$analysis->annotations->offsetUnset($components);
$analysis->removeAnnotation($components);
}

$merge = array_filter($merge, fn (OA\AbstractAnnotation $annotation): bool => !$annotation instanceof OA\Components);
Expand Down
2 changes: 2 additions & 0 deletions src/Processors/MergeJsonContent.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,13 @@ public function __invoke(Analysis $analysis): void
$jsonContent->example = Generator::UNDEFINED;
$jsonContent->examples = Generator::UNDEFINED;
$jsonContent->encoding = Generator::UNDEFINED;
$jsonContent->_context = new Context(['nested' => $mediaType, 'generated' => true], $mediaType->_context);

$index = array_search($jsonContent, $parent->_unmerged, true);
if ($index !== false) {
array_splice($parent->_unmerged, $index, 1);
}
$analysis->removeAnnotation($jsonContent);
}
}
}
2 changes: 2 additions & 0 deletions src/Processors/MergeXmlContent.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,13 @@ public function __invoke(Analysis $analysis): void
}
$xmlContent->example = Generator::UNDEFINED;
$xmlContent->examples = Generator::UNDEFINED;
$xmlContent->_context = new Context(['nested' => $mediaType, 'generated' => true], $mediaType->_context);

$index = array_search($xmlContent, $parent->_unmerged, true);
if ($index !== false) {
array_splice($parent->_unmerged, $index, 1);
}
$analysis->removeAnnotation($xmlContent);
}
}
}