{"id":45776961,"url":"https://github.com/malikad778/wp-hook-check","last_synced_at":"2026-03-03T15:01:11.958Z","repository":{"id":340526721,"uuid":"1166441570","full_name":"malikad778/wp-hook-check","owner":"malikad778","description":"Static analysis for WordPress hooks. Detect orphaned listeners, unheard hooks, and typos in actions and filters without running WordPress. Faster, safer WP development.","archived":false,"fork":false,"pushed_at":"2026-02-26T05:11:02.000Z","size":5119,"stargazers_count":9,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-02-28T14:58:31.641Z","etag":null,"topics":["automated-testing","cli","code-quality","developer-tools","linting","pestphp","php","static-analysis","wordpress","wordpress-development","wordpress-hooks","wordpress-plugins"],"latest_commit_sha":null,"homepage":"https://packagist.org/packages/malikad778/wp-hook-check","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/malikad778.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-02-25T08:22:04.000Z","updated_at":"2026-02-27T13:47:29.000Z","dependencies_parsed_at":"2026-02-27T11:05:40.611Z","dependency_job_id":null,"html_url":"https://github.com/malikad778/wp-hook-check","commit_stats":null,"previous_names":["malikad778/wp-hook-check"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/malikad778/wp-hook-check","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/malikad778%2Fwp-hook-check","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/malikad778%2Fwp-hook-check/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/malikad778%2Fwp-hook-check/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/malikad778%2Fwp-hook-check/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/malikad778","download_url":"https://codeload.github.com/malikad778/wp-hook-check/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/malikad778%2Fwp-hook-check/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":29969700,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-03-01T12:56:10.327Z","status":"ssl_error","status_checked_at":"2026-03-01T12:55:24.744Z","response_time":124,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.6:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["automated-testing","cli","code-quality","developer-tools","linting","pestphp","php","static-analysis","wordpress","wordpress-development","wordpress-hooks","wordpress-plugins"],"created_at":"2026-02-26T10:32:22.884Z","updated_at":"2026-03-01T13:00:44.171Z","avatar_url":"https://github.com/malikad778.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"# wp-hook-check\n\n**Static analysis for WordPress hooks. Find broken hook connections before they reach production.**\n\n[![Tests](https://github.com/malikad778/wp-hook-check/actions/workflows/tests.yml/badge.svg)](https://github.com/malikad778/wp-hook-check/actions)\n[![Latest Version](https://img.shields.io/packagist/v/malikad778/wp-hook-check)](https://packagist.org/packages/malikad778/wp-hook-check)\n[![PHP Version](https://img.shields.io/packagist/php-v/malikad778/wp-hook-check)](https://packagist.org/packages/malikad778/wp-hook-check)\n[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)\n\n![Demo](demo.gif)\n\n---\n\n## The problem\n\nWordPress hooks are silent. When `add_action('my_hook', $cb)` exists but `do_action('my_hook')` was renamed or removed, nothing throws. The callback just stops running. You find out in production when a feature breaks.\n\n```php\n// v1 - fires a hook before checkout\ndo_action( 'my_plugin_before_checkout', $order_id );\n\n// Another plugin listens for it\nadd_action( 'my_plugin_before_checkout', 'apply_discount' );\n\n// v2 - renamed without announcement\ndo_action( 'my_plugin_checkout_start', $order_id );\n// apply_discount() never runs again. No error, no warning.\n```\n\nThis tool parses every PHP file in a directory, maps all hook registrations and invocations, and reports mismatches. No WordPress install needed.\n\n---\n\n## Install\n\n### Package (Composer)\n\n```bash\ncomposer require --dev malikad778/wp-hook-check\n```\n\nPHP 8.2+.\n\n### Global (WP-CLI)\n\nInstall globally via WP-CLI to scan any site:\n\n```bash\nwp package install malikad778/wp-hook-check\n```\n\n---\n\n## Usage\n\n### As a standalone script\n```bash\n# Scan current directory\nvendor/bin/wp-hook-audit audit .\n```\n\n### Via WP-CLI\n```bash\n# Scan a specific plugin\nwp hook-check ./wp-content/plugins/my-plugin\n```\n\n# Scan a plugin\nvendor/bin/wp-hook-audit audit ./wp-content/plugins/my-plugin\n\n# Scan all plugins at once (hooks are cross-referenced across all files)\nvendor/bin/wp-hook-audit audit ./wp-content/plugins\n```\n\n---\n\n## What gets flagged\n\n| Type | Severity | When |\n|---|---|---|\n| `ORPHANED_LISTENER` | 🔴 HIGH | `add_action/filter` exists, no matching `do_action/apply_filters` found |\n| `UNHEARD_HOOK` | 🟡 MEDIUM | `do_action/apply_filters` fired, no listener registered anywhere |\n| `HOOK_NAME_TYPO` | 🔴 HIGH | Hook name differs from another by 1–2 characters |\n| `DYNAMIC_HOOK` | 🔵 INFO | Hook name is a variable - can't be resolved, skipped by other detectors |\n\n---\n\n## Output formats\n\n### Table (default)\n\n```\n  WP HOOK AUDITOR  Scanned 47 files in 0.091s\n  ──────────────────────────────────────────────────────────\n\n  [HIGH] ORPHANED_LISTENER\n  File  : includes/class-checkout.php:234\n  Hook  : my_plugin_before_checkout\n\n  add_action('my_plugin_before_checkout') registered (callback: apply_discount)\n  - no matching do_action() or apply_filters() found.\n\n  Fix: Either remove the add_action() call or add do_action('my_plugin_before_checkout')\n  where it should fire.\n\n  ──────────────────────────────────────────────────────────\n  SUMMARY  1 HIGH   0 MEDIUM   0 INFO\n```\n\n### JSON (`--format=json`)\n\n```json\n{\n  \"meta\": {\n    \"files_scanned\": 47,\n    \"duration_sec\": 0.091,\n    \"issue_count\": 1\n  },\n  \"issues\": [\n    {\n      \"type\": \"orphaned_listener\",\n      \"severity\": \"high\",\n      \"hook\": \"my_plugin_before_checkout\",\n      \"file\": \"includes/class-checkout.php\",\n      \"line\": 234,\n      \"message\": \"...\",\n      \"safe_alternative\": \"...\",\n      \"suggestion\": null\n    }\n  ]\n}\n```\n\n### GitHub Annotations (`--format=github`)\n\n```\n::error file=includes/class-checkout.php,line=234,title=ORPHANED_LISTENER::...\n::warning file=...,title=UNHEARD_HOOK::...\n::notice file=...,title=DYNAMIC_HOOK::...\n```\n\nIssues show up as inline annotations on the exact lines in GitHub pull requests.\n\n---\n\n## CLI options\n\n### `audit` / `wp hook-check`\n\n```bash\nvendor/bin/wp-hook-audit audit [path] [options]\n# OR\nwp hook-check [path] [options]\n```\n\n| Option | Default | Description |\n|---|---|---|\n| `--format` | `table` | `table`, `json`, or `github` |\n| `--fail-on` | `high` | Exit 1 if issues at this level exist: `high`, `medium`, `any`, `none` |\n| `--exclude` | - | Comma-separated paths to skip |\n| `--ignore-dynamic` | - | Hide INFO dynamic hook notices |\n| `--only` | all | Run only these detectors: `orphaned`, `unheard`, `typo`, `dynamic` |\n| `--config` | `wp-hook-audit.json` | Path to config file |\n\n### `dump`\n\n```bash\nvendor/bin/wp-hook-audit dump [path] [--format=table|json]\n```\n\nDumps the full hook map - every `add_action`, `do_action`, `add_filter`, `apply_filters` call, with file, line, and priority. No detectors run. Good for exploring an unfamiliar codebase. *(Not currently supported via WP-CLI).*\n\n---\n\n## Exit codes\n\n| Code | Meaning |\n|---|---|\n| `0` | Clean (no issues above threshold) |\n| `1` | Issues found at or above `--fail-on` level |\n| `2` | Parse error, unreadable file, or bad config |\n\n---\n\n## Config file\n\nDrop a `wp-hook-audit.json` in the directory you're scanning, or point to one with `--config`:\n\n```json\n{\n    \"exclude\": [\"vendor/\", \"tests/\", \"node_modules/\"],\n    \"detectors\": {\n        \"orphaned_listener\": true,\n        \"unheard_hook\": true,\n        \"typo\": true,\n        \"dynamic_hook\": false\n    },\n    \"ignore\": [\n        { \"type\": \"unheard_hook\", \"hook\": \"my_plugin_extensibility_point\" }\n    ],\n    \"external_prefixes\": [\n        \"wp_\", \"admin_\", \"woocommerce_\", \"init\", \"shutdown\"\n    ]\n}\n```\n\n### `external_prefixes`\n\nWordPress core fires hundreds of hooks (`init`, `plugins_loaded`, `save_post`, etc.) that live inside WordPress itself, not your plugin. Without this setting, every `add_action('init', ...)` flags as `ORPHANED_LISTENER` because the matching `do_action('init')` is in WordPress core - outside the folder you're scanning.\n\nThe defaults already cover 40+ common WP core patterns. Add your own plugin's extensibility hooks here too if you're getting false positives from a third-party plugin you depend on.\n\nSee `wp-hook-audit.json.example` for the full default list.\n\n---\n\n## CI/CD\n\n### GitHub Actions\n\n```yaml\nname: Hook Audit\non: [pull_request]\n\njobs:\n  audit:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: shivammathur/setup-php@v2\n        with: { php-version: '8.2' }\n      - run: composer require --dev malikad778/wp-hook-check\n      - run: vendor/bin/wp-hook-audit audit . --format=github --fail-on=high\n```\n\n### GitLab CI\n\n```yaml\nhook_audit:\n  script:\n    - composer install\n    - vendor/bin/wp-hook-audit audit . --format=json \u003e hook-report.json\n  artifacts:\n    paths: [hook-report.json]\n```\n\n---\n\n## How it works\n\nParses PHP files into an AST using `nikic/php-parser`, walks every function call node, and records any of the 10 tracked WordPress functions. Hook names are extracted from the first argument - string literals are captured as-is, variables and concatenations are marked dynamic and skipped by mismatch detectors. The result is a `HookMap` keyed by hook name, which the four detectors then query.\n\nParse errors are non-fatal. A file that fails to parse is skipped with a warning, scan continues.\n\n---\n\n## Tracked functions\n\n| Function | Role |\n|---|---|\n| `add_action()`, `add_filter()` | Registration - checked for orphaned listeners |\n| `do_action()`, `apply_filters()` | Invocation - checked for missing listeners |\n| `do_action_ref_array()`, `apply_filters_ref_array()` | Invocation |\n| `remove_action()`, `remove_filter()` | Tracked, never flagged |\n| `has_action()`, `has_filter()` | Counts as invocation - stops false UNHEARD positives |\n\n---\n\n## Known gaps\n\n- Dynamic hook names (variables, string concatenation) are skipped by all mismatch detectors\n- Hooks registered inside conditionals are still tracked - may produce false positives if the condition never runs\n- Closures show as `{closure}` in the hook map output\n- Hooks from WordPress core or third-party plugins need their prefixes in `external_prefixes`\n\n---\n\n## License\n\nMIT - see [LICENSE](LICENSE)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmalikad778%2Fwp-hook-check","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmalikad778%2Fwp-hook-check","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmalikad778%2Fwp-hook-check/lists"}