Skip to content

Plugin authors and maintainers

hook-contract

Compare two plugin or theme versions to find hook changes that can break snippets, add-ons and client customizations.

Kind
Open-source tool
Language
JavaScript
License
MIT
Status
First release
  • backward-compatibility
  • github-action
  • hooks
  • jev
  • php
  • plugin-development
  • typesafe
  • wordpress

Hooks are interfaces

A WordPress plugin can keep every PHP function compatible and still break code that depends on it. Hooks create another public interface. Other plugins, snippets and client customizations may depend on an action or filter firing at a certain point, with a certain name and a certain set of arguments.

A small edit can change that contract. Removing a hook stops attached callbacks. Renaming one has the same effect for code using the old name. Passing fewer arguments can cause an ArgumentCountError when a callback expects arguments that are no longer supplied. Changing an action into a filter, or a filter into an action, also changes how callers use it.

A hook is a string passed to do_action() or apply_filters(), not a function signature, so PHP's own compatibility checks do not see it. hook-contract compares what two versions of a plugin or theme fire, so maintainers can review those changes before a release.

How comparison works

hook-contract reads the PHP in both versions and never executes it. You can compare two folders, two git revisions, or a release tag against the files currently on disk. It can also run in a pull request through a GitHub Action.

The parser recognizes do_action, apply_filters, their _ref_array forms and their _deprecated forms. For each fired hook it records the arguments, call sites and surrounding conditions that it can see from the source.

The comparison classifies changes such as removed, renamed, fewer-arguments, type-changed, now-conditional, fewer-call-sites, more-arguments, deprecated and added. Findings are grouped by severity. Breaking findings can make the command exit with code 1, and --fail-on <level> controls where that cutoff sits.

Names assembled from variables are compared as patterns. For example, "acme_{$type}_saved" and 'acme_' . $kind . '_saved' are treated as the same hook pattern, so a variable rename alone is not reported as a hook rename.

By default, folders named vendor, node_modules, tests and test are skipped. That keeps bundled dependencies and test fixtures out of the plugin or theme contract being checked.

Decisions that matter

Most findings come directly from syntax and do not need a model. A missing hook, a reduced argument count or a changed action/filter type can be established from the code the parser reads.

Three cases need interpretation. A probable renamed finding starts with a new hook of the same type, action or filter. It counts as a likely rename when the new hook is fired from the same function or the same file and its name is at least 60% alike. A name that is 50% alike also counts when the new hook is fired from the same function with the same number of arguments. A now-conditional finding may or may not change behavior that users rely on. A removed call site may still leave the hook firing in every situation that matters.

With --jev, hook-contract asks Jev, TypeSafe AI's System One model, about those cases. Jev answers typed yes/no questions with probabilities and writes no text. A rename question asks whether the new hook represents the same event at the same moment with the same data. Condition and removed-call-site questions ask whether the hook now stops firing in situations where it fired before.

Answers at or above the configured threshold are used. The default threshold is 0.8. Answers that do not clear it are marked review. A confident no can lower a condition finding to information. A rename remains breaking because callbacks using the old hook name still stop running. Jev only helps decide whether the change looks like a rename that should get a deprecated alias or a removal that should be documented.

Everything else works without a key. --dry-run with --jev reports how many questions would be sent and sends nothing.

Data stays local

Without --jev, hook-contract makes no network call. It reads files and git history locally.

With --jev, it sends one request to api.typesafe.ai for every 25 judged findings. Each question contains the hook name, up to three call sites from each version, the function containing those calls and their conditions. Each call site is limited to 240 characters.

No other code is sent. The request does not include additional file contents or information about the local environment. The key comes from TYPESAFE_API_KEY and is sent only in the Authorization header.

The synthetic example in examples/ uses two made-up versions of a small plugin. It is not code from a live site. Its generated text, JSON and Markdown outputs are stored as examples/report.txt, examples/report.json and examples/summary.md.

Where it stops

hook-contract reports what it can infer from source as written. It does not execute PHP. A hook name stored in another variable or fired through a custom wrapper is visible only in the form that appears at the call site.

Argument counts for do_action() and apply_filters() are exact. For _ref_array forms, the count is known only when the array is written directly in the call. Otherwise, no argument-count finding is produced.

The tool does not detect semantic changes inside an argument. Replacing a post object with an ID can change a callback contract even when the argument count stays the same.

renamed remains a heuristic. The new hook must be the same type. A name at least 60% alike counts when the hook is fired from the same function or the same file. A name at least 50% alike also counts when it is fired from the same function with the same number of arguments. Review that result yourself or use Jev for the extra judgment.

The scan concerns hooks a codebase fires. Registrations such as add_action() that attach callbacks to hooks from another plugin are outside its scope.

Install and run

hook-contract needs Node 20 or later. Its single php-parser dependency is pinned. A TypeSafe key is needed only for runs that add --jev.

npm install -g github:hamzaahmadaslam/hook-contract
hook-contract my-plugin-2.3.0/ my-plugin-2.4.0/
hook-contract --git v2.3.0 v2.4.0
export TYPESAFE_API_KEY="<your key>"
hook-contract --git v2.3.0 v2.4.0 --jev

Use --json when another tool needs structured findings. --markdown <file> writes a Markdown summary for a pull request or job summary. In a monorepo, --path <dir> limits a git comparison to one folder. --exclude <a,b> replaces the default excluded-folder list.

Related