Skip to content

Commit 385ce0f

Browse files
committed
Add Assurance subject/exactness model for IDE type narrowing
Extend the #[Assurance] vocabulary so consumers (FluentGen, FluentAnalysis) can derive how a composable prefix transforms the wrapped node's assured type: - Add #[AssuranceSubject] with AssuranceSubjectMode (Wrap / Elements / Container) describing whether a prefix wraps, iterates, or derives the subject. - Add the `exact` flag to #[Assurance]; remove AssuranceModifier::Nullable, now expressed as AssuranceSubject(Wrap) + type: 'null'. This is a BC break (3.0). - Thread a TExact template through the fluent builders for analyzer state. - Correct the README composition claim: prefix resolution is single-level.
1 parent 4950c15 commit 385ce0f

13 files changed

Lines changed: 228 additions & 20 deletions

.gitattributes

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/* export-ignore
2+
3+
# Project files
4+
/README.md -export-ignore
5+
/composer.json -export-ignore
6+
/docs -export-ignore
7+
/src -export-ignore
8+
9+
# SBOM information
10+
/LICENSE -export-ignore
11+
/LICENSES -export-ignore
12+
/REUSE.toml -export-ignore

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,15 +178,18 @@ a name, constructor arguments, and an optional wrapper.
178178

179179
**NamespaceLookup vs ComposingLookup:** use `NamespaceLookup` for simple
180180
name-to-class mapping. Wrap it with `ComposingLookup` when you need prefix
181-
composition like `notEmail()``Not(Email())`. `ComposingLookup` supports
182-
recursive unwrapping, so `notNullOrEmail()``Not(NullOr(Email()))` works too.
181+
composition like `notEmail()``Not(Email())`. Composition resolves a single
182+
prefix level (e.g. `notEmail`, `nullOrEmail`); deeper nesting such as
183+
`notNullOrEmail` is not decomposed.
183184

184185
## Assurance attributes
185186

186187
Node classes can declare what they assure about their input via `#[Assurance]`.
187188
Assertion methods are marked with `#[AssuranceAssertion]`, and `#[AssuranceParameter]`
188189
identifies specific parameters. Constructor parameters for composition use
189-
`#[ComposableParameter]`.
190+
`#[ComposableParameter]`. Composable prefixes declare how they relate to the
191+
wrapped node's subject with `#[AssuranceSubject]` (`Wrap`, `Elements`, or
192+
`Container`).
190193

191194
This metadata is available at runtime through reflection and is also consumed
192195
by tools like [FluentAnalysis](https://github.com/Respect/FluentAnalysis)

composer.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,10 @@
4747
"allow-plugins": {
4848
"dealerdirect/phpcodesniffer-composer-installer": true
4949
}
50+
},
51+
"extra": {
52+
"branch-alias": {
53+
"dev-ide-narrowing": "3.0.x-dev"
54+
}
5055
}
5156
}

docs/api.md

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -346,24 +346,32 @@ final readonly class When implements Validator
346346
public function __construct(Validator $when, Validator $then, Validator $else) {}
347347
}
348348

349-
// Modifier: exclude type instead of asserting it
349+
// Exact: passes if and only if the input is of the declared type
350+
#[Assurance(type: 'int', exact: true)]
351+
final readonly class IntType implements Validator { /* ... */ }
352+
353+
// Modifier on a Wrap prefix: negate the wrapped node's assurance
350354
#[Assurance(modifier: AssuranceModifier::Exclude)]
355+
#[AssuranceSubject(AssuranceSubjectMode::Wrap)]
351356
final readonly class Not implements Validator { /* ... */ }
352357

353-
// Modifier: add null to the assured type
354-
#[Assurance(modifier: AssuranceModifier::Nullable)]
358+
// Bypass set on a Wrap prefix: 'null' is admitted in union with the
359+
// wrapped node's assurance (nullOrIntType() assures int|null)
360+
#[Assurance(type: 'null', exact: true)]
361+
#[AssuranceSubject(AssuranceSubjectMode::Wrap)]
355362
final readonly class NullOr implements Validator { /* ... */ }
356363
```
357364

358365
Properties:
359366

360-
| Property | Type | Purpose |
361-
|----------------|--------------------------|------------------------------------------------------------------|
362-
| `type` | `?string` | Fixed type string (e.g. `'int'`, `'string'`) |
363-
| `from` | `?AssuranceFrom` | Derive type from a method argument |
364-
| `compose` | `?AssuranceCompose` | Combine assurances from child validators |
365-
| `composeRange` | `?array{int, int\|null}` | Subset of arguments to compose (`[from, to]`, null = open-ended) |
366-
| `modifier` | `?AssuranceModifier` | Modify how the assurance is applied |
367+
| Property | Type | Purpose |
368+
|----------------|------------------------------|------------------------------------------------------------------|
369+
| `type` | `string\|list<string>\|null` | Fixed type string (e.g. `'int'`); a list means their union |
370+
| `from` | `?AssuranceFrom` | Derive type from a method argument |
371+
| `compose` | `?AssuranceCompose` | Combine assurances from child validators |
372+
| `composeRange` | `?array{int, int\|null}` | Subset of arguments to compose (`[from, to]`, null = open-ended) |
373+
| `modifier` | `?AssuranceModifier` | Modify how the assurance is applied |
374+
| `exact` | `bool` | The node passes *iff* the input is of the declared type |
367375

368376
### AssuranceFrom (enum)
369377

@@ -388,7 +396,37 @@ Determines how child assurances are combined:
388396

389397
Modifies how an assurance is applied:
390398

391-
| Case | Meaning |
392-
|------------|------------------------------------------|
393-
| `Exclude` | Removes the type instead of asserting it |
394-
| `Nullable` | Adds `null` to the assured type |
399+
| Case | Meaning |
400+
|-----------|-----------------------------------------------------------------------|
401+
| `Exclude` | The wrapped node's assurance is negated: passing implies NOT the type |
402+
403+
### AssuranceSubject
404+
405+
Declares how a `#[Composable]` prefix relates to its wrapped node's subject.
406+
A prefix without it yields no assurance for composed names: tools must drop,
407+
not copy, the wrapped node's assurance.
408+
409+
```php
410+
// Same subject, modified: notEmail() negates Email's assurance
411+
#[Assurance(modifier: AssuranceModifier::Exclude)]
412+
#[AssuranceSubject(AssuranceSubjectMode::Wrap)]
413+
final readonly class Not implements Validator { /* ... */ }
414+
415+
// Derived subject: keyEmail('name') assures only the container type
416+
#[Assurance(type: ['array', 'ArrayAccess'])]
417+
#[AssuranceSubject(AssuranceSubjectMode::Container)]
418+
final readonly class Key implements Validator { /* ... */ }
419+
```
420+
421+
### AssuranceSubjectMode (enum)
422+
423+
| Case | Meaning |
424+
|-------------|----------------------------------------------------------------------------------|
425+
| `Wrap` | Same subject as the wrapped node: its assurance passes through, modified |
426+
| `Elements` | The wrapped node validates each element: assurance becomes `iterable<T>` |
427+
| `Container` | The wrapped node validates a derived subject: only the container type is assured |
428+
429+
A `Wrap` prefix's own `#[Assurance(type:)]` declares its *bypass set*: inputs
430+
it admits itself, in union with whatever the wrapped node assures. It is only
431+
meaningful in composition, never as a claim about direct calls; `exact` on it
432+
means the bypass set is an exact characterization.

src/Attributes/Assurance.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public function __construct(
2525
public AssuranceCompose|null $compose = null,
2626
public array|null $composeRange = null,
2727
public AssuranceModifier|null $modifier = null,
28+
public bool $exact = false,
2829
) {
2930
}
3031
}

src/Attributes/AssuranceModifier.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@
1212

1313
enum AssuranceModifier: string
1414
{
15+
/** The wrapped node's assurance is negated: passing implies NOT the type */
1516
case Exclude = 'exclude';
16-
case Nullable = 'nullable';
1717
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
/*
4+
* SPDX-License-Identifier: ISC
5+
* SPDX-FileCopyrightText: (c) Respect Project Contributors
6+
* SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com>
7+
*/
8+
9+
declare(strict_types=1);
10+
11+
namespace Respect\Fluent\Attributes;
12+
13+
use Attribute;
14+
15+
/**
16+
* Declares how a composable prefix relates to its wrapped node's subject.
17+
*
18+
* A prefix without this attribute yields no assurance for composed names:
19+
* tools must drop, not copy, the wrapped node's assurance.
20+
*/
21+
#[Attribute(Attribute::TARGET_CLASS)]
22+
final readonly class AssuranceSubject
23+
{
24+
/** @param string|list<string>|null $type Container type assured about the input */
25+
public function __construct(
26+
public AssuranceSubjectMode $mode,
27+
public string|array|null $type = null,
28+
) {
29+
}
30+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
/*
4+
* SPDX-License-Identifier: ISC
5+
* SPDX-FileCopyrightText: (c) Respect Project Contributors
6+
* SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com>
7+
*/
8+
9+
declare(strict_types=1);
10+
11+
namespace Respect\Fluent\Attributes;
12+
13+
enum AssuranceSubjectMode: string
14+
{
15+
/**
16+
* Same subject as the wrapped node: its assurance passes through, modified.
17+
*
18+
* A Wrap prefix's own #[Assurance(type:)] declares its bypass set — inputs
19+
* it admits itself, in union with whatever the wrapped node assures. It is
20+
* only meaningful in composition, never as a claim about direct calls;
21+
* `exact` on it means the bypass set is an exact characterization.
22+
*/
23+
case Wrap = 'wrap';
24+
25+
/** The wrapped node validates each element: assurance becomes iterable<T> */
26+
case Elements = 'elements';
27+
28+
/** The wrapped node validates a derived subject: only the container type is assured */
29+
case Container = 'container';
30+
}

src/Builders/Append.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
use function array_values;
1414

15-
/** @extends FluentBuilder<list<object>, mixed, never> */
15+
/** @extends FluentBuilder<list<object>, mixed, never, true> */
1616
readonly class Append extends FluentBuilder
1717
{
1818
public function attach(object ...$nodes): static

src/Builders/FluentBuilder.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
* @template TNodes of list<object>
2121
* @template TSure
2222
* @template TSureNot
23+
* @template TExact of bool = true
2324
*/
2425
abstract readonly class FluentBuilder
2526
{

0 commit comments

Comments
 (0)