Skip to content

Commit 8469e6e

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). - Add AssuranceFrom::TypeString (an instance of the class named by a class-string argument). #[AssuranceParameter] is now purely argument indexing (any position, default first); `from` selects the derivation, decoupled from class-string-ness. - 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 8469e6e

14 files changed

Lines changed: 256 additions & 30 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: 68 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,18 @@ final readonly class ValidatorBuilder extends Append
7575

7676
### AssuranceParameter
7777

78-
Marks a parameter for assurance type resolution. Contextual based on where it
79-
appears:
78+
Selects **which** argument carries the assurance information — purely an index,
79+
defaulting to the first parameter when absent. It does not itself imply any
80+
particular derivation; `from:` decides how the selected argument maps to the
81+
assured type. Contextual based on where it appears:
8082

81-
**On a constructor parameter** — the parameter's value determines the assurance
82-
type (replaces the old `parameter:` string reference):
83+
**On a constructor parameter** — selects which argument is the type source. The
84+
example below pairs it with `from: TypeString` so the class-string argument
85+
narrows to an instance of that class (replaces the old `parameter:` string
86+
reference):
8387

8488
```php
85-
#[Assurance]
89+
#[Assurance(from: AssuranceFrom::TypeString, exact: true)]
8690
final readonly class Instance implements Validator
8791
{
8892
public function __construct(
@@ -302,8 +306,9 @@ FluentAnalysis reads this to determine how each node narrows a type:
302306
#[Assurance(type: 'int')]
303307
final readonly class IntType implements Validator { /* ... */ }
304308

305-
// Type from a constructor parameter (use #[AssuranceParameter])
306-
#[Assurance]
309+
// Instance of the class named by a class-string argument
310+
// (#[AssuranceParameter] selects which argument; from: TypeString the derivation)
311+
#[Assurance(from: AssuranceFrom::TypeString, exact: true)]
307312
final readonly class Instance implements Validator
308313
{
309314
public function __construct(
@@ -346,34 +351,43 @@ final readonly class When implements Validator
346351
public function __construct(Validator $when, Validator $then, Validator $else) {}
347352
}
348353

349-
// Modifier: exclude type instead of asserting it
354+
// Exact: passes if and only if the input is of the declared type
355+
#[Assurance(type: 'int', exact: true)]
356+
final readonly class IntType implements Validator { /* ... */ }
357+
358+
// Modifier on a Wrap prefix: negate the wrapped node's assurance
350359
#[Assurance(modifier: AssuranceModifier::Exclude)]
360+
#[AssuranceSubject(AssuranceSubjectMode::Wrap)]
351361
final readonly class Not implements Validator { /* ... */ }
352362

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

358370
Properties:
359371

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 |
372+
| Property | Type | Purpose |
373+
|----------------|------------------------------|------------------------------------------------------------------|
374+
| `type` | `string\|list<string>\|null` | Fixed type string (e.g. `'int'`); a list means their union |
375+
| `from` | `?AssuranceFrom` | Derive type from a method argument |
376+
| `compose` | `?AssuranceCompose` | Combine assurances from child validators |
377+
| `composeRange` | `?array{int, int\|null}` | Subset of arguments to compose (`[from, to]`, null = open-ended) |
378+
| `modifier` | `?AssuranceModifier` | Modify how the assurance is applied |
379+
| `exact` | `bool` | The node passes *iff* the input is of the declared type |
367380

368381
### AssuranceFrom (enum)
369382

370383
Determines how the assured type is derived from a method argument:
371384

372385
| Case | Meaning |
373386
|------------|---------------------------------------------------------|
374-
| `Value` | The argument's literal type (e.g. `42``42`) |
375-
| `Member` | The iterable value type (e.g. `['a','b']``'a'\|'b'`) |
376-
| `Elements` | An array of the inner assurance type |
387+
| `Value` | The argument's literal type (e.g. `42``42`) |
388+
| `Member` | The iterable value type (e.g. `['a','b']``'a'\|'b'`) |
389+
| `Elements` | An array of the inner assurance type |
390+
| `TypeString` | An instance of the class named by a class-string argument |
377391

378392
### AssuranceCompose (enum)
379393

@@ -388,7 +402,37 @@ Determines how child assurances are combined:
388402

389403
Modifies how an assurance is applied:
390404

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,21 @@
1010

1111
namespace Respect\Fluent\Attributes;
1212

13+
/**
14+
* How the assured type is derived from the indexed argument (see #[AssuranceParameter],
15+
* which selects which argument; this enum selects the derivation).
16+
*/
1317
enum AssuranceFrom: string
1418
{
19+
/** The argument's own value type is the assured type (e.g. identical($x)). */
1520
case Value = 'value';
21+
22+
/** The argument is a haystack; the assured type is its member type (e.g. in($haystack)). */
1623
case Member = 'member';
24+
25+
/** The argument validates elements; the assured type is iterable of them (e.g. each($v)). */
1726
case Elements = 'elements';
27+
28+
/** The argument is a class-string; the assured type is an instance of it (e.g. instance($class)). */
29+
case TypeString = 'type-string';
1830
}

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

0 commit comments

Comments
 (0)