Skip to content

Prophet idea: OutOfPurposeProphet — flag a class doing work out of its role (role-vs-behaviour incoherence), generalising RegistryNamingHonesty #134

@jessegall

Description

@jessegall

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:

  • DefinitionRegistrya registry (store/lookup) that is mostly a reflection compiler (~28 private
    reflect*/extract* methods, imports ReflectionClass + Spatie\StructureDiscoverer\Discover).
  • TestRunOutcomeDataa Spatie Data DTO that carries a 30-method graph-walking assembler and even
    injects NodeDescriptorRegistry to build itself.
  • NodeDescriptorRegistrya registry that is also a descriptor factory (make*TriggerDescriptor, …).
  • BridgeToTypedContextPipea 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions