Skip to content

feat(skip): add #[Skip] attribute to skip tests declaratively - #314

Merged
roxblnfk merged 48 commits into
php-testo:1.xfrom
Meacue:feat/skip-attribute
Sep 18, 2026
Merged

roxblnfk merged 48 commits into
php-testo:1.xfrom
Meacue:feat/skip-attribute

Conversation

@Meacue

@Meacue Meacue commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🔍 What was changed

#[Skip('reason')] on a test method, class or function: the body never runs and the test is reported as Status::Skipped, counted in the totals, carrying {testId} is skipped via #[Skip] ==> {reason} in the result's failure message. JUnit, TeamCity and HTML show that message; the terminal prints the skipped line without it.

Ships as its own package, testo/skip. SkipPlugin joins the default suite plugins, and the attribute additionally wires its own interceptor, so the skip still works in a suite that dropped the plugin — only the lifecycle hooks are then unaware of it.

core/ gains one flag, TestDefinition::$skipped, plus the accessors and the runner fallback that read it. No new Status case.

How it works

  • LocationSkipLocatorInterceptor flags the marked tests on their definitions. They stay active, so the case keeps them, runs and reports them.
  • RunSkipInterceptor returns the Skipped result at the entry of the per-test pipeline, outer to fibers, data providers, #[Retry]/#[Repeat], coverage and #[BeforeTest]/#[AfterTest]. None of them engage, and a data-driven test yields a single Skipped entry without calling its provider.
  • Case hooks#[BeforeClass]/#[AfterClass] run while the case still has a test to run. When every test of it is skipped they stay silent and the case class is never constructed.

Semantics

  • Targets a method, a class (every test of the case) or a free function; inherited from parent classes, traits and overridden methods, and a method-level #[Skip] wins over the class-level one, reason included.
  • A run consisting only of skipped tests is a success, exit code 0.
  • reason is optional and the attribute is not repeatable.
  • Inert on a non-test member and on #[Bench]/#[TestInline] targets.

Review notes

  • A skipped test has to stay active. Deactivating it looks equivalent and is not: SuiteFactory drops a case left without active tests, so a fully skipped case would vanish from the report together with the tests it was supposed to show.
  • throw new SkipTest(...) from an interceptor does not skip anything — it leaves the pipeline and lands as Status::Aborted. The result has to be returned instead.

Checklist

Documentation

Skills testo-write-tests, testo-plugin-author and testo-flaky-tests carry the new contract.

@Meacue
Meacue marked this pull request as ready for review September 2, 2026 13:51
@Meacue
Meacue requested a review from a team as a code owner September 2, 2026 13:51
Comment thread plugin/test/src/Skip.php Outdated
Meacue added a commit to Meacue/testo that referenced this pull request Sep 2, 2026
…back

Review feedback on php-testo#314: the attribute now implements Interceptable and
declares #[FallbackInterceptor(SkipInterceptor::class)], mirroring Retry.
The interface alone would break class-level usage (an Interceptable without
a fallback alias makes the attributes interceptor throw at pipeline build),
so the pair goes together. The TestPlugin registration stays: the case-level
fallback path only reads class attributes, so a method-level #[Skip] still
needs the registered instance; the duplicate spawn for class-level cases is
collapsed by the sorter's conflict policy.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
@Meacue
Meacue marked this pull request as draft September 2, 2026 16:55
@Meacue

Meacue commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Found some bugs, fixing them right now. Marked this PR as draft until they're fixed.

Meacue added a commit to Meacue/testo that referenced this pull request Sep 2, 2026
…rviving tests

LifecycleInterceptor::collectCaseFunctions() resolved a function-based
case's source file by scanning the case's remaining tests for the first
ReflectionFunction. Any outer case interceptor that prunes tests before
the lifecycle interceptor runs (it is the innermost, order PHP_INT_MAX)
could therefore empty the scan: with #[Skip] (PR php-testo#314)
parking every #[Test] function of a file, the path came back null and
the #[BeforeClass]/#[AfterClass] hooks were silently dropped, breaking
the documented Skip contract that "BeforeClass/AfterClass hooks still
run - also when every test of the case is skipped". Class-based cases
were unaffected (hooks come from CaseDefinition::$reflection).

The path now comes from CaseDefinition::$file, which every locator fills
from the tokenized file the case was discovered in, so hook discovery no
longer depends on who pruned the test set or when (the same hole existed
for a fully filtered-out case).

Motive check: the surviving-test scan (da29d6a, 2026-07-01) predates
CaseDefinition::$file (f17f4ed, 2026-08-02); it was the only way to get
the path at the time and was simply never migrated - no semantic reason
to keep it.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
@Meacue
Meacue force-pushed the feat/skip-attribute branch from 0c06301 to 4664f13 Compare September 3, 2026 14:29
roxblnfk pushed a commit to Meacue/testo that referenced this pull request Sep 4, 2026
…back

Review feedback on php-testo#314: the attribute now implements Interceptable and
declares #[FallbackInterceptor(SkipInterceptor::class)], mirroring Retry.
The interface alone would break class-level usage (an Interceptable without
a fallback alias makes the attributes interceptor throw at pipeline build),
so the pair goes together. The TestPlugin registration stays: the case-level
fallback path only reads class attributes, so a method-level #[Skip] still
needs the registered instance; the duplicate spawn for class-level cases is
collapsed by the sorter's conflict policy.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
@roxblnfk
roxblnfk force-pushed the feat/skip-attribute branch from 4664f13 to 6059ac0 Compare September 4, 2026 09:50
A plain marker attribute (method, class, or free function) plus a registered
case-level TestCaseRunInterceptor: parked tests are filtered out of the case
before any lifecycle hooks and reported back as synthetic Skipped results with
a composed reason ("{testId} is skipped via #[Skip] ==> {reason}"), delivered
through a batch-runner wrapper so reporters render them inside the case block
with no reporter changes.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
Unit: interceptor filtration before $next, reason composition, synthetic
result shape, origin attribute, batch-runner wrapping, pipeline events.
Feature: full v1 contract over a stub catalog - method/class/function targets,
inheritance from parent and trait, lifecycle hook contract, instantiation
rules, DataProvider/Retry/Repeat/fiber composition, summary arithmetic and
the success of an only-parked run. Plus one narrow JUnitWriter case pinning
that a Skipped result renders its failure message.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
…y-tests

write-tests gains a "Parking a test" section with the skip-tool comparison
table and the runtime contract; plugin-author points to SkipInterceptor as the
canonical shipped "return, do not throw" example; flaky-tests adds the parking
branch to the decision flow.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
feat bumps testo/test 0.1.6 -> 0.2.0 on release: widen the root require to
"0.1.6 - 1" so composer resolves both before and after the release, and move
the path-repository version to 0.2.x-dev per docs/spec/plugin-creation.md.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
…back

Review feedback on php-testo#314: the attribute now implements Interceptable and
declares #[FallbackInterceptor(SkipInterceptor::class)], mirroring Retry.
The interface alone would break class-level usage (an Interceptable without
a fallback alias makes the attributes interceptor throw at pipeline build),
so the pair goes together. The TestPlugin registration stays: the case-level
fallback path only reads class attributes, so a method-level #[Skip] still
needs the registered instance; the duplicate spawn for class-level cases is
collapsed by the sorter's conflict policy.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
Class-level #[Skip] spawns a duplicate interceptor instance through the
fallback alias; ConflictPolicy::First (previously implicit) collapses it
onto the instance registered by TestPlugin. Spell the policy out and
explain why, mirroring DataProviderInterceptor.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
The fallback interceptor is spawned from class attributes only
(AttributesInterceptor::runTestCase), so without TestPlugin it rescues
a class-level #[Skip] alone; a method- or function-level #[Skip] is
inert in that setup. The previous wording implied full standalone
coverage.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
…ameter

The instance is built both by the container (TestPlugin registration,
where an attribute cannot be resolved) and by the injector on a fallback
spawn (where the attribute is passed and ignored); attributes are looked
up per case in findParked(). A Skip parameter would break the container
path at pipeline construction.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
test(test): pin the declared testType of SkipInterceptor

SkipInterceptor declares testType: TestType::Test, so the type filter
drops it for #[Bench] and #[TestInline] cases and the attribute has no
effect there. Add the point to the Skip runtime contract and the
write-tests skill, and pin the declaration with a unit test.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
TerminalLogger reads the description from the result attributes, where
the regular test path stamps it; the synthetic result built for a parked
test lacked it, so the PHPDoc description was not rendered. Stamp
TestDefinition::getDescription() the way TestRunner does.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
…tPlugin

Every existing feature test runs through suite defaults where TestPlugin
is registered, so the FallbackInterceptor declaration itself was never
exercised. Run a convention-discovered catalog with
SuitePlugins::without(TestPlugin::class) and assert the class-level
#[Skip] still parks every test, with exactly one result per test.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
…s live

The dedup invariant (registered interceptor + fallback spawn collapse
into one delivery) was held only by the arithmetic of the summary tests.
Name it: a full-application run of a class-level parked catalog yields
exactly one result per test in its CaseResult.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
Integration proof of the #[Skip] contract documented on the attribute:
BeforeClass/AfterClass hooks still run when every test of the case is parked.
Drives the real pipeline over a function-based and a class-based catalog and
checks the hooks fire exactly once while the per-test hooks stay silent.

Complements the Skip-independent unit regression test that landed with the
lifecycle fix: that one pins hook discovery on a pruned case in isolation,
this one pins the contract with the actual #[Skip] interceptor doing the
pruning.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
… neighbor

Audit follow-up. namedReasonArgument asserted a PHP language guarantee (named
argument binding to the single promoted parameter) and could not fail while its
positional twin passes, so it is removed. enabled_function in skip_functions.php
was asserted by nothing: TestRunner::runTest() returns only the requested
result, so a throwing neighbor would go unnoticed. The new feature test runs it
and expects Passed, closing the one untested cell of the semantics matrix: an
enabled function of a partially parked file survives the wrapped batch runner.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
…n order

The two function-based tests resolved their targets only because an earlier
test's inner catalog run happened to include skip_functions.php: functions are
not autoloadable, and TestRunner::runTest() throws "Invalid test function
provided." when the name does not exist yet. Running either test in isolation
(--filter) errored before any assertion. Load the stub in the constructor, the
same way the other function-based feature tests already do.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>
The docs no longer promise the skip reason in every report, and the construction, fiber and provider checks now assert per-run evidence.

Assisted-By: Claude Fable 5.1 <noreply@anthropic.com>
php-testo#318 removed TestDefinitions::undefine() and gave TestDefinition an
`active` flag. The interceptor now flips that flag the way the filter
plugin does, walks the case's active tests only, and the docblocks
describe the mechanic that is left instead of the one that is gone.
Three unit tests pin deactivation-not-discard, an inert #[Skip] on a
non-test member, and no Skipped report for an already-filtered test;
a fourth pins the declared order and conflict policy.

Assisted-By: Claude Fable 5.1 <noreply@anthropic.com>
@Meacue
Meacue force-pushed the feat/skip-attribute branch from 6379b2a to 58bd873 Compare September 8, 2026 07:00
@Meacue
Meacue marked this pull request as ready for review September 8, 2026 07:01
@Meacue

Meacue commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

@roxblnfk sorry for the pause. PR is ready for review, rebased onto the current 1.x.

…case pipeline

The case pipeline is built from class attributes alone, so an attribute that has to act on the whole case from a single test (take it out before the class-level hooks) could only be wired by a plugin. Scanning every `Interceptable` on the tests would make a method-level `#[RunInFiber]` install a fiber batch runner for the entire case, hence an explicit opt-in interface. Test-level attributes are not stamped on `CaseInfo::$attributes`.

Assisted-By: Claude Fable 5.1
@roxblnfk
roxblnfk force-pushed the feat/skip-attribute branch from 04d7d87 to bda23e9 Compare September 8, 2026 12:01
`#[Skip]` is a one-attribute plugin of the same shape as `testo/retry` and `testo/repeat`, and the plugin naming rule puts the top-level class `\Testo\Skip` in a package of the same short name. The manifest starts at 0.0.0 so the first release is 0.1.0.

Assisted-By: Claude Fable 5.1
… the `#[Skip]` docblocks

Assisted-By: Claude Fable 5.1
…stPlugin`

docs(skills): point `#[Skip]` guidance at the self-wiring attribute and document `CaseInterceptable`

The attribute is wired by its `#[FallbackInterceptor]` alone: as a `CaseInterceptable` it reaches the case pipeline from a method or function too, so no plugin registers anything and the attribute works in any suite. `ConflictPolicy::First` now collapses the instances spawned per `#[Skip]` occurrence.

Assisted-By: Claude Fable 5.1
@roxblnfk
roxblnfk force-pushed the feat/skip-attribute branch from bda23e9 to b9fd5d6 Compare September 8, 2026 13:05
…in convention

The root requirement takes the shape every freshly added plugin uses, and the plugin pins the core release that ships `CaseInterceptable` with an explicit patch component, like its neighbours.

Assisted-By: Claude Opus 5
CI pins `COMPOSER_ROOT_VERSION` to `resources/version.json`, so the root package is the released core (0.10.47) and a constraint on the unreleased 0.11.0 makes the whole install unresolvable. Every other plugin names an already-released core the same way; bump this one once 0.11.0, which ships `CaseInterceptable`, is out.

Assisted-By: Claude Opus 5
…stating it

The case-level scan walks the active tests, and that is the load-bearing part: an attribute on a test the filter dropped must not shape the case. The stale "merge and instantiate" line went with it — instantiation now happens a few lines above.

Assisted-By: Claude Opus 5
refactor(core)!: drop `CaseInterceptable`

A skipped test is a fact about the definition, not about the run: with `TestDefinition::$skipped` a locator can state it ahead of the run, and the lifecycle plugin and the test runner read it instead of a case interceptor having to deactivate tests and hand back synthetic results. A skipped test stays active, so the case keeps it and reports it, and the runner returns the Skipped result itself when no interceptor did.

`CaseInterceptable` existed only to get a method-level `#[Skip]` onto the case pipeline; with the flag on the definition nothing reaches the case pipeline from a test, and the case pipeline reads class attributes alone again.

Assisted-By: Claude Fable 5.1
…est to run

A skipped test has no body to set up, so the per-test hooks stay silent for it, and a case whose active tests are all skipped, or that an outer interceptor left without a test, gets no class-level hooks either: there is nothing they would prepare for. Previously the class hooks fired for such a case because hook discovery only looked at the non-tests.

Assisted-By: Claude Fable 5.1
… per-test pipeline

docs(skills): describe the skip flag instead of `CaseInterceptable`

`SkipPlugin`, part of the default suite plugins, sets `TestDefinition::$skipped` from a case locator, which is what keeps the lifecycle hooks away from the skipped tests and from a fully skipped case. `SkipInterceptor` becomes a per-test interceptor spawned by the attribute, ordered outer to the fiber wrap and the data provider, and returns the Skipped result with the reason; the batch-runner wrap and the hand-dispatched events are gone with it. The Skipped result does not depend on the plugin: without it only the hooks are left unaware of the skip.

Assisted-By: Claude Fable 5.1
…what reads them

docs(core): cut the skipped-test comment back to its placement facts
docs(lifecycle): drop the comment restating the skipped flag

`SkipInterceptor` claimed it never throws because a throw is a runtime skip that reports differently. The real constraint is that a throw from an interceptor leaves the pipeline and lands as `Aborted`, so the result has to be returned.

The rest is scope: a comment holds what its own lines cannot. What the flag makes lifecycle hooks and the runner do belongs at those two sites, where it already is, so `SkipLocatorInterceptor` keeps only the split against `SkipInterceptor` — flag here, reason there. In its place that docblock now separates the two type mechanisms: `testType` drops the interceptor from a run filtered to other types, while a located file still yields cases of every type, which is why the per-case check must stay.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@roxblnfk
roxblnfk requested a balanced review from Copilot September 18, 2026 17:25
@roxblnfk roxblnfk changed the title feat(test): add #[Skip] attribute to skip tests declaratively PR-1. feat(skip): add #[Skip] attribute to skip tests declaratively Sep 18, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Lifecycle contract, inherited-reason selection, and split-package compatibility issues remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds a declarative #[Skip] attribute that reports tests as skipped without executing their bodies.

Changes:

  • Adds the testo/skip plugin and core skipped-definition support.
  • Integrates lifecycle handling and comprehensive tests.
  • Updates documentation, dependencies, and release configuration.
File summaries
File Description
tools/phpunit/phpunit.xml Includes skip plugin coverage.
tools/phpunit/infection.phpunit.json Adds skip mutation scope.
tests/Output/Unit/JUnit/JUnitWriterTest.php Tests skipped-reason XML output.
tests/Core/Definition/TestDefinitionsTest.php Tests skipped filtering.
tests/Application/Stub/Skipped/SkippedByLocator.php Adds skipped-definition fixture.
tests/Application/Stub/Skipped/FlagSkippedPlugin.php Adds locator flagging fixture.
tests/Application/Feature/Runner/SkippedDefinitionTest.php Tests core skipped execution.
testo.php Registers skip test suites.
skills/testo-write-tests/SKILL.md Documents #[Skip] usage.
skills/testo-plugin-author/SKILL.md Documents skip integration.
skills/testo-flaky-tests/SKILL.md Documents skipping flaky tests.
resources/version.json Versions the skip plugin.
plugin/skip/tests/Unit/SkipPluginTest.php Tests plugin registration.
plugin/skip/tests/Unit/SkipAttributeTest.php Tests attribute metadata.
plugin/skip/tests/Unit/Internal/SkipLocatorInterceptorTest.php Tests definition flagging.
plugin/skip/tests/Unit/Internal/SkipInterceptorTest.php Tests skipped-result creation.
plugin/skip/tests/Unit/Fixture/SkipMixedMethodsFixture.php Provides mixed-method fixtures.
plugin/skip/tests/Unit/Fixture/SkipClassLevelFixture.php Provides class-level fixtures.
plugin/skip/tests/suites.php Configures skip test suites.
plugin/skip/tests/Stub/SkipSummary/OnlySkipped/OnlySkippedStub.php Covers all-skipped summaries.
plugin/skip/tests/Stub/SkipSummary/Mixed/MixedStub.php Covers mixed summaries.
plugin/skip/tests/Stub/SkipStandalone/StandaloneSkippedTest.php Covers standalone discovery.
plugin/skip/tests/Stub/Skip/SkipWithRetryStub.php Covers retry composition.
plugin/skip/tests/Stub/Skip/SkipWithRepeatStub.php Covers repeat composition.
plugin/skip/tests/Stub/Skip/SkipWithHooksStub.php Covers lifecycle composition.
plugin/skip/tests/Stub/Skip/SkipWithDataProviderStub.php Covers provider suppression.
plugin/skip/tests/Stub/Skip/SkipTraitStub.php Covers trait inheritance.
plugin/skip/tests/Stub/Skip/SkipParentStub.php Defines inherited class skip.
plugin/skip/tests/Stub/Skip/SkipOverridingMethodStub.php Covers method inheritance.
plugin/skip/tests/Stub/Skip/SkipOverriddenMethodParentStub.php Defines inherited method skip.
plugin/skip/tests/Stub/Skip/SkipNonStaticHookStub.php Covers non-static hooks.
plugin/skip/tests/Stub/Skip/SkipMethodStub.php Covers method-level skips.
plugin/skip/tests/Stub/Skip/SkipMarkerTrait.php Defines trait skip marker.
plugin/skip/tests/Stub/Skip/SkipInFiberStub.php Covers fiber composition.
plugin/skip/tests/Stub/Skip/SkipConstructorSpyStub.php Checks construction suppression.
plugin/skip/tests/Stub/Skip/SkipClassLevelStub.php Covers class-level skipping.
plugin/skip/tests/Stub/Skip/SkipClassAndMethodStub.php Covers reason precedence.
plugin/skip/tests/Stub/Skip/SkipChildStub.php Covers parent inheritance.
plugin/skip/tests/Stub/Skip/skip_functions.php Covers function-level skipping.
plugin/skip/tests/Stub/PipelineEntrySpyPlugin.php Tracks pipeline entry.
plugin/skip/tests/Feature/SkipSummaryTest.php Tests summary behavior.
plugin/skip/tests/Feature/SkipFeatureTest.php Tests end-to-end semantics.
plugin/skip/tests/Feature/SkipFallbackStandaloneTest.php Tests fallback interception.
plugin/skip/src/SkipPlugin.php Registers skip location handling.
plugin/skip/src/Internal/SkipLocatorInterceptor.php Flags skipped definitions.
plugin/skip/src/Internal/SkipInterceptor.php Produces skipped results.
plugin/skip/Skip.php Defines the public attribute.
plugin/skip/README.md Documents the package.
plugin/skip/composer.json Defines package metadata.
plugin/skip/.github/workflows/close-prs.yml Configures mirror PR handling.
plugin/lifecycle/tests/Unit/Internal/LifecycleInterceptorTest.php Tests skipped lifecycle behavior.
plugin/lifecycle/tests/Stub/FullySkipped/FullySkippedClassStub.php Adds skipped-class fixture.
plugin/lifecycle/tests/Stub/FullySkipped/fully_skipped_functions.php Adds skipped-function fixtures.
plugin/lifecycle/tests/Feature/FullySkippedCaseFeatureTest.php Tests fully skipped cases.
plugin/lifecycle/src/Internal/LifecycleInterceptor.php Integrates skipped definitions.
core/Pipeline/Internal/AttributesInterceptor.php Removes a redundant comment.
core/Core/Definition/TestDefinitions.php Adds skipped-state filtering.
core/Core/Definition/TestDefinition.php Adds the skipped flag.
core/Application/Internal/Runner/TestRunner.php Handles flagged skipped tests.
core/Application/Config/Plugin/SuitePlugins.php Enables the skip plugin.
composer.json Adds skip package wiring.
.github/workflows/split-publish.yml Adds skip release tags.
.github/.release-please-config.json Configures skip releases.
Review details

Suppressed comments (2)

plugin/skip/Skip.php:60

  • This introduces a public test attribute with faithful PHPUnit/Pest equivalents, but omits the bridge Rector rules, fixtures, and FEATURE_PARITY.md update required by AGENTS.md. Although issue #313 defers that work to PR 2, the repository guideline requires public feature parity in the same change; add the conversions (including class-level handling), or add the documented stub and TODO when faithful conversion is not possible.
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)]
#[FallbackInterceptor(SkipInterceptor::class)]
final readonly class Skip implements Interceptable

plugin/skip/README.md:28

  • “Every report … with its reason” overpromises the reporting behavior: terminal output omits the reason, and compact JSON only includes the skipped count. Limit the claim to the reporters that render the message.
Marks a test, a test class or a test function as skipped without deleting or hiding it. The test is not executed, but stays in every report as Skipped with its reason, so parked tests remain visible until someone returns to them.
  • Files reviewed: 63/63 changed files
  • Comments generated: 4
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread plugin/lifecycle/src/Internal/LifecycleInterceptor.php
Comment thread plugin/lifecycle/src/Internal/LifecycleInterceptor.php
Comment thread plugin/skip/composer.json
Comment thread plugin/skip/src/Internal/SkipInterceptor.php
…rviving interceptor

docs(skip): stop naming other plugins in the `#[Skip]` docs
docs(core): say the skipped flag binds its readers, not lifecycle hooks

`Reflection` lists an overriding function's own attribute before the one it inherits, while `ConflictPolicy::Last` kept the final interceptor of the position — so an override that repeated `#[Skip]` with its own reason reported the prototype's. The interceptor no longer takes the occurrence that spawned it: it reads the nearest declaration back with `limit: 1`, falling back to the class, which makes the reported reason independent of how the pipeline collapses the duplicates. `ConflictPolicy::First` now collapses them, since the instances are interchangeable.

A skipped test is a core state, so the attribute documents that state and leaves it to `#[Retry]`, the lifecycle hooks and the rest to say what each of them does with it. Both meanings stay distinguishable: the flag is set before the run, a thrown `SkipTest` only once the body has started.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… hooks for an empty case

Non-tests do outlive the filtering of tests, which is what keeps a partially filtered or partially skipped case finding all of its hooks. The claim built on top of that — hooks running for a case whose tests were all filtered out — named the wrong cause: a filter deactivates at location time and such a case is dropped before the run. That state came from a case interceptor deactivating during the run, and the hooks no longer fire for it.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs(skip): halve the `#[Skip]` docblock

A function has no class to fall back on, so the fallback that composes the message without a reason runs a different path there than the method it was pinned on. The spy list gains the function too, so a regression that let it reach the pipeline is caught where its neighbours already are.

The docblock had grown by accretion to forty-four lines for a one-parameter attribute and restated itself: the worked example repeated the paragraph under it, and the message format was given twice. Everything a reader cannot get from the code survives. The reporters that show the reason are named in the user guide instead, where the rest of their behaviour already is.

Assisted-By: Claude Fable 5.1 <noreply@anthropic.com>
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@roxblnfk
roxblnfk merged commit 19638da into php-testo:1.x Sep 18, 2026
14 of 16 checks passed
@roxblnfk roxblnfk mentioned this pull request Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants