Prophet idea: flag a class doing work out of its purpose (role-vs-behaviour incoherence)
Motivation
A cleanup pass over a real ~930-file consumer (the jessegall/workflows app) turned up the same shape
again and again: a class whose name/role declares one job but whose body is a second engine. The
clearest cases:
DefinitionRegistry — a registry (store/lookup) that is mostly a reflection compiler (~28 private
reflect*/extract* methods, imports ReflectionClass + Spatie\StructureDiscoverer\Discover).
TestRunOutcomeData — a Spatie Data DTO that carries a 30-method graph-walking assembler and even
injects NodeDescriptorRegistry to build itself.
NodeDescriptorRegistry — a registry that is also a descriptor factory (make*TriggerDescriptor, …).
BridgeToTypedContextPipe — a Pipe hosting a stateless type-coercion library.
This is the same instinct behind RegistryNamingHonesty / RegistryPattern / RegistryBaseBypass —
"the name should match the single job" — just generalised beyond registries to every role the codebase
names (*Registry, *Data/DTO, *Pipe/Node, *Resolver, *Factory, …).
Is it possible? Yes — if it does NOT try to "measure SRP"
Full single-responsibility is undecidable and subjective; a generic "too many methods" prophet would be a
false-positive machine (a fluent builder, a service provider's binding list, and a cohesive parser are all
legitimately large). The package already solved this category the right way: marker-driven + explicit
exemptions (RegistryReturnContract intentionally skips the un-marked "looks like a registry" heuristic).
Apply the same discipline here. Detect incoherence, not size.
Detection design (AST, low-FP)
Primary signal — role marker × forbidden collaborator (sharp, AST-only):
A class carries a role (by name suffix, base class, interface, or attribute), and each role has a set of
collaborators it has no business importing/using. The import list is the tell:
| Role (marker) |
Out-of-purpose if it imports / uses… |
*Registry / extends Registry / #[Registry] |
ReflectionClass, Spatie\StructureDiscoverer\*, DOMDocument, HTTP/DB/filesystem — a lookup table doesn't reflect, scan, parse, or do I/O |
Spatie Data / readonly DTO |
injected services, ReflectionClass, graph/DB walking, private static builder clusters — a DTO is a payload, not an assembler |
*Pipe / Node |
a large stateless util/coercion cluster unrelated to handle() |
*Resolver / *Factory |
a registration store (register()/add() + a keyed array) — that's a registry, not a resolver |
A *Registry that imports ReflectionClass or StructureDiscoverer is almost always doing too much — very
low FP, and it would have flagged DefinitionRegistry and NodeDescriptorRegistry directly.
Secondary signals (raise confidence; never fire alone):
- verb-cluster diversity — methods fall into ≥2 distinct prefix families (
reflect* + discover* +
hydrate*; or make*Trigger* + make*Pipe*). Multiple verb families on one noun = multiple jobs.
- shape mix — a class that has BOTH a "store" (a keyed-array property +
register/add mutator) AND a
heavy non-lookup engine (reflection/parsing/I/O).
- size proxies (private-method count, import count, length) — only as a tie-breaker behind a marker, never
the trigger.
Mandatory exemptions (mirror the existing FP discipline):
ServiceProvider (binding lists / const arrays are legitimately import-heavy),
- a fluent builder/DSL whose builder is its executor (e.g.
Pipeline),
- anything the consumer opts out of via config or an attribute.
Why this is safe (verified against the same codebase)
It would fire on DefinitionRegistry, NodeDescriptorRegistry, TestRunOutcomeData,
BridgeToTypedContextPipe — and would correctly stay silent on Pipeline.php (cohesive builder, no
role/forbidden-collaborator mismatch) and WorkflowsServiceProvider.php (provider exemption), which a naive
size threshold would have flagged. The marker×collaborator core is what keeps the FP rate down.
Shape / tier
- Advisory, never
[AUTO-FIXABLE] — extraction is a design call (same stance as RegistryPattern,
PreferTotalOverNullable). The finding should suggest the cut ("a *Registry importing
ReflectionClass — extract the reflection into a …Reflector collaborator"), not rewrite anything.
- Points back to a skill (a
single-responsibility / role-coherence skill, or fold into registry).
NeedsCodebaseIndex optional — the primary signal is class-local (imports + markers + method names), so
it can run without the call graph.
Naming
OutOfPurposeProphet (matches your phrasing). Alternatives: RoleCoherenceProphet,
PurposeDriftProphet, MixedResponsibilitiesProphet, ClassDoingTooMuchProphet. It generalises
RegistryNamingHonesty — consider whether the registry-specific ones become configured instances of this.
Open questions
- Role catalog: ship a default set (Registry/Data/Pipe/Resolver/Factory) + let consumers add
role => {markers, forbidden} in config?
- Should the "store + engine" shape mix be its own sub-rule (it overlaps
RegistryBaseBypass)?
- Confidence model: require marker + (forbidden-collaborator OR ≥2 verb-clusters) to fire, with size as a pure tie-breaker — agree?
Happy to prototype the marker×forbidden-collaborator core against the workflows codebase as the proving
ground (I have a snapshot with the known-positive and known-negative cases).
Prophet idea: flag a class doing work out of its purpose (role-vs-behaviour incoherence)
Motivation
A cleanup pass over a real ~930-file consumer (the
jessegall/workflowsapp) turned up the same shapeagain and again: a class whose name/role declares one job but whose body is a second engine. The
clearest cases:
DefinitionRegistry— a registry (store/lookup) that is mostly a reflection compiler (~28 privatereflect*/extract*methods, importsReflectionClass+Spatie\StructureDiscoverer\Discover).TestRunOutcomeData— a SpatieDataDTO that carries a 30-method graph-walking assembler and eveninjects
NodeDescriptorRegistryto build itself.NodeDescriptorRegistry— a registry that is also a descriptor factory (make*TriggerDescriptor, …).BridgeToTypedContextPipe— aPipehosting a stateless type-coercion library.This is the same instinct behind
RegistryNamingHonesty/RegistryPattern/RegistryBaseBypass—"the name should match the single job" — just generalised beyond registries to every role the codebase
names (
*Registry,*Data/DTO,*Pipe/Node,*Resolver,*Factory, …).Is it possible? Yes — if it does NOT try to "measure SRP"
Full single-responsibility is undecidable and subjective; a generic "too many methods" prophet would be a
false-positive machine (a fluent builder, a service provider's binding list, and a cohesive parser are all
legitimately large). The package already solved this category the right way: marker-driven + explicit
exemptions (RegistryReturnContract intentionally skips the un-marked "looks like a registry" heuristic).
Apply the same discipline here. Detect incoherence, not size.
Detection design (AST, low-FP)
Primary signal — role marker × forbidden collaborator (sharp, AST-only):
A class carries a role (by name suffix, base class, interface, or attribute), and each role has a set of
collaborators it has no business importing/using. The import list is the tell:
*Registry/extends Registry/#[Registry]ReflectionClass,Spatie\StructureDiscoverer\*,DOMDocument, HTTP/DB/filesystem — a lookup table doesn't reflect, scan, parse, or do I/OData/ readonly DTOReflectionClass, graph/DB walking,private staticbuilder clusters — a DTO is a payload, not an assembler*Pipe/Nodehandle()*Resolver/*Factoryregister()/add()+ a keyed array) — that's a registry, not a resolverA *Registry that imports ReflectionClass or StructureDiscovereris almost always doing too much — verylow FP, and it would have flagged
DefinitionRegistryandNodeDescriptorRegistrydirectly.Secondary signals (raise confidence; never fire alone):
reflect*+discover*+hydrate*; ormake*Trigger*+make*Pipe*). Multiple verb families on one noun = multiple jobs.register/addmutator) AND aheavy non-lookup engine (reflection/parsing/I/O).
the trigger.
Mandatory exemptions (mirror the existing FP discipline):
ServiceProvider(binding lists / const arrays are legitimately import-heavy),Pipeline),Why this is safe (verified against the same codebase)
It would fire on
DefinitionRegistry,NodeDescriptorRegistry,TestRunOutcomeData,BridgeToTypedContextPipe— and would correctly stay silent onPipeline.php(cohesive builder, norole/forbidden-collaborator mismatch) and
WorkflowsServiceProvider.php(provider exemption), which a naivesize threshold would have flagged. The marker×collaborator core is what keeps the FP rate down.
Shape / tier
[AUTO-FIXABLE]— extraction is a design call (same stance asRegistryPattern,PreferTotalOverNullable). The finding should suggest the cut ("a*RegistryimportingReflectionClass— extract the reflection into a…Reflectorcollaborator"), not rewrite anything.single-responsibility/role-coherenceskill, or fold intoregistry).NeedsCodebaseIndexoptional — the primary signal is class-local (imports + markers + method names), soit can run without the call graph.
Naming
OutOfPurposeProphet(matches your phrasing). Alternatives:RoleCoherenceProphet,PurposeDriftProphet,MixedResponsibilitiesProphet,ClassDoingTooMuchProphet. It generalisesRegistryNamingHonesty— consider whether the registry-specific ones become configured instances of this.Open questions
role => {markers, forbidden}in config?RegistryBaseBypass)?Happy to prototype the marker×forbidden-collaborator core against the workflows codebase as the proving
ground (I have a snapshot with the known-positive and known-negative cases).