[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"blog-site-config":3,"$fQxGcIaoNvzBag95adPPX-vjGnFU7oTi553_uDgElU1Y":6,"mdc--au8k1g-key":130},{"gaMeasurementId":4,"bookingUrl":5},"G-7BYTDCVDDR","https:\u002F\u002Ftidycal.com\u002Fcmdcntr\u002F30-minute-meeting",{"post":7,"related":94},{"id":8,"slug":9,"title":10,"description":11,"summary":12,"content":13,"coverImageUrl":14,"coverImageAlt":15,"tags":16,"targetKeywords":23,"faq":26,"howTo":42,"demo":63,"featured":64,"tool":63,"status":65,"reviewNote":63,"metaTitle":10,"metaDescription":66,"canonicalUrl":63,"sourceTicketId":63,"agentGenerated":64,"publishedAt":67,"publishedBy":68,"createdAt":69,"updatedAt":67,"authorId":70,"clusterId":71,"ctaId":72,"author":73,"cluster":83,"cta":87,"tools":93},"8834c87e-e752-4219-a7e1-3cb631d0d629","ai-code-quality-make-your-agent-run-phpstan","AI Code Quality: Make Your Agent Run PHPStan First","Give AI agents a deterministic gate: PHPStan with stubs, Biome rules, Claude Code hooks and git hooks that stop 'done' until the checks pass.","To improve AI code quality, give the agent checks that do not depend on its judgment. PHPStan with stubs catches invented functions, Biome rules enforce house conventions, and Claude Code hooks block the word \"done\" until the checks pass. The agent then sees its own errors instead of you finding them on the dev server.","I have had an agent report a change as done, then hit a fatal error on the dev server for a function that does not exist. The code read fine. Nothing had run it. In one of my packages I later found a filter call using a tag that exists nowhere in Elementor Pro, which does nothing and warns about nothing.\n\nBetter prompts do not fix this, because the model has no way to look up what your codebase contains. Tools that read the code can. This article covers three layers of AI code quality checks and how to make an agent run them before it says \"done\". Static analysis finds invented symbols. Lint rules hold house conventions. Hooks block completion until everything passes. For the wider picture, see [QA is part of the build](\u002Fblog\u002Fai-best-practices\u002Fqa-is-part-of-the-build). Everything below comes from PHP and TypeScript projects I maintain.\n\n## How does static analysis improve AI code quality?\n\nAn agent writes `$this->get_setting( 'title' )` inside an Elementor widget. Elementor has no such method. The model produces names that look right, and nothing in its loop knows the real list.\n\nPHPStan does know it. It reads your code plus stub files that describe the plugins you depend on, and at `level: max` it flags a call to a method that does not exist. I maintain [PHPStan stubs for Elementor and Elementor Pro](https:\u002F\u002Fgithub.com\u002Fartkrsk\u002Felementor-stubs), which Packagist shows installed over 10,000 times. You can install them and run the same check:\n\n```bash\ncomposer require --dev phpstan\u002Fphpstan arts\u002Felementor-stubs\n```\n\nThe stubs extend WooCommerce and WP-CLI (the WordPress command-line tool) classes, so all four stub files must load. With the WP-CLI file missing, PHPStan stops at startup with `Class \"WP_CLI_Command\" not found`.\n\n```yaml\nparameters:\n    level: max\n    paths:\n        - src\n    bootstrapFiles:\n        - vendor\u002Fphp-stubs\u002Fwordpress-stubs\u002Fwordpress-stubs.php\n        - vendor\u002Fphp-stubs\u002Fwoocommerce-stubs\u002Fwoocommerce-stubs.php\n        - vendor\u002Fphp-stubs\u002Fwp-cli-stubs\u002Fwp-cli-stubs.php\n        - vendor\u002Farts\u002Felementor-stubs\u002Felementor-stubs.php\n```\n\nI ran this against a widget that makes the invented call:\n\n```php\nfinal class Demo_Widget extends Widget_Base {\n\tprotected function render(): void {\n\t\t$title = $this->get_setting( 'title' );\n\t\techo esc_html( (string) $title );\n\t}\n}\n```\n\nPHPStan 2.3.1 reports `Call to an undefined method Demo_Widget::get_setting()` with the identifier `method.notFound`. Without the Elementor stubs the same file produces three errors, including `class.notFound` for `Widget_Base`, which PHPStan does not let you ignore. The real mistake then sits between errors that only mean a stub is missing. To verify your own setup, run `vendor\u002Fbin\u002Fphpstan analyse` and confirm the invented method appears. Then remove the Elementor stub line and watch the noise return.\n\nPHPStan also talks to agents. When it [detects one](https:\u002F\u002Fgithub.com\u002Fphpstan\u002Fphpstan-src\u002Fblob\u002F2083a817ca74bdac523bdf19e078733643d35c23\u002Fsrc\u002FInternal\u002FAgentDetector.php) through environment variables such as `AI_AGENT` and `CLAUDECODE`, it prints an [\"Instructions for interpreting errors\"](https:\u002F\u002Fgithub.com\u002Fphpstan\u002Fphpstan-src\u002Fblob\u002F2083a817ca74bdac523bdf19e078733643d35c23\u002Fsrc\u002FCommand\u002FAnalyseCommand.php#L583-L597) block. Inside Claude Code that block includes the line \"Do not add `@phpstan-ignore` comments, `@phpstan-ignore-next-line` comments, or baseline entries to suppress the error.\" It disappeared when I unset both variables.\n\nThe tool trusts its stubs, which has a cost. In one package the Elementor stubs declare `Plugin::$instance` as non-nullable, so a `!== null` check reads as always true. The right guard there is `class_exists('\\Elementor\\Plugin')` alone. For TypeScript the same job belongs to the compiler: `tsc --noEmit`.\n\n## How do you enforce house rules on AI-written code without bloating CLAUDE.md?\n\nRules written as prose in `CLAUDE.md` load into every session, whether or not the agent touches that code. They grow with every rule and depend on the agent remembering them. For a component framework I maintain, I moved the conventions into a Biome plugin. The first commit says it replaces the authoring skill as the source of truth. `CLAUDE.md` now mentions the plugin and how to suppress a rule, and nothing else about those conventions. One of the AI best practices I rely on is simple: if a machine can check a rule, a machine checks it.\n\nBiome supports custom lint rules written in GritQL. You save a `.grit` file and list it under `plugins` in `biome.json`. The package has 15 rules, mostly banning a raw browser API in favor of a managed wrapper that cleans up when a component is destroyed. This one covers timers:\n\n```grit\n`$callee($args)` where {\n\t$callee \u003C: or { `setTimeout`, `setInterval`, `clearTimeout`, `clearInterval` },\n\t$program \u003C: contains JsExtendsClause(super_class=$base) where {\n\t\t$base \u003C: r\".*Component$\"\n\t},\n\tregister_diagnostic(\n\t\tspan=$callee,\n\t\tmessage=\"Use this.ctx.setTimeout(fn, ms) (or this.ctx.setInterval \u002F clearTimeout \u002F clearInterval). Auto-cancelled when the component destroys.\",\n\t\tseverity=\"error\"\n\t)\n}\n```\n\nThe message matters most for agents. When the linter fails, that text is the hint the agent reads, at the moment it looks at the offending line. A message that names the replacement gets fixed on the next attempt. A bare \"not allowed\" invites a workaround. The rule fires only in files with a class that extends `*Component`, so it stays quiet in build scripts and third-party helpers.\n\nSome rules catch code that looks correct. One bans `instanceof HTMLElement` and its relatives. Elements from the Elementor editor's preview iframe belong to another frame, so the check is silently false there and the feature does nothing. A rule can be suppressed with `\u002F\u002F biome-ignore lint\u002Fplugin: \u003Creason>`, and the reason has to explain why the rule does not apply. An agent that wants around a rule has to write that down where you will see it in the diff.\n\nTo verify the rules, each one has an invalid and a valid fixture. The test runs Biome over both and asserts an exact diagnostic count, zero on the valid file, and that the message contains the hint. The first release shipped ten rules and only six matched, because four patterns missed class members with modifiers like `public override`. Biome's output looked the same as it does for a clean file. The suite now runs 33 tests that would catch that.\n\nGritQL has limits. It cannot follow data between statements, so `matchMedia(q).addEventListener(...)` is caught and `const mq = matchMedia(q); mq.addEventListener(...)` is not. It cannot see filenames, so \"a class in `Foo.ts` must be named `Foo`\" stays in `CLAUDE.md`.\n\n## Why do AI code checks pass when they should fail?\n\nA check that cannot fail gives the agent nothing to react to. Mine failed that way more than once.\n\n![Three ways a check passes when it should fail: PHPStan passing from a warm result cache, blanket ignores that no longer match anything, and skip lists naming files that no longer exist, each shown with its fix](\u002Fapi\u002Fblog\u002Fimages\u002Fblog\u002F1\u002F7626918e-2436-45b8-aa55-520782d66599.webp)\n\nIn one theme, `reportUnmatchedIgnoredErrors` was off in the PHPStan config, and three blanket ignores inherited from an older project had stopped matching anything. With the flag off there was no way to tell. I turned it on, deleted the ignores, and found that PHPStan had only been passing from a warm result cache. A fresh run reported four real type errors, where functions such as `wp_parse_id_list()` and `implode()` received values of the wrong type. Run `phpstan clear-result-cache` and check again before you trust a green result.\n\nIn a second repository, a Rector skip list and a lint allowlist pointed at files that had been renamed or never existed. An allowlist entry that matches no file never fires, and a skip that matches no path stops protecting what it was written for. I added a script that checks every path in `rector.php` and the test configs exists on disk, and wired it into `composer check`. Biome has its own version: plugin globs match against the absolute file path, so a root-relative `src\u002F`** silently never fires and needs a **`\u002F` prefix.\n\nStatic analysis also has a blind spot: strings. A filter name is a string, so PHPStan cannot know that nothing registers the tag. One of my packages called `elementor_pro\u002Fquery_control\u002Fget_query_args\u002Fcurrent_query`, which exists nowhere in Elementor Pro. WordPress treats `apply_filters` on an unregistered tag as a no-op, so nothing failed, and the code sat behind a misleading comment. The fix was a test that pins the one real tag and expects the invented name never to be called. Tests are the next layer, and they belong in [your definition of done](\u002Fblog\u002Fai-best-practices\u002Fqa-is-part-of-the-build). For code an agent writes in volume, mutation testing with Infection checks that those tests fail when the logic is wrong.\n\nThe same limit applies to accessibility. The [accessibility review checklist for AI-generated components](\u002Fblog\u002Fai-best-practices\u002Fai-generated-ui-components-fail-wcag-2-2-review-checklist) treats an automated scan as the floor of a review. A passing check is where the review of AI production code starts.\n\n## How do Claude Code hooks make an AI agent run checks before it says done?\n\nAn agent skips a check it is not forced to run. Hooks remove that choice. You can attach checks at five points, and the earlier points give faster feedback.\n\n| When | Where | What runs |\n|---|---|---|\n| Right after an edit | Claude Code `PostToolUse` hook | Lint the one file |\n| When the agent says it is finished | Claude Code `Stop` hook | PHPStan and `tsc` |\n| On commit | git pre-commit | Biome, Rector, PHP_CodeSniffer, `tsc` |\n| On push | git pre-push | PHPStan and tests |\n| On pull request | continuous integration (CI) | Everything, from a cold cache |\n\n![Diagram of five checkpoints in order: a PostToolUse hook lints the edited file, a Stop hook runs PHPStan and tsc, git pre-commit runs Biome, Rector, PHP_CodeSniffer and tsc, git pre-push runs PHPStan and tests, and CI runs everything from a cold cache](\u002Fapi\u002Fblog\u002Fimages\u002Fblog\u002F1\u002Fcf5f598f-5a48-490a-a151-6bc9b819457e.webp)\n\nThe Claude Code hooks documentation describes the two events that matter here. `PostToolUse` fires after a tool call succeeds, and exit code 2 shows your stderr to Claude. The edit has already happened, so the hook cannot undo it, but the agent sees the error immediately. `Stop` fires when Claude finishes responding, and exit code 2 prevents it from stopping and continues the conversation with your stderr as the reason. Hooks live in a settings file: `~\u002F.claude\u002Fsettings.json` applies everywhere, and `.claude\u002Fsettings.json` is committed with the project. A third file, `.claude\u002Fsettings.local.json`, stays personal. Each hook receives a JSON payload on stdin.\n\n```jsonc\n{\n  \"hooks\": {\n    \"PostToolUse\": [\n      {\n        \"matcher\": \"Edit|Write\",\n        \"hooks\": [{ \"type\": \"command\", \"command\": \"\\\"$CLAUDE_PROJECT_DIR\\\"\u002F.claude\u002Fhooks\u002Flint-edited.sh\" }]\n      }\n    ],\n    \"Stop\": [\n      { \"hooks\": [{ \"type\": \"command\", \"command\": \"\\\"$CLAUDE_PROJECT_DIR\\\"\u002F.claude\u002Fhooks\u002Fverify-stop.sh\" }] }\n    ]\n  }\n}\n```\n\n```bash\n#!\u002Fusr\u002Fbin\u002Fenv bash\n# lint-edited.sh: check the file that was just touched\nfile=$(jq -r '.tool_input.file_path \u002F\u002F empty')\ncase \"$file\" in\n  *.ts)  pnpm exec biome lint \"$file\" >&2 || exit 2 ;;\n  *.php) php -l \"$file\" >&2 || exit 2 ;;\nesac\n```\n\n```bash\n#!\u002Fusr\u002Fbin\u002Fenv bash\n# verify-stop.sh: block \"done\" while the project's checks fail\ninput=$(cat)\ncd \"${CLAUDE_PROJECT_DIR:-.}\" || exit 0\n\n# Nothing edited this turn: skip the checks.\n[ -z \"$(git status --porcelain)\" ] && exit 0\n\n# Already continued once because of this hook: let the agent stop and report.\n[ \"$(jq -r '.stop_hook_active' \u003C\u003C\u003C\"$input\")\" = \"true\" ] && exit 0\n\nout=$(composer phpstan 2>&1 && pnpm check:types 2>&1) || {\n  printf '%s\\n' \"$out\" | tail -40 >&2\n  exit 2\n}\n```\n\nI ran both scripts. The lint script exits 2 with Biome's message on a `debugger;` statement and 0 on a clean file. The stop script exits 2 with the PHPStan error, skips a clean working tree, and lets the agent stop when `stop_hook_active` is true. `Stop` fires on every turn, including ones where the agent only answered a question, so the `git status` line skips the checks when nothing changed. The documentation describes `stop_hook_active` as true when Claude is already continuing because of a stop hook, and says to check it so errors the agent cannot fix do not trap it. Claude Code also [caps stop-hook continuations at eight in a row](https:\u002F\u002Fcode.claude.com\u002Fdocs\u002Fen\u002Fhooks#stop-input), and `CLAUDE_CODE_STOP_HOOK_BLOCK_CAP` raises the cap. Most events time out after 600 seconds by default, but keep `PostToolUse` fast and put the whole-project checks on `Stop`.\n\nGit hooks catch the agent when it commits and cover tools with no hook system. I use Lefthook in the JavaScript-first repositories and CaptainHook in the PHP package. Pre-commit rewrites and re-stages files, and pre-push runs the slower checks. Order matters. I had Rector running after `phpcbf`. Rector prints changed nodes with space indentation and can import a class name from a docblock, so misformatted files reached the commit. Running Rector first and letting `phpcbf` clean up fixed it, with `phpcbf` exit code 1 treated as success because it means the files were fixed. These hooks stay advisory (`LEFTHOOK=0` skips them for a work-in-progress commit), so CI remains the authoritative gate.\n\n## When should you refactor AI-written code?\n\nRefactor suggesters answer a different question from the checks above. PHPStan asks whether the code is wrong. These tools ask what should change next, and an agent that applies their output blindly can break working code.\n\nOn the TypeScript side I run Fallow and Knip. Fallow reports dead code and circular imports, and it measures duplication and complexity. It cannot see components that are discovered from rendered HTML, so I list the entry points in `.fallowrc.jsonc`. Even then the output needs checking. After I added one unrelated file, Fallow 3.32.0 changed the unused-class-member count for one library's classes from 9 to 13. Before I delete anything it reports, I check the callers. Knip has a similar blind spot for packages consumed only from Sass, which no JavaScript import graph can see, so its config lists those dependencies as ignored.\n\nOn the PHP side I run Rector with four prepared sets: dead code, code quality, type declarations and early returns. Style and naming sets stay off because PHP_CodeSniffer owns style. The config is pinned to PHP 8.0, so it cannot suggest `readonly` or enums in a package that ships to shared hosting. It also skips the files that are tied to Elementor's runtime, with a comment on each explaining that their behavior can only be exercised in a live Elementor. Pre-commit runs Rector on staged PHP files, so every change it makes is visible in the commit.\n\nA practical trigger for a larger refactor: when a report for the same module keeps growing between agent sessions, plan a deliberate rework instead of another patch.\n\n## How do you set this up, step by step?\n\nThe steps below follow the order that pays off fastest. Each one ends with a way to check that it works.\n\n**Step 1: Run PHPStan at max with stubs.** Install `phpstan\u002Fphpstan` and the stubs for every plugin you call, then set `level: max`. To verify, call a method that does not exist and confirm PHPStan reports `method.notFound`.\n\n**Step 2: Move house rules into Biome rules.** Write one `.grit` file per rule, with a message that names the replacement. To verify, keep an invalid fixture that must fire an exact number of times and a valid fixture that must stay silent.\n\n**Step 3: Lint each edited file.** Add a `PostToolUse` hook with the matcher `Edit|Write` that runs the linter on the file path from the payload. To verify, have the agent write a `debugger;` line and confirm it receives the error in the same turn.\n\n**Step 4: Block \"done\" while checks fail.** Add a `Stop` hook that runs PHPStan and the TypeScript compiler, and check `stop_hook_active` so unfixable errors cannot trap the agent. To verify, rename a function in a call and confirm the agent is sent back to fix it.\n\n**Step 5: Add git hooks as the backstop.** Run formatters and fixers on pre-commit and the slow checks on pre-push. To verify, commit a file that breaks a rule and confirm the hook stops it.\n\n**Step 6: Prove every check can fail.** Turn on `reportUnmatchedIgnoredErrors`, run PHPStan from a cold cache, and check that every path in your skip lists exists. To verify, delete a file named in a skip list and confirm your config check fails.\n\n## Key takeaways\n\n- PHPStan with stubs for every plugin you call catches invented functions and methods. Prompting does not.\n- Rules a pattern can match belong in Biome plugin rules whose messages name the replacement. `CLAUDE.md` keeps the rest.\n- A `Stop` hook runs the checks before the agent can say done, and a `PostToolUse` hook gives quick per-file feedback.\n- A check that cannot fail is worse than none. Test the rules and clear the cache, and check every path in your configs.","\u002Fapi\u002Fblog\u002Fimages\u002Fblog\u002F1\u002Ff5af1e7b-2c69-4b55-91c8-9a5fe2a1c1ee.webp","Terminal showing PHPStan reporting an undefined method called by an AI-written Elementor widget",[17,18,19,20,21,22],"ai code quality","phpstan","claude code hooks","biome","static analysis","ai coding agents",[17,24,25,21,19],"ai best practices","ai production",[27,30,33,36,39],{"answer":28,"question":29},"Yes, if you tell it to in `CLAUDE.md` or in the task prompt. That depends on the agent remembering, though, and it can report done without running anything. A Claude Code `Stop` hook runs PHPStan every time the agent tries to finish and sends the errors back, so the check happens whether the agent remembers or not.","Can an AI coding agent run PHPStan on its own?",{"answer":31,"question":32},"No. A hook is any command that reads a JSON payload on stdin and returns an exit code, so it can run any of your checks, such as PHPStan or `tsc`, or a shell script of your own. The `matcher` field limits a `PostToolUse` hook to certain tools, such as `Edit|Write`, so it runs only after files change.","Do Claude Code hooks only work with specific tools?",{"answer":34,"question":35},"No. The hook receives `stop_hook_active`, which is true when Claude is already continuing because of a stop hook, and the documentation says to check it before blocking again. Claude Code also ends the turn after eight consecutive stop-hook continuations, and `CLAUDE_CODE_STOP_HOOK_BLOCK_CAP` changes that limit.","Will a Claude Code Stop hook loop forever?",{"answer":37,"question":38},"Put any rule a pattern can match into a lint rule. The diagnostic reaches the agent at the offending line and costs no context in sessions that never touch that code. Keep in `CLAUDE.md` what a rule cannot express, such as naming a class after its file, which GritQL cannot check.","Should house rules go in CLAUDE.md or lint rules?",{"answer":40,"question":41},"PHPStan caches results, so a warm cache can hide errors that a cold run reports. In one of my themes, a fresh run found four real type errors that had been passing. Run `phpstan clear-result-cache` before you trust a green result, and run CI from a cold cache.","Why does PHPStan pass locally but fail on a fresh run?",{"name":43,"steps":44},"Give an AI agent a deterministic way to check its own work",[45,48,51,54,57,60],{"name":46,"text":47},"Run PHPStan at max with stubs","Install `phpstan\u002Fphpstan` and the stubs for every plugin you call, then set `level: max`. To verify, call a method that does not exist and confirm PHPStan reports `method.notFound`.",{"name":49,"text":50},"Move house rules into Biome rules","Write one `.grit` file per rule, with a message that names the replacement. To verify, keep an invalid fixture that must fire an exact number of times and a valid fixture that must stay silent.",{"name":52,"text":53},"Lint each edited file","Add a `PostToolUse` hook with the matcher `Edit|Write` that runs the linter on the file path from the payload. To verify, have the agent write a `debugger;` line and confirm it receives the error in the same turn.",{"name":55,"text":56},"Block \"done\" while checks fail","Add a `Stop` hook that runs PHPStan and the TypeScript compiler, and check `stop_hook_active` so unfixable errors cannot trap the agent. To verify, rename a function in a call and confirm the agent is sent back to fix it.",{"name":58,"text":59},"Add git hooks as the backstop","Run formatters and fixers on pre-commit and the slow checks on pre-push. To verify, commit a file that breaks a rule and confirm the hook stops it.",{"name":61,"text":62},"Prove every check can fail","Turn on `reportUnmatchedIgnoredErrors`, run PHPStan from a cold cache, and check that every path in your skip lists exists. To verify, delete a file named in a skip list and confirm your config check fails.",null,false,"published","Agents report \"done\" on code that fails. Add PHPStan with stubs, Biome rules and Claude Code hooks so the error reaches the agent before it reaches you.","2026-10-22T02:48:28.506Z","fb696ca1-7851-4fc7-bb8d-0b897916e799","2026-10-14T05:08:59.100Z","d2e105e2-e504-4955-bb0d-62225a87037a","ffab1f0c-63d8-41e8-b3e4-87e028178c8e","c0fcf6a1-58d2-4262-8996-3ad29d58eea5",{"slug":74,"name":75,"avatarUrl":76,"role":77,"bio":78,"isPublic":79,"expertiseSummary":63,"linkedin":80,"github":81,"twitter":63,"website":82},"artem-semkin","Artem Semkin","\u002Fapi\u002Fpublic\u002Favatar\u002F6f0370c4-5e63-4ca0-bd2b-16ae42e38548","Senior WordPress Engineer","I develop premium motion-focused themes and plugins for WordPress focusing primarily on Elementor ecosystem. I can either design (in Figma) and build using modern WordPress stack and tools. I'm also fine with supporting legacy\u002Foutdated WordPress installations from jQuery era. Currently I have 10 plugins published on WP.org https:\u002F\u002Fprofiles.wordpress.org\u002Fartemsemkin\u002F that I maintain.",true,"https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fartemsemkin\u002F","https:\u002F\u002Fgithub.com\u002Fartkrsk\u002F","https:\u002F\u002Fartemsemkin.com\u002F",{"slug":84,"name":85,"color":86},"ai-best-practices","AI Best Practices","#EC4899",{"id":72,"name":88,"ctaType":89,"heading":90,"description":91,"buttonText":92,"url":5,"leadMagnetUrl":63},"Book a Call (blog)","book_call","Shipping AI-generated code that keeps breaking?","Book a free 30-minute call with a senior engineer. We diagnose what is going wrong and give you a concrete fix plan, no obligation.","Book a free call",[],[95,116],{"id":96,"slug":97,"title":98,"description":99,"coverImageUrl":100,"coverImageAlt":101,"tags":102,"featured":64,"tool":63,"publishedAt":108,"author":109,"cluster":115},"15c5cc69-3d6b-455e-860c-ed71f11cfa73","qa-is-part-of-the-build","QA Is Part of the Build: Where Testing Belongs in Your Definition of Done","A demo failed because a shared environment was left in an unknown state. The lesson: QA is not a handoff. It belongs in the engineer's definition of done, at every company size.","https:\u002F\u002Fcmdcntr.io\u002Fcovers\u002Fai-best-practices\u002Fqa-is-part-of-the-build.webp","A chrome drafting compass standing upright, fused to a chrome blueprint sheet, whole and cleanly engineered, with electric violet binary code and thin wires running into the blueprint grid and a single cyan guidance trace, on a near-black floor etched with circuit traces.",[103,104,105,106,107],"qa","testing","definition of done","ai code","best practices","2026-09-11T20:29:49.653Z",{"slug":110,"name":111,"avatarUrl":112,"role":113,"bio":114,"isPublic":79},"michael-graham","Michael Graham","\u002Fapi\u002Fpublic\u002Favatar\u002F7452d3cc-b0aa-46e4-96e6-da9c0225c471","Founder & Software Engineer","Obsessed with building top-tier web software and crafting unique, polished user experiences.",{"slug":84,"name":85,"color":86},{"id":117,"slug":118,"title":119,"description":120,"coverImageUrl":121,"coverImageAlt":122,"tags":123,"featured":64,"tool":63,"publishedAt":127,"author":128,"cluster":129},"12e12c27-422a-415a-8dcd-08ad886a6f91","ai-generated-ui-components-fail-wcag-2-2-review-checklist","AI-Generated UI Components Fail WCAG 2.2: The Review Checklist That Catches It","AI tools generate UI components that look right and fail WCAG 2.2 at scale, for the same reason they skip security. Here is the fast review checklist that catches it before merge.","https:\u002F\u002Fcmdcntr.io\u002Fcovers\u002Fenterprise-cms\u002Fai-components.webp","A chrome user-interface element shattering with purple energy and streaming code spilling out",[106,124,125,126,107],"accessibility","wcag","code review","2026-06-10T14:00:00.000Z",{"slug":110,"name":111,"avatarUrl":112,"role":113,"bio":114,"isPublic":79},{"slug":84,"name":85,"color":86},{"data":131,"body":132},{},{"type":133,"children":134},"root",[135,143,157,164,178,201,245,258,393,398,455,500,555,591,597,617,646,744,757,778,791,834,840,845,854,890,927,955,968,974,979,1127,1135,1178,1292,1532,1921,1994,2029,2035,2040,2053,2066,2071,2077,2082,2114,2131,2163,2186,2196,2213,2219,2265],{"type":136,"tag":137,"props":138,"children":139},"element","p",{},[140],{"type":141,"value":142},"text","I have had an agent report a change as done, then hit a fatal error on the dev server for a function that does not exist. The code read fine. Nothing had run it. In one of my packages I later found a filter call using a tag that exists nowhere in Elementor Pro, which does nothing and warns about nothing.",{"type":136,"tag":137,"props":144,"children":145},{},[146,148,155],{"type":141,"value":147},"Better prompts do not fix this, because the model has no way to look up what your codebase contains. Tools that read the code can. This article covers three layers of AI code quality checks and how to make an agent run them before it says \"done\". Static analysis finds invented symbols. Lint rules hold house conventions. Hooks block completion until everything passes. For the wider picture, see ",{"type":136,"tag":149,"props":150,"children":152},"a",{"href":151},"\u002Fblog\u002Fai-best-practices\u002Fqa-is-part-of-the-build",[153],{"type":141,"value":154},"QA is part of the build",{"type":141,"value":156},". Everything below comes from PHP and TypeScript projects I maintain.",{"type":136,"tag":158,"props":159,"children":161},"h2",{"id":160},"how-does-static-analysis-improve-ai-code-quality",[162],{"type":141,"value":163},"How does static analysis improve AI code quality?",{"type":136,"tag":137,"props":165,"children":166},{},[167,169,176],{"type":141,"value":168},"An agent writes ",{"type":136,"tag":170,"props":171,"children":173},"code",{"className":172},[],[174],{"type":141,"value":175},"$this->get_setting( 'title' )",{"type":141,"value":177}," inside an Elementor widget. Elementor has no such method. The model produces names that look right, and nothing in its loop knows the real list.",{"type":136,"tag":137,"props":179,"children":180},{},[181,183,189,191,199],{"type":141,"value":182},"PHPStan does know it. It reads your code plus stub files that describe the plugins you depend on, and at ",{"type":136,"tag":170,"props":184,"children":186},{"className":185},[],[187],{"type":141,"value":188},"level: max",{"type":141,"value":190}," it flags a call to a method that does not exist. I maintain ",{"type":136,"tag":149,"props":192,"children":196},{"href":193,"rel":194},"https:\u002F\u002Fgithub.com\u002Fartkrsk\u002Felementor-stubs",[195],"nofollow",[197],{"type":141,"value":198},"PHPStan stubs for Elementor and Elementor Pro",{"type":141,"value":200},", which Packagist shows installed over 10,000 times. You can install them and run the same check:",{"type":136,"tag":202,"props":203,"children":208},"pre",{"className":204,"code":205,"language":206,"meta":207,"style":207},"language-bash shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","composer require --dev phpstan\u002Fphpstan arts\u002Felementor-stubs\n","bash","",[209],{"type":136,"tag":170,"props":210,"children":211},{"__ignoreMap":207},[212],{"type":136,"tag":213,"props":214,"children":217},"span",{"class":215,"line":216},"line",1,[218,224,230,235,240],{"type":136,"tag":213,"props":219,"children":221},{"style":220},"--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B",[222],{"type":141,"value":223},"composer",{"type":136,"tag":213,"props":225,"children":227},{"style":226},"--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D",[228],{"type":141,"value":229}," require",{"type":136,"tag":213,"props":231,"children":232},{"style":226},[233],{"type":141,"value":234}," --dev",{"type":136,"tag":213,"props":236,"children":237},{"style":226},[238],{"type":141,"value":239}," phpstan\u002Fphpstan",{"type":136,"tag":213,"props":241,"children":242},{"style":226},[243],{"type":141,"value":244}," arts\u002Felementor-stubs\n",{"type":136,"tag":137,"props":246,"children":247},{},[248,250,256],{"type":141,"value":249},"The stubs extend WooCommerce and WP-CLI (the WordPress command-line tool) classes, so all four stub files must load. With the WP-CLI file missing, PHPStan stops at startup with ",{"type":136,"tag":170,"props":251,"children":253},{"className":252},[],[254],{"type":141,"value":255},"Class \"WP_CLI_Command\" not found",{"type":141,"value":257},".",{"type":136,"tag":202,"props":259,"children":263},{"className":260,"code":261,"language":262,"meta":207,"style":207},"language-yaml shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","parameters:\n    level: max\n    paths:\n        - src\n    bootstrapFiles:\n        - vendor\u002Fphp-stubs\u002Fwordpress-stubs\u002Fwordpress-stubs.php\n        - vendor\u002Fphp-stubs\u002Fwoocommerce-stubs\u002Fwoocommerce-stubs.php\n        - vendor\u002Fphp-stubs\u002Fwp-cli-stubs\u002Fwp-cli-stubs.php\n        - vendor\u002Farts\u002Felementor-stubs\u002Felementor-stubs.php\n","yaml",[264],{"type":136,"tag":170,"props":265,"children":266},{"__ignoreMap":207},[267,282,301,314,328,341,354,367,380],{"type":136,"tag":213,"props":268,"children":269},{"class":215,"line":216},[270,276],{"type":136,"tag":213,"props":271,"children":273},{"style":272},"--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178",[274],{"type":141,"value":275},"parameters",{"type":136,"tag":213,"props":277,"children":279},{"style":278},"--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF",[280],{"type":141,"value":281},":\n",{"type":136,"tag":213,"props":283,"children":285},{"class":215,"line":284},2,[286,291,296],{"type":136,"tag":213,"props":287,"children":288},{"style":272},[289],{"type":141,"value":290},"    level",{"type":136,"tag":213,"props":292,"children":293},{"style":278},[294],{"type":141,"value":295},":",{"type":136,"tag":213,"props":297,"children":298},{"style":226},[299],{"type":141,"value":300}," max\n",{"type":136,"tag":213,"props":302,"children":304},{"class":215,"line":303},3,[305,310],{"type":136,"tag":213,"props":306,"children":307},{"style":272},[308],{"type":141,"value":309},"    paths",{"type":136,"tag":213,"props":311,"children":312},{"style":278},[313],{"type":141,"value":281},{"type":136,"tag":213,"props":315,"children":317},{"class":215,"line":316},4,[318,323],{"type":136,"tag":213,"props":319,"children":320},{"style":278},[321],{"type":141,"value":322},"        -",{"type":136,"tag":213,"props":324,"children":325},{"style":226},[326],{"type":141,"value":327}," src\n",{"type":136,"tag":213,"props":329,"children":331},{"class":215,"line":330},5,[332,337],{"type":136,"tag":213,"props":333,"children":334},{"style":272},[335],{"type":141,"value":336},"    bootstrapFiles",{"type":136,"tag":213,"props":338,"children":339},{"style":278},[340],{"type":141,"value":281},{"type":136,"tag":213,"props":342,"children":344},{"class":215,"line":343},6,[345,349],{"type":136,"tag":213,"props":346,"children":347},{"style":278},[348],{"type":141,"value":322},{"type":136,"tag":213,"props":350,"children":351},{"style":226},[352],{"type":141,"value":353}," vendor\u002Fphp-stubs\u002Fwordpress-stubs\u002Fwordpress-stubs.php\n",{"type":136,"tag":213,"props":355,"children":357},{"class":215,"line":356},7,[358,362],{"type":136,"tag":213,"props":359,"children":360},{"style":278},[361],{"type":141,"value":322},{"type":136,"tag":213,"props":363,"children":364},{"style":226},[365],{"type":141,"value":366}," vendor\u002Fphp-stubs\u002Fwoocommerce-stubs\u002Fwoocommerce-stubs.php\n",{"type":136,"tag":213,"props":368,"children":370},{"class":215,"line":369},8,[371,375],{"type":136,"tag":213,"props":372,"children":373},{"style":278},[374],{"type":141,"value":322},{"type":136,"tag":213,"props":376,"children":377},{"style":226},[378],{"type":141,"value":379}," vendor\u002Fphp-stubs\u002Fwp-cli-stubs\u002Fwp-cli-stubs.php\n",{"type":136,"tag":213,"props":381,"children":383},{"class":215,"line":382},9,[384,388],{"type":136,"tag":213,"props":385,"children":386},{"style":278},[387],{"type":141,"value":322},{"type":136,"tag":213,"props":389,"children":390},{"style":226},[391],{"type":141,"value":392}," vendor\u002Farts\u002Felementor-stubs\u002Felementor-stubs.php\n",{"type":136,"tag":137,"props":394,"children":395},{},[396],{"type":141,"value":397},"I ran this against a widget that makes the invented call:",{"type":136,"tag":202,"props":399,"children":403},{"className":400,"code":401,"language":402,"meta":207,"style":207},"language-php shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","final class Demo_Widget extends Widget_Base {\n    protected function render(): void {\n        $title = $this->get_setting( 'title' );\n        echo esc_html( (string) $title );\n    }\n}\n","php",[404],{"type":136,"tag":170,"props":405,"children":406},{"__ignoreMap":207},[407,415,423,431,439,447],{"type":136,"tag":213,"props":408,"children":409},{"class":215,"line":216},[410],{"type":136,"tag":213,"props":411,"children":412},{},[413],{"type":141,"value":414},"final class Demo_Widget extends Widget_Base {\n",{"type":136,"tag":213,"props":416,"children":417},{"class":215,"line":284},[418],{"type":136,"tag":213,"props":419,"children":420},{},[421],{"type":141,"value":422},"    protected function render(): void {\n",{"type":136,"tag":213,"props":424,"children":425},{"class":215,"line":303},[426],{"type":136,"tag":213,"props":427,"children":428},{},[429],{"type":141,"value":430},"        $title = $this->get_setting( 'title' );\n",{"type":136,"tag":213,"props":432,"children":433},{"class":215,"line":316},[434],{"type":136,"tag":213,"props":435,"children":436},{},[437],{"type":141,"value":438},"        echo esc_html( (string) $title );\n",{"type":136,"tag":213,"props":440,"children":441},{"class":215,"line":330},[442],{"type":136,"tag":213,"props":443,"children":444},{},[445],{"type":141,"value":446},"    }\n",{"type":136,"tag":213,"props":448,"children":449},{"class":215,"line":343},[450],{"type":136,"tag":213,"props":451,"children":452},{},[453],{"type":141,"value":454},"}\n",{"type":136,"tag":137,"props":456,"children":457},{},[458,460,466,468,474,476,482,484,490,492,498],{"type":141,"value":459},"PHPStan 2.3.1 reports ",{"type":136,"tag":170,"props":461,"children":463},{"className":462},[],[464],{"type":141,"value":465},"Call to an undefined method Demo_Widget::get_setting()",{"type":141,"value":467}," with the identifier ",{"type":136,"tag":170,"props":469,"children":471},{"className":470},[],[472],{"type":141,"value":473},"method.notFound",{"type":141,"value":475},". Without the Elementor stubs the same file produces three errors, including ",{"type":136,"tag":170,"props":477,"children":479},{"className":478},[],[480],{"type":141,"value":481},"class.notFound",{"type":141,"value":483}," for ",{"type":136,"tag":170,"props":485,"children":487},{"className":486},[],[488],{"type":141,"value":489},"Widget_Base",{"type":141,"value":491},", which PHPStan does not let you ignore. The real mistake then sits between errors that only mean a stub is missing. To verify your own setup, run ",{"type":136,"tag":170,"props":493,"children":495},{"className":494},[],[496],{"type":141,"value":497},"vendor\u002Fbin\u002Fphpstan analyse",{"type":141,"value":499}," and confirm the invented method appears. Then remove the Elementor stub line and watch the noise return.",{"type":136,"tag":137,"props":501,"children":502},{},[503,505,512,514,520,522,528,530,537,539,545,547,553],{"type":141,"value":504},"PHPStan also talks to agents. When it ",{"type":136,"tag":149,"props":506,"children":509},{"href":507,"rel":508},"https:\u002F\u002Fgithub.com\u002Fphpstan\u002Fphpstan-src\u002Fblob\u002F2083a817ca74bdac523bdf19e078733643d35c23\u002Fsrc\u002FInternal\u002FAgentDetector.php",[195],[510],{"type":141,"value":511},"detects one",{"type":141,"value":513}," through environment variables such as ",{"type":136,"tag":170,"props":515,"children":517},{"className":516},[],[518],{"type":141,"value":519},"AI_AGENT",{"type":141,"value":521}," and ",{"type":136,"tag":170,"props":523,"children":525},{"className":524},[],[526],{"type":141,"value":527},"CLAUDECODE",{"type":141,"value":529},", it prints an ",{"type":136,"tag":149,"props":531,"children":534},{"href":532,"rel":533},"https:\u002F\u002Fgithub.com\u002Fphpstan\u002Fphpstan-src\u002Fblob\u002F2083a817ca74bdac523bdf19e078733643d35c23\u002Fsrc\u002FCommand\u002FAnalyseCommand.php#L583-L597",[195],[535],{"type":141,"value":536},"\"Instructions for interpreting errors\"",{"type":141,"value":538}," block. Inside Claude Code that block includes the line \"Do not add ",{"type":136,"tag":170,"props":540,"children":542},{"className":541},[],[543],{"type":141,"value":544},"@phpstan-ignore",{"type":141,"value":546}," comments, ",{"type":136,"tag":170,"props":548,"children":550},{"className":549},[],[551],{"type":141,"value":552},"@phpstan-ignore-next-line",{"type":141,"value":554}," comments, or baseline entries to suppress the error.\" It disappeared when I unset both variables.",{"type":136,"tag":137,"props":556,"children":557},{},[558,560,566,568,574,576,582,584,590],{"type":141,"value":559},"The tool trusts its stubs, which has a cost. In one package the Elementor stubs declare ",{"type":136,"tag":170,"props":561,"children":563},{"className":562},[],[564],{"type":141,"value":565},"Plugin::$instance",{"type":141,"value":567}," as non-nullable, so a ",{"type":136,"tag":170,"props":569,"children":571},{"className":570},[],[572],{"type":141,"value":573},"!== null",{"type":141,"value":575}," check reads as always true. The right guard there is ",{"type":136,"tag":170,"props":577,"children":579},{"className":578},[],[580],{"type":141,"value":581},"class_exists('\\Elementor\\Plugin')",{"type":141,"value":583}," alone. For TypeScript the same job belongs to the compiler: ",{"type":136,"tag":170,"props":585,"children":587},{"className":586},[],[588],{"type":141,"value":589},"tsc --noEmit",{"type":141,"value":257},{"type":136,"tag":158,"props":592,"children":594},{"id":593},"how-do-you-enforce-house-rules-on-ai-written-code-without-bloating-claudemd",[595],{"type":141,"value":596},"How do you enforce house rules on AI-written code without bloating CLAUDE.md?",{"type":136,"tag":137,"props":598,"children":599},{},[600,602,608,610,615],{"type":141,"value":601},"Rules written as prose in ",{"type":136,"tag":170,"props":603,"children":605},{"className":604},[],[606],{"type":141,"value":607},"CLAUDE.md",{"type":141,"value":609}," load into every session, whether or not the agent touches that code. They grow with every rule and depend on the agent remembering them. For a component framework I maintain, I moved the conventions into a Biome plugin. The first commit says it replaces the authoring skill as the source of truth. ",{"type":136,"tag":170,"props":611,"children":613},{"className":612},[],[614],{"type":141,"value":607},{"type":141,"value":616}," now mentions the plugin and how to suppress a rule, and nothing else about those conventions. One of the AI best practices I rely on is simple: if a machine can check a rule, a machine checks it.",{"type":136,"tag":137,"props":618,"children":619},{},[620,622,628,630,636,638,644],{"type":141,"value":621},"Biome supports custom lint rules written in GritQL. You save a ",{"type":136,"tag":170,"props":623,"children":625},{"className":624},[],[626],{"type":141,"value":627},".grit",{"type":141,"value":629}," file and list it under ",{"type":136,"tag":170,"props":631,"children":633},{"className":632},[],[634],{"type":141,"value":635},"plugins",{"type":141,"value":637}," in ",{"type":136,"tag":170,"props":639,"children":641},{"className":640},[],[642],{"type":141,"value":643},"biome.json",{"type":141,"value":645},". The package has 15 rules, mostly banning a raw browser API in favor of a managed wrapper that cleans up when a component is destroyed. This one covers timers:",{"type":136,"tag":202,"props":647,"children":651},{"className":648,"code":649,"language":650,"meta":207,"style":207},"language-grit shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","`$callee($args)` where {\n    $callee \u003C: or { `setTimeout`, `setInterval`, `clearTimeout`, `clearInterval` },\n    $program \u003C: contains JsExtendsClause(super_class=$base) where {\n        $base \u003C: r\".*Component$\"\n    },\n    register_diagnostic(\n        span=$callee,\n        message=\"Use this.ctx.setTimeout(fn, ms) (or this.ctx.setInterval \u002F clearTimeout \u002F clearInterval). Auto-cancelled when the component destroys.\",\n        severity=\"error\"\n    )\n}\n","grit",[652],{"type":136,"tag":170,"props":653,"children":654},{"__ignoreMap":207},[655,663,671,679,687,695,703,711,719,727,736],{"type":136,"tag":213,"props":656,"children":657},{"class":215,"line":216},[658],{"type":136,"tag":213,"props":659,"children":660},{},[661],{"type":141,"value":662},"`$callee($args)` where {\n",{"type":136,"tag":213,"props":664,"children":665},{"class":215,"line":284},[666],{"type":136,"tag":213,"props":667,"children":668},{},[669],{"type":141,"value":670},"    $callee \u003C: or { `setTimeout`, `setInterval`, `clearTimeout`, `clearInterval` },\n",{"type":136,"tag":213,"props":672,"children":673},{"class":215,"line":303},[674],{"type":136,"tag":213,"props":675,"children":676},{},[677],{"type":141,"value":678},"    $program \u003C: contains JsExtendsClause(super_class=$base) where {\n",{"type":136,"tag":213,"props":680,"children":681},{"class":215,"line":316},[682],{"type":136,"tag":213,"props":683,"children":684},{},[685],{"type":141,"value":686},"        $base \u003C: r\".*Component$\"\n",{"type":136,"tag":213,"props":688,"children":689},{"class":215,"line":330},[690],{"type":136,"tag":213,"props":691,"children":692},{},[693],{"type":141,"value":694},"    },\n",{"type":136,"tag":213,"props":696,"children":697},{"class":215,"line":343},[698],{"type":136,"tag":213,"props":699,"children":700},{},[701],{"type":141,"value":702},"    register_diagnostic(\n",{"type":136,"tag":213,"props":704,"children":705},{"class":215,"line":356},[706],{"type":136,"tag":213,"props":707,"children":708},{},[709],{"type":141,"value":710},"        span=$callee,\n",{"type":136,"tag":213,"props":712,"children":713},{"class":215,"line":369},[714],{"type":136,"tag":213,"props":715,"children":716},{},[717],{"type":141,"value":718},"        message=\"Use this.ctx.setTimeout(fn, ms) (or this.ctx.setInterval \u002F clearTimeout \u002F clearInterval). Auto-cancelled when the component destroys.\",\n",{"type":136,"tag":213,"props":720,"children":721},{"class":215,"line":382},[722],{"type":136,"tag":213,"props":723,"children":724},{},[725],{"type":141,"value":726},"        severity=\"error\"\n",{"type":136,"tag":213,"props":728,"children":730},{"class":215,"line":729},10,[731],{"type":136,"tag":213,"props":732,"children":733},{},[734],{"type":141,"value":735},"    )\n",{"type":136,"tag":213,"props":737,"children":739},{"class":215,"line":738},11,[740],{"type":136,"tag":213,"props":741,"children":742},{},[743],{"type":141,"value":454},{"type":136,"tag":137,"props":745,"children":746},{},[747,749,755],{"type":141,"value":748},"The message matters most for agents. When the linter fails, that text is the hint the agent reads, at the moment it looks at the offending line. A message that names the replacement gets fixed on the next attempt. A bare \"not allowed\" invites a workaround. The rule fires only in files with a class that extends ",{"type":136,"tag":170,"props":750,"children":752},{"className":751},[],[753],{"type":141,"value":754},"*Component",{"type":141,"value":756},", so it stays quiet in build scripts and third-party helpers.",{"type":136,"tag":137,"props":758,"children":759},{},[760,762,768,770,776],{"type":141,"value":761},"Some rules catch code that looks correct. One bans ",{"type":136,"tag":170,"props":763,"children":765},{"className":764},[],[766],{"type":141,"value":767},"instanceof HTMLElement",{"type":141,"value":769}," and its relatives. Elements from the Elementor editor's preview iframe belong to another frame, so the check is silently false there and the feature does nothing. A rule can be suppressed with ",{"type":136,"tag":170,"props":771,"children":773},{"className":772},[],[774],{"type":141,"value":775},"\u002F\u002F biome-ignore lint\u002Fplugin: \u003Creason>",{"type":141,"value":777},", and the reason has to explain why the rule does not apply. An agent that wants around a rule has to write that down where you will see it in the diff.",{"type":136,"tag":137,"props":779,"children":780},{},[781,783,789],{"type":141,"value":782},"To verify the rules, each one has an invalid and a valid fixture. The test runs Biome over both and asserts an exact diagnostic count, zero on the valid file, and that the message contains the hint. The first release shipped ten rules and only six matched, because four patterns missed class members with modifiers like ",{"type":136,"tag":170,"props":784,"children":786},{"className":785},[],[787],{"type":141,"value":788},"public override",{"type":141,"value":790},". Biome's output looked the same as it does for a clean file. The suite now runs 33 tests that would catch that.",{"type":136,"tag":137,"props":792,"children":793},{},[794,796,802,804,810,812,818,820,826,828,833],{"type":141,"value":795},"GritQL has limits. It cannot follow data between statements, so ",{"type":136,"tag":170,"props":797,"children":799},{"className":798},[],[800],{"type":141,"value":801},"matchMedia(q).addEventListener(...)",{"type":141,"value":803}," is caught and ",{"type":136,"tag":170,"props":805,"children":807},{"className":806},[],[808],{"type":141,"value":809},"const mq = matchMedia(q); mq.addEventListener(...)",{"type":141,"value":811}," is not. It cannot see filenames, so \"a class in ",{"type":136,"tag":170,"props":813,"children":815},{"className":814},[],[816],{"type":141,"value":817},"Foo.ts",{"type":141,"value":819}," must be named ",{"type":136,"tag":170,"props":821,"children":823},{"className":822},[],[824],{"type":141,"value":825},"Foo",{"type":141,"value":827},"\" stays in ",{"type":136,"tag":170,"props":829,"children":831},{"className":830},[],[832],{"type":141,"value":607},{"type":141,"value":257},{"type":136,"tag":158,"props":835,"children":837},{"id":836},"why-do-ai-code-checks-pass-when-they-should-fail",[838],{"type":141,"value":839},"Why do AI code checks pass when they should fail?",{"type":136,"tag":137,"props":841,"children":842},{},[843],{"type":141,"value":844},"A check that cannot fail gives the agent nothing to react to. Mine failed that way more than once.",{"type":136,"tag":137,"props":846,"children":847},{},[848],{"type":136,"tag":849,"props":850,"children":853},"img",{"alt":851,"src":852},"Three ways a check passes when it should fail: PHPStan passing from a warm result cache, blanket ignores that no longer match anything, and skip lists naming files that no longer exist, each shown with its fix","\u002Fapi\u002Fblog\u002Fimages\u002Fblog\u002F1\u002F7626918e-2436-45b8-aa55-520782d66599.webp",[],{"type":136,"tag":137,"props":855,"children":856},{},[857,859,865,867,873,874,880,882,888],{"type":141,"value":858},"In one theme, ",{"type":136,"tag":170,"props":860,"children":862},{"className":861},[],[863],{"type":141,"value":864},"reportUnmatchedIgnoredErrors",{"type":141,"value":866}," was off in the PHPStan config, and three blanket ignores inherited from an older project had stopped matching anything. With the flag off there was no way to tell. I turned it on, deleted the ignores, and found that PHPStan had only been passing from a warm result cache. A fresh run reported four real type errors, where functions such as ",{"type":136,"tag":170,"props":868,"children":870},{"className":869},[],[871],{"type":141,"value":872},"wp_parse_id_list()",{"type":141,"value":521},{"type":136,"tag":170,"props":875,"children":877},{"className":876},[],[878],{"type":141,"value":879},"implode()",{"type":141,"value":881}," received values of the wrong type. Run ",{"type":136,"tag":170,"props":883,"children":885},{"className":884},[],[886],{"type":141,"value":887},"phpstan clear-result-cache",{"type":141,"value":889}," and check again before you trust a green result.",{"type":136,"tag":137,"props":891,"children":892},{},[893,895,901,903,909,911,917,919,925],{"type":141,"value":894},"In a second repository, a Rector skip list and a lint allowlist pointed at files that had been renamed or never existed. An allowlist entry that matches no file never fires, and a skip that matches no path stops protecting what it was written for. I added a script that checks every path in ",{"type":136,"tag":170,"props":896,"children":898},{"className":897},[],[899],{"type":141,"value":900},"rector.php",{"type":141,"value":902}," and the test configs exists on disk, and wired it into ",{"type":136,"tag":170,"props":904,"children":906},{"className":905},[],[907],{"type":141,"value":908},"composer check",{"type":141,"value":910},". Biome has its own version: plugin globs match against the absolute file path, so a root-relative ",{"type":136,"tag":170,"props":912,"children":914},{"className":913},[],[915],{"type":141,"value":916},"src\u002F",{"type":141,"value":918},"** silently never fires and needs a **",{"type":136,"tag":170,"props":920,"children":922},{"className":921},[],[923],{"type":141,"value":924},"\u002F",{"type":141,"value":926}," prefix.",{"type":136,"tag":137,"props":928,"children":929},{},[930,932,938,940,946,948,953],{"type":141,"value":931},"Static analysis also has a blind spot: strings. A filter name is a string, so PHPStan cannot know that nothing registers the tag. One of my packages called ",{"type":136,"tag":170,"props":933,"children":935},{"className":934},[],[936],{"type":141,"value":937},"elementor_pro\u002Fquery_control\u002Fget_query_args\u002Fcurrent_query",{"type":141,"value":939},", which exists nowhere in Elementor Pro. WordPress treats ",{"type":136,"tag":170,"props":941,"children":943},{"className":942},[],[944],{"type":141,"value":945},"apply_filters",{"type":141,"value":947}," on an unregistered tag as a no-op, so nothing failed, and the code sat behind a misleading comment. The fix was a test that pins the one real tag and expects the invented name never to be called. Tests are the next layer, and they belong in ",{"type":136,"tag":149,"props":949,"children":950},{"href":151},[951],{"type":141,"value":952},"your definition of done",{"type":141,"value":954},". For code an agent writes in volume, mutation testing with Infection checks that those tests fail when the logic is wrong.",{"type":136,"tag":137,"props":956,"children":957},{},[958,960,966],{"type":141,"value":959},"The same limit applies to accessibility. The ",{"type":136,"tag":149,"props":961,"children":963},{"href":962},"\u002Fblog\u002Fai-best-practices\u002Fai-generated-ui-components-fail-wcag-2-2-review-checklist",[964],{"type":141,"value":965},"accessibility review checklist for AI-generated components",{"type":141,"value":967}," treats an automated scan as the floor of a review. A passing check is where the review of AI production code starts.",{"type":136,"tag":158,"props":969,"children":971},{"id":970},"how-do-claude-code-hooks-make-an-ai-agent-run-checks-before-it-says-done",[972],{"type":141,"value":973},"How do Claude Code hooks make an AI agent run checks before it says done?",{"type":136,"tag":137,"props":975,"children":976},{},[977],{"type":141,"value":978},"An agent skips a check it is not forced to run. Hooks remove that choice. You can attach checks at five points, and the earlier points give faster feedback.",{"type":136,"tag":980,"props":981,"children":982},"table",{},[983,1007],{"type":136,"tag":984,"props":985,"children":986},"thead",{},[987],{"type":136,"tag":988,"props":989,"children":990},"tr",{},[991,997,1002],{"type":136,"tag":992,"props":993,"children":994},"th",{},[995],{"type":141,"value":996},"When",{"type":136,"tag":992,"props":998,"children":999},{},[1000],{"type":141,"value":1001},"Where",{"type":136,"tag":992,"props":1003,"children":1004},{},[1005],{"type":141,"value":1006},"What runs",{"type":136,"tag":1008,"props":1009,"children":1010},"tbody",{},[1011,1038,1068,1091,1109],{"type":136,"tag":988,"props":1012,"children":1013},{},[1014,1020,1033],{"type":136,"tag":1015,"props":1016,"children":1017},"td",{},[1018],{"type":141,"value":1019},"Right after an edit",{"type":136,"tag":1015,"props":1021,"children":1022},{},[1023,1025,1031],{"type":141,"value":1024},"Claude Code ",{"type":136,"tag":170,"props":1026,"children":1028},{"className":1027},[],[1029],{"type":141,"value":1030},"PostToolUse",{"type":141,"value":1032}," hook",{"type":136,"tag":1015,"props":1034,"children":1035},{},[1036],{"type":141,"value":1037},"Lint the one file",{"type":136,"tag":988,"props":1039,"children":1040},{},[1041,1046,1057],{"type":136,"tag":1015,"props":1042,"children":1043},{},[1044],{"type":141,"value":1045},"When the agent says it is finished",{"type":136,"tag":1015,"props":1047,"children":1048},{},[1049,1050,1056],{"type":141,"value":1024},{"type":136,"tag":170,"props":1051,"children":1053},{"className":1052},[],[1054],{"type":141,"value":1055},"Stop",{"type":141,"value":1032},{"type":136,"tag":1015,"props":1058,"children":1059},{},[1060,1062],{"type":141,"value":1061},"PHPStan and ",{"type":136,"tag":170,"props":1063,"children":1065},{"className":1064},[],[1066],{"type":141,"value":1067},"tsc",{"type":136,"tag":988,"props":1069,"children":1070},{},[1071,1076,1081],{"type":136,"tag":1015,"props":1072,"children":1073},{},[1074],{"type":141,"value":1075},"On commit",{"type":136,"tag":1015,"props":1077,"children":1078},{},[1079],{"type":141,"value":1080},"git pre-commit",{"type":136,"tag":1015,"props":1082,"children":1083},{},[1084,1086],{"type":141,"value":1085},"Biome, Rector, PHP_CodeSniffer, ",{"type":136,"tag":170,"props":1087,"children":1089},{"className":1088},[],[1090],{"type":141,"value":1067},{"type":136,"tag":988,"props":1092,"children":1093},{},[1094,1099,1104],{"type":136,"tag":1015,"props":1095,"children":1096},{},[1097],{"type":141,"value":1098},"On push",{"type":136,"tag":1015,"props":1100,"children":1101},{},[1102],{"type":141,"value":1103},"git pre-push",{"type":136,"tag":1015,"props":1105,"children":1106},{},[1107],{"type":141,"value":1108},"PHPStan and tests",{"type":136,"tag":988,"props":1110,"children":1111},{},[1112,1117,1122],{"type":136,"tag":1015,"props":1113,"children":1114},{},[1115],{"type":141,"value":1116},"On pull request",{"type":136,"tag":1015,"props":1118,"children":1119},{},[1120],{"type":141,"value":1121},"continuous integration (CI)",{"type":136,"tag":1015,"props":1123,"children":1124},{},[1125],{"type":141,"value":1126},"Everything, from a cold cache",{"type":136,"tag":137,"props":1128,"children":1129},{},[1130],{"type":136,"tag":849,"props":1131,"children":1134},{"alt":1132,"src":1133},"Diagram of five checkpoints in order: a PostToolUse hook lints the edited file, a Stop hook runs PHPStan and tsc, git pre-commit runs Biome, Rector, PHP_CodeSniffer and tsc, git pre-push runs PHPStan and tests, and CI runs everything from a cold cache","\u002Fapi\u002Fblog\u002Fimages\u002Fblog\u002F1\u002Fcf5f598f-5a48-490a-a151-6bc9b819457e.webp",[],{"type":136,"tag":137,"props":1136,"children":1137},{},[1138,1140,1145,1147,1152,1154,1160,1162,1168,1170,1176],{"type":141,"value":1139},"The Claude Code hooks documentation describes the two events that matter here. ",{"type":136,"tag":170,"props":1141,"children":1143},{"className":1142},[],[1144],{"type":141,"value":1030},{"type":141,"value":1146}," fires after a tool call succeeds, and exit code 2 shows your stderr to Claude. The edit has already happened, so the hook cannot undo it, but the agent sees the error immediately. ",{"type":136,"tag":170,"props":1148,"children":1150},{"className":1149},[],[1151],{"type":141,"value":1055},{"type":141,"value":1153}," fires when Claude finishes responding, and exit code 2 prevents it from stopping and continues the conversation with your stderr as the reason. Hooks live in a settings file: ",{"type":136,"tag":170,"props":1155,"children":1157},{"className":1156},[],[1158],{"type":141,"value":1159},"~\u002F.claude\u002Fsettings.json",{"type":141,"value":1161}," applies everywhere, and ",{"type":136,"tag":170,"props":1163,"children":1165},{"className":1164},[],[1166],{"type":141,"value":1167},".claude\u002Fsettings.json",{"type":141,"value":1169}," is committed with the project. A third file, ",{"type":136,"tag":170,"props":1171,"children":1173},{"className":1172},[],[1174],{"type":141,"value":1175},".claude\u002Fsettings.local.json",{"type":141,"value":1177},", stays personal. Each hook receives a JSON payload on stdin.",{"type":136,"tag":202,"props":1179,"children":1183},{"className":1180,"code":1181,"language":1182,"meta":207,"style":207},"language-jsonc shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","{\n  \"hooks\": {\n    \"PostToolUse\": [\n      {\n        \"matcher\": \"Edit|Write\",\n        \"hooks\": [{ \"type\": \"command\", \"command\": \"\\\"$CLAUDE_PROJECT_DIR\\\"\u002F.claude\u002Fhooks\u002Flint-edited.sh\" }]\n      }\n    ],\n    \"Stop\": [\n      { \"hooks\": [{ \"type\": \"command\", \"command\": \"\\\"$CLAUDE_PROJECT_DIR\\\"\u002F.claude\u002Fhooks\u002Fverify-stop.sh\" }] }\n    ]\n  }\n}\n","jsonc",[1184],{"type":136,"tag":170,"props":1185,"children":1186},{"__ignoreMap":207},[1187,1195,1203,1211,1219,1227,1235,1243,1251,1259,1267,1275,1284],{"type":136,"tag":213,"props":1188,"children":1189},{"class":215,"line":216},[1190],{"type":136,"tag":213,"props":1191,"children":1192},{},[1193],{"type":141,"value":1194},"{\n",{"type":136,"tag":213,"props":1196,"children":1197},{"class":215,"line":284},[1198],{"type":136,"tag":213,"props":1199,"children":1200},{},[1201],{"type":141,"value":1202},"  \"hooks\": {\n",{"type":136,"tag":213,"props":1204,"children":1205},{"class":215,"line":303},[1206],{"type":136,"tag":213,"props":1207,"children":1208},{},[1209],{"type":141,"value":1210},"    \"PostToolUse\": [\n",{"type":136,"tag":213,"props":1212,"children":1213},{"class":215,"line":316},[1214],{"type":136,"tag":213,"props":1215,"children":1216},{},[1217],{"type":141,"value":1218},"      {\n",{"type":136,"tag":213,"props":1220,"children":1221},{"class":215,"line":330},[1222],{"type":136,"tag":213,"props":1223,"children":1224},{},[1225],{"type":141,"value":1226},"        \"matcher\": \"Edit|Write\",\n",{"type":136,"tag":213,"props":1228,"children":1229},{"class":215,"line":343},[1230],{"type":136,"tag":213,"props":1231,"children":1232},{},[1233],{"type":141,"value":1234},"        \"hooks\": [{ \"type\": \"command\", \"command\": \"\\\"$CLAUDE_PROJECT_DIR\\\"\u002F.claude\u002Fhooks\u002Flint-edited.sh\" }]\n",{"type":136,"tag":213,"props":1236,"children":1237},{"class":215,"line":356},[1238],{"type":136,"tag":213,"props":1239,"children":1240},{},[1241],{"type":141,"value":1242},"      }\n",{"type":136,"tag":213,"props":1244,"children":1245},{"class":215,"line":369},[1246],{"type":136,"tag":213,"props":1247,"children":1248},{},[1249],{"type":141,"value":1250},"    ],\n",{"type":136,"tag":213,"props":1252,"children":1253},{"class":215,"line":382},[1254],{"type":136,"tag":213,"props":1255,"children":1256},{},[1257],{"type":141,"value":1258},"    \"Stop\": [\n",{"type":136,"tag":213,"props":1260,"children":1261},{"class":215,"line":729},[1262],{"type":136,"tag":213,"props":1263,"children":1264},{},[1265],{"type":141,"value":1266},"      { \"hooks\": [{ \"type\": \"command\", \"command\": \"\\\"$CLAUDE_PROJECT_DIR\\\"\u002F.claude\u002Fhooks\u002Fverify-stop.sh\" }] }\n",{"type":136,"tag":213,"props":1268,"children":1269},{"class":215,"line":738},[1270],{"type":136,"tag":213,"props":1271,"children":1272},{},[1273],{"type":141,"value":1274},"    ]\n",{"type":136,"tag":213,"props":1276,"children":1278},{"class":215,"line":1277},12,[1279],{"type":136,"tag":213,"props":1280,"children":1281},{},[1282],{"type":141,"value":1283},"  }\n",{"type":136,"tag":213,"props":1285,"children":1287},{"class":215,"line":1286},13,[1288],{"type":136,"tag":213,"props":1289,"children":1290},{},[1291],{"type":141,"value":454},{"type":136,"tag":202,"props":1293,"children":1295},{"className":204,"code":1294,"language":206,"meta":207,"style":207},"#!\u002Fusr\u002Fbin\u002Fenv bash\n# lint-edited.sh: check the file that was just touched\nfile=$(jq -r '.tool_input.file_path \u002F\u002F empty')\ncase \"$file\" in\n  *.ts)  pnpm exec biome lint \"$file\" >&2 || exit 2 ;;\n  *.php) php -l \"$file\" >&2 || exit 2 ;;\nesac\n",[1296],{"type":136,"tag":170,"props":1297,"children":1298},{"__ignoreMap":207},[1299,1308,1316,1360,1389,1466,1524],{"type":136,"tag":213,"props":1300,"children":1301},{"class":215,"line":216},[1302],{"type":136,"tag":213,"props":1303,"children":1305},{"style":1304},"--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic",[1306],{"type":141,"value":1307},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",{"type":136,"tag":213,"props":1309,"children":1310},{"class":215,"line":284},[1311],{"type":136,"tag":213,"props":1312,"children":1313},{"style":1304},[1314],{"type":141,"value":1315},"# lint-edited.sh: check the file that was just touched\n",{"type":136,"tag":213,"props":1317,"children":1318},{"class":215,"line":303},[1319,1325,1330,1335,1340,1345,1350,1355],{"type":136,"tag":213,"props":1320,"children":1322},{"style":1321},"--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8",[1323],{"type":141,"value":1324},"file",{"type":136,"tag":213,"props":1326,"children":1327},{"style":278},[1328],{"type":141,"value":1329},"=$(",{"type":136,"tag":213,"props":1331,"children":1332},{"style":220},[1333],{"type":141,"value":1334},"jq",{"type":136,"tag":213,"props":1336,"children":1337},{"style":226},[1338],{"type":141,"value":1339}," -r",{"type":136,"tag":213,"props":1341,"children":1342},{"style":278},[1343],{"type":141,"value":1344}," '",{"type":136,"tag":213,"props":1346,"children":1347},{"style":226},[1348],{"type":141,"value":1349},".tool_input.file_path \u002F\u002F empty",{"type":136,"tag":213,"props":1351,"children":1352},{"style":278},[1353],{"type":141,"value":1354},"'",{"type":136,"tag":213,"props":1356,"children":1357},{"style":278},[1358],{"type":141,"value":1359},")\n",{"type":136,"tag":213,"props":1361,"children":1362},{"class":215,"line":316},[1363,1369,1374,1379,1384],{"type":136,"tag":213,"props":1364,"children":1366},{"style":1365},"--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic",[1367],{"type":141,"value":1368},"case",{"type":136,"tag":213,"props":1370,"children":1371},{"style":278},[1372],{"type":141,"value":1373}," \"",{"type":136,"tag":213,"props":1375,"children":1376},{"style":1321},[1377],{"type":141,"value":1378},"$file",{"type":136,"tag":213,"props":1380,"children":1381},{"style":278},[1382],{"type":141,"value":1383},"\"",{"type":136,"tag":213,"props":1385,"children":1386},{"style":1365},[1387],{"type":141,"value":1388}," in\n",{"type":136,"tag":213,"props":1390,"children":1391},{"class":215,"line":330},[1392,1397,1402,1407,1412,1417,1422,1427,1431,1435,1439,1444,1449,1455,1461],{"type":136,"tag":213,"props":1393,"children":1394},{"style":278},[1395],{"type":141,"value":1396},"  *",{"type":136,"tag":213,"props":1398,"children":1399},{"style":226},[1400],{"type":141,"value":1401},".ts",{"type":136,"tag":213,"props":1403,"children":1404},{"style":278},[1405],{"type":141,"value":1406},")",{"type":136,"tag":213,"props":1408,"children":1409},{"style":220},[1410],{"type":141,"value":1411},"  pnpm",{"type":136,"tag":213,"props":1413,"children":1414},{"style":226},[1415],{"type":141,"value":1416}," exec",{"type":136,"tag":213,"props":1418,"children":1419},{"style":226},[1420],{"type":141,"value":1421}," biome",{"type":136,"tag":213,"props":1423,"children":1424},{"style":226},[1425],{"type":141,"value":1426}," lint",{"type":136,"tag":213,"props":1428,"children":1429},{"style":278},[1430],{"type":141,"value":1373},{"type":136,"tag":213,"props":1432,"children":1433},{"style":1321},[1434],{"type":141,"value":1378},{"type":136,"tag":213,"props":1436,"children":1437},{"style":278},[1438],{"type":141,"value":1383},{"type":136,"tag":213,"props":1440,"children":1441},{"style":278},[1442],{"type":141,"value":1443}," >&2",{"type":136,"tag":213,"props":1445,"children":1446},{"style":278},[1447],{"type":141,"value":1448}," ||",{"type":136,"tag":213,"props":1450,"children":1452},{"style":1451},"--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF",[1453],{"type":141,"value":1454}," exit",{"type":136,"tag":213,"props":1456,"children":1458},{"style":1457},"--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C",[1459],{"type":141,"value":1460}," 2",{"type":136,"tag":213,"props":1462,"children":1463},{"style":278},[1464],{"type":141,"value":1465}," ;;\n",{"type":136,"tag":213,"props":1467,"children":1468},{"class":215,"line":343},[1469,1473,1478,1482,1487,1492,1496,1500,1504,1508,1512,1516,1520],{"type":136,"tag":213,"props":1470,"children":1471},{"style":278},[1472],{"type":141,"value":1396},{"type":136,"tag":213,"props":1474,"children":1475},{"style":226},[1476],{"type":141,"value":1477},".php",{"type":136,"tag":213,"props":1479,"children":1480},{"style":278},[1481],{"type":141,"value":1406},{"type":136,"tag":213,"props":1483,"children":1484},{"style":220},[1485],{"type":141,"value":1486}," php",{"type":136,"tag":213,"props":1488,"children":1489},{"style":226},[1490],{"type":141,"value":1491}," -l",{"type":136,"tag":213,"props":1493,"children":1494},{"style":278},[1495],{"type":141,"value":1373},{"type":136,"tag":213,"props":1497,"children":1498},{"style":1321},[1499],{"type":141,"value":1378},{"type":136,"tag":213,"props":1501,"children":1502},{"style":278},[1503],{"type":141,"value":1383},{"type":136,"tag":213,"props":1505,"children":1506},{"style":278},[1507],{"type":141,"value":1443},{"type":136,"tag":213,"props":1509,"children":1510},{"style":278},[1511],{"type":141,"value":1448},{"type":136,"tag":213,"props":1513,"children":1514},{"style":1451},[1515],{"type":141,"value":1454},{"type":136,"tag":213,"props":1517,"children":1518},{"style":1457},[1519],{"type":141,"value":1460},{"type":136,"tag":213,"props":1521,"children":1522},{"style":278},[1523],{"type":141,"value":1465},{"type":136,"tag":213,"props":1525,"children":1526},{"class":215,"line":356},[1527],{"type":136,"tag":213,"props":1528,"children":1529},{"style":1365},[1530],{"type":141,"value":1531},"esac\n",{"type":136,"tag":202,"props":1533,"children":1535},{"className":204,"code":1534,"language":206,"meta":207,"style":207},"#!\u002Fusr\u002Fbin\u002Fenv bash\n# verify-stop.sh: block \"done\" while the project's checks fail\ninput=$(cat)\ncd \"${CLAUDE_PROJECT_DIR:-.}\" || exit 0\n\n# Nothing edited this turn: skip the checks.\n[ -z \"$(git status --porcelain)\" ] && exit 0\n\n# Already continued once because of this hook: let the agent stop and report.\n[ \"$(jq -r '.stop_hook_active' \u003C\u003C\u003C\"$input\")\" = \"true\" ] && exit 0\n\nout=$(composer phpstan 2>&1 && pnpm check:types 2>&1) || {\n  printf '%s\\n' \"$out\" | tail -40 >&2\n  exit 2\n}\n",[1536],{"type":136,"tag":170,"props":1537,"children":1538},{"__ignoreMap":207},[1539,1546,1554,1575,1620,1628,1636,1687,1694,1702,1784,1791,1845,1899,1913],{"type":136,"tag":213,"props":1540,"children":1541},{"class":215,"line":216},[1542],{"type":136,"tag":213,"props":1543,"children":1544},{"style":1304},[1545],{"type":141,"value":1307},{"type":136,"tag":213,"props":1547,"children":1548},{"class":215,"line":284},[1549],{"type":136,"tag":213,"props":1550,"children":1551},{"style":1304},[1552],{"type":141,"value":1553},"# verify-stop.sh: block \"done\" while the project's checks fail\n",{"type":136,"tag":213,"props":1555,"children":1556},{"class":215,"line":303},[1557,1562,1566,1571],{"type":136,"tag":213,"props":1558,"children":1559},{"style":1321},[1560],{"type":141,"value":1561},"input",{"type":136,"tag":213,"props":1563,"children":1564},{"style":278},[1565],{"type":141,"value":1329},{"type":136,"tag":213,"props":1567,"children":1568},{"style":220},[1569],{"type":141,"value":1570},"cat",{"type":136,"tag":213,"props":1572,"children":1573},{"style":278},[1574],{"type":141,"value":1359},{"type":136,"tag":213,"props":1576,"children":1577},{"class":215,"line":316},[1578,1583,1588,1593,1598,1602,1607,1611,1615],{"type":136,"tag":213,"props":1579,"children":1580},{"style":1451},[1581],{"type":141,"value":1582},"cd",{"type":136,"tag":213,"props":1584,"children":1585},{"style":278},[1586],{"type":141,"value":1587}," \"${",{"type":136,"tag":213,"props":1589,"children":1590},{"style":1321},[1591],{"type":141,"value":1592},"CLAUDE_PROJECT_DIR",{"type":136,"tag":213,"props":1594,"children":1595},{"style":278},[1596],{"type":141,"value":1597},":-",{"type":136,"tag":213,"props":1599,"children":1600},{"style":226},[1601],{"type":141,"value":257},{"type":136,"tag":213,"props":1603,"children":1604},{"style":278},[1605],{"type":141,"value":1606},"}\"",{"type":136,"tag":213,"props":1608,"children":1609},{"style":278},[1610],{"type":141,"value":1448},{"type":136,"tag":213,"props":1612,"children":1613},{"style":1451},[1614],{"type":141,"value":1454},{"type":136,"tag":213,"props":1616,"children":1617},{"style":1457},[1618],{"type":141,"value":1619}," 0\n",{"type":136,"tag":213,"props":1621,"children":1622},{"class":215,"line":330},[1623],{"type":136,"tag":213,"props":1624,"children":1625},{"emptyLinePlaceholder":79},[1626],{"type":141,"value":1627},"\n",{"type":136,"tag":213,"props":1629,"children":1630},{"class":215,"line":343},[1631],{"type":136,"tag":213,"props":1632,"children":1633},{"style":1304},[1634],{"type":141,"value":1635},"# Nothing edited this turn: skip the checks.\n",{"type":136,"tag":213,"props":1637,"children":1638},{"class":215,"line":356},[1639,1644,1649,1654,1659,1664,1669,1674,1679,1683],{"type":136,"tag":213,"props":1640,"children":1641},{"style":278},[1642],{"type":141,"value":1643},"[",{"type":136,"tag":213,"props":1645,"children":1646},{"style":278},[1647],{"type":141,"value":1648}," -z",{"type":136,"tag":213,"props":1650,"children":1651},{"style":278},[1652],{"type":141,"value":1653}," \"$(",{"type":136,"tag":213,"props":1655,"children":1656},{"style":220},[1657],{"type":141,"value":1658},"git",{"type":136,"tag":213,"props":1660,"children":1661},{"style":226},[1662],{"type":141,"value":1663}," status --porcelain",{"type":136,"tag":213,"props":1665,"children":1666},{"style":278},[1667],{"type":141,"value":1668},")\"",{"type":136,"tag":213,"props":1670,"children":1671},{"style":278},[1672],{"type":141,"value":1673}," ]",{"type":136,"tag":213,"props":1675,"children":1676},{"style":278},[1677],{"type":141,"value":1678}," &&",{"type":136,"tag":213,"props":1680,"children":1681},{"style":1451},[1682],{"type":141,"value":1454},{"type":136,"tag":213,"props":1684,"children":1685},{"style":1457},[1686],{"type":141,"value":1619},{"type":136,"tag":213,"props":1688,"children":1689},{"class":215,"line":369},[1690],{"type":136,"tag":213,"props":1691,"children":1692},{"emptyLinePlaceholder":79},[1693],{"type":141,"value":1627},{"type":136,"tag":213,"props":1695,"children":1696},{"class":215,"line":382},[1697],{"type":136,"tag":213,"props":1698,"children":1699},{"style":1304},[1700],{"type":141,"value":1701},"# Already continued once because of this hook: let the agent stop and report.\n",{"type":136,"tag":213,"props":1703,"children":1704},{"class":215,"line":729},[1705,1709,1713,1717,1722,1726,1731,1735,1740,1745,1750,1755,1759,1764,1768,1772,1776,1780],{"type":136,"tag":213,"props":1706,"children":1707},{"style":278},[1708],{"type":141,"value":1643},{"type":136,"tag":213,"props":1710,"children":1711},{"style":278},[1712],{"type":141,"value":1653},{"type":136,"tag":213,"props":1714,"children":1715},{"style":220},[1716],{"type":141,"value":1334},{"type":136,"tag":213,"props":1718,"children":1719},{"style":226},[1720],{"type":141,"value":1721}," -r ",{"type":136,"tag":213,"props":1723,"children":1724},{"style":278},[1725],{"type":141,"value":1354},{"type":136,"tag":213,"props":1727,"children":1728},{"style":226},[1729],{"type":141,"value":1730},".stop_hook_active",{"type":136,"tag":213,"props":1732,"children":1733},{"style":278},[1734],{"type":141,"value":1354},{"type":136,"tag":213,"props":1736,"children":1737},{"style":278},[1738],{"type":141,"value":1739}," \u003C\u003C\u003C\"",{"type":136,"tag":213,"props":1741,"children":1742},{"style":1321},[1743],{"type":141,"value":1744},"$input",{"type":136,"tag":213,"props":1746,"children":1747},{"style":278},[1748],{"type":141,"value":1749},"\")\"",{"type":136,"tag":213,"props":1751,"children":1752},{"style":278},[1753],{"type":141,"value":1754}," =",{"type":136,"tag":213,"props":1756,"children":1757},{"style":278},[1758],{"type":141,"value":1373},{"type":136,"tag":213,"props":1760,"children":1761},{"style":226},[1762],{"type":141,"value":1763},"true",{"type":136,"tag":213,"props":1765,"children":1766},{"style":278},[1767],{"type":141,"value":1383},{"type":136,"tag":213,"props":1769,"children":1770},{"style":278},[1771],{"type":141,"value":1673},{"type":136,"tag":213,"props":1773,"children":1774},{"style":278},[1775],{"type":141,"value":1678},{"type":136,"tag":213,"props":1777,"children":1778},{"style":1451},[1779],{"type":141,"value":1454},{"type":136,"tag":213,"props":1781,"children":1782},{"style":1457},[1783],{"type":141,"value":1619},{"type":136,"tag":213,"props":1785,"children":1786},{"class":215,"line":738},[1787],{"type":136,"tag":213,"props":1788,"children":1789},{"emptyLinePlaceholder":79},[1790],{"type":141,"value":1627},{"type":136,"tag":213,"props":1792,"children":1793},{"class":215,"line":1277},[1794,1799,1803,1807,1812,1817,1821,1826,1831,1836,1840],{"type":136,"tag":213,"props":1795,"children":1796},{"style":1321},[1797],{"type":141,"value":1798},"out",{"type":136,"tag":213,"props":1800,"children":1801},{"style":278},[1802],{"type":141,"value":1329},{"type":136,"tag":213,"props":1804,"children":1805},{"style":220},[1806],{"type":141,"value":223},{"type":136,"tag":213,"props":1808,"children":1809},{"style":226},[1810],{"type":141,"value":1811}," phpstan",{"type":136,"tag":213,"props":1813,"children":1814},{"style":278},[1815],{"type":141,"value":1816}," 2>&1",{"type":136,"tag":213,"props":1818,"children":1819},{"style":278},[1820],{"type":141,"value":1678},{"type":136,"tag":213,"props":1822,"children":1823},{"style":220},[1824],{"type":141,"value":1825}," pnpm",{"type":136,"tag":213,"props":1827,"children":1828},{"style":226},[1829],{"type":141,"value":1830}," check:types",{"type":136,"tag":213,"props":1832,"children":1833},{"style":278},[1834],{"type":141,"value":1835}," 2>&1)",{"type":136,"tag":213,"props":1837,"children":1838},{"style":278},[1839],{"type":141,"value":1448},{"type":136,"tag":213,"props":1841,"children":1842},{"style":278},[1843],{"type":141,"value":1844}," {\n",{"type":136,"tag":213,"props":1846,"children":1847},{"class":215,"line":1286},[1848,1853,1857,1862,1866,1870,1875,1879,1884,1889,1894],{"type":136,"tag":213,"props":1849,"children":1850},{"style":1451},[1851],{"type":141,"value":1852},"  printf",{"type":136,"tag":213,"props":1854,"children":1855},{"style":278},[1856],{"type":141,"value":1344},{"type":136,"tag":213,"props":1858,"children":1859},{"style":226},[1860],{"type":141,"value":1861},"%s\\n",{"type":136,"tag":213,"props":1863,"children":1864},{"style":278},[1865],{"type":141,"value":1354},{"type":136,"tag":213,"props":1867,"children":1868},{"style":278},[1869],{"type":141,"value":1373},{"type":136,"tag":213,"props":1871,"children":1872},{"style":1321},[1873],{"type":141,"value":1874},"$out",{"type":136,"tag":213,"props":1876,"children":1877},{"style":278},[1878],{"type":141,"value":1383},{"type":136,"tag":213,"props":1880,"children":1881},{"style":278},[1882],{"type":141,"value":1883}," |",{"type":136,"tag":213,"props":1885,"children":1886},{"style":220},[1887],{"type":141,"value":1888}," tail",{"type":136,"tag":213,"props":1890,"children":1891},{"style":226},[1892],{"type":141,"value":1893}," -40",{"type":136,"tag":213,"props":1895,"children":1896},{"style":278},[1897],{"type":141,"value":1898}," >&2\n",{"type":136,"tag":213,"props":1900,"children":1902},{"class":215,"line":1901},14,[1903,1908],{"type":136,"tag":213,"props":1904,"children":1905},{"style":1451},[1906],{"type":141,"value":1907},"  exit",{"type":136,"tag":213,"props":1909,"children":1910},{"style":1457},[1911],{"type":141,"value":1912}," 2\n",{"type":136,"tag":213,"props":1914,"children":1916},{"class":215,"line":1915},15,[1917],{"type":136,"tag":213,"props":1918,"children":1919},{"style":278},[1920],{"type":141,"value":454},{"type":136,"tag":137,"props":1922,"children":1923},{},[1924,1926,1932,1934,1940,1942,1947,1949,1955,1957,1962,1964,1971,1973,1979,1981,1986,1988,1993],{"type":141,"value":1925},"I ran both scripts. The lint script exits 2 with Biome's message on a ",{"type":136,"tag":170,"props":1927,"children":1929},{"className":1928},[],[1930],{"type":141,"value":1931},"debugger;",{"type":141,"value":1933}," statement and 0 on a clean file. The stop script exits 2 with the PHPStan error, skips a clean working tree, and lets the agent stop when ",{"type":136,"tag":170,"props":1935,"children":1937},{"className":1936},[],[1938],{"type":141,"value":1939},"stop_hook_active",{"type":141,"value":1941}," is true. ",{"type":136,"tag":170,"props":1943,"children":1945},{"className":1944},[],[1946],{"type":141,"value":1055},{"type":141,"value":1948}," fires on every turn, including ones where the agent only answered a question, so the ",{"type":136,"tag":170,"props":1950,"children":1952},{"className":1951},[],[1953],{"type":141,"value":1954},"git status",{"type":141,"value":1956}," line skips the checks when nothing changed. The documentation describes ",{"type":136,"tag":170,"props":1958,"children":1960},{"className":1959},[],[1961],{"type":141,"value":1939},{"type":141,"value":1963}," as true when Claude is already continuing because of a stop hook, and says to check it so errors the agent cannot fix do not trap it. Claude Code also ",{"type":136,"tag":149,"props":1965,"children":1968},{"href":1966,"rel":1967},"https:\u002F\u002Fcode.claude.com\u002Fdocs\u002Fen\u002Fhooks#stop-input",[195],[1969],{"type":141,"value":1970},"caps stop-hook continuations at eight in a row",{"type":141,"value":1972},", and ",{"type":136,"tag":170,"props":1974,"children":1976},{"className":1975},[],[1977],{"type":141,"value":1978},"CLAUDE_CODE_STOP_HOOK_BLOCK_CAP",{"type":141,"value":1980}," raises the cap. Most events time out after 600 seconds by default, but keep ",{"type":136,"tag":170,"props":1982,"children":1984},{"className":1983},[],[1985],{"type":141,"value":1030},{"type":141,"value":1987}," fast and put the whole-project checks on ",{"type":136,"tag":170,"props":1989,"children":1991},{"className":1990},[],[1992],{"type":141,"value":1055},{"type":141,"value":257},{"type":136,"tag":137,"props":1995,"children":1996},{},[1997,1999,2005,2007,2012,2014,2019,2021,2027],{"type":141,"value":1998},"Git hooks catch the agent when it commits and cover tools with no hook system. I use Lefthook in the JavaScript-first repositories and CaptainHook in the PHP package. Pre-commit rewrites and re-stages files, and pre-push runs the slower checks. Order matters. I had Rector running after ",{"type":136,"tag":170,"props":2000,"children":2002},{"className":2001},[],[2003],{"type":141,"value":2004},"phpcbf",{"type":141,"value":2006},". Rector prints changed nodes with space indentation and can import a class name from a docblock, so misformatted files reached the commit. Running Rector first and letting ",{"type":136,"tag":170,"props":2008,"children":2010},{"className":2009},[],[2011],{"type":141,"value":2004},{"type":141,"value":2013}," clean up fixed it, with ",{"type":136,"tag":170,"props":2015,"children":2017},{"className":2016},[],[2018],{"type":141,"value":2004},{"type":141,"value":2020}," exit code 1 treated as success because it means the files were fixed. These hooks stay advisory (",{"type":136,"tag":170,"props":2022,"children":2024},{"className":2023},[],[2025],{"type":141,"value":2026},"LEFTHOOK=0",{"type":141,"value":2028}," skips them for a work-in-progress commit), so CI remains the authoritative gate.",{"type":136,"tag":158,"props":2030,"children":2032},{"id":2031},"when-should-you-refactor-ai-written-code",[2033],{"type":141,"value":2034},"When should you refactor AI-written code?",{"type":136,"tag":137,"props":2036,"children":2037},{},[2038],{"type":141,"value":2039},"Refactor suggesters answer a different question from the checks above. PHPStan asks whether the code is wrong. These tools ask what should change next, and an agent that applies their output blindly can break working code.",{"type":136,"tag":137,"props":2041,"children":2042},{},[2043,2045,2051],{"type":141,"value":2044},"On the TypeScript side I run Fallow and Knip. Fallow reports dead code and circular imports, and it measures duplication and complexity. It cannot see components that are discovered from rendered HTML, so I list the entry points in ",{"type":136,"tag":170,"props":2046,"children":2048},{"className":2047},[],[2049],{"type":141,"value":2050},".fallowrc.jsonc",{"type":141,"value":2052},". Even then the output needs checking. After I added one unrelated file, Fallow 3.32.0 changed the unused-class-member count for one library's classes from 9 to 13. Before I delete anything it reports, I check the callers. Knip has a similar blind spot for packages consumed only from Sass, which no JavaScript import graph can see, so its config lists those dependencies as ignored.",{"type":136,"tag":137,"props":2054,"children":2055},{},[2056,2058,2064],{"type":141,"value":2057},"On the PHP side I run Rector with four prepared sets: dead code, code quality, type declarations and early returns. Style and naming sets stay off because PHP_CodeSniffer owns style. The config is pinned to PHP 8.0, so it cannot suggest ",{"type":136,"tag":170,"props":2059,"children":2061},{"className":2060},[],[2062],{"type":141,"value":2063},"readonly",{"type":141,"value":2065}," or enums in a package that ships to shared hosting. It also skips the files that are tied to Elementor's runtime, with a comment on each explaining that their behavior can only be exercised in a live Elementor. Pre-commit runs Rector on staged PHP files, so every change it makes is visible in the commit.",{"type":136,"tag":137,"props":2067,"children":2068},{},[2069],{"type":141,"value":2070},"A practical trigger for a larger refactor: when a report for the same module keeps growing between agent sessions, plan a deliberate rework instead of another patch.",{"type":136,"tag":158,"props":2072,"children":2074},{"id":2073},"how-do-you-set-this-up-step-by-step",[2075],{"type":141,"value":2076},"How do you set this up, step by step?",{"type":136,"tag":137,"props":2078,"children":2079},{},[2080],{"type":141,"value":2081},"The steps below follow the order that pays off fastest. Each one ends with a way to check that it works.",{"type":136,"tag":137,"props":2083,"children":2084},{},[2085,2091,2093,2099,2101,2106,2108,2113],{"type":136,"tag":2086,"props":2087,"children":2088},"strong",{},[2089],{"type":141,"value":2090},"Step 1: Run PHPStan at max with stubs.",{"type":141,"value":2092}," Install ",{"type":136,"tag":170,"props":2094,"children":2096},{"className":2095},[],[2097],{"type":141,"value":2098},"phpstan\u002Fphpstan",{"type":141,"value":2100}," and the stubs for every plugin you call, then set ",{"type":136,"tag":170,"props":2102,"children":2104},{"className":2103},[],[2105],{"type":141,"value":188},{"type":141,"value":2107},". To verify, call a method that does not exist and confirm PHPStan reports ",{"type":136,"tag":170,"props":2109,"children":2111},{"className":2110},[],[2112],{"type":141,"value":473},{"type":141,"value":257},{"type":136,"tag":137,"props":2115,"children":2116},{},[2117,2122,2124,2129],{"type":136,"tag":2086,"props":2118,"children":2119},{},[2120],{"type":141,"value":2121},"Step 2: Move house rules into Biome rules.",{"type":141,"value":2123}," Write one ",{"type":136,"tag":170,"props":2125,"children":2127},{"className":2126},[],[2128],{"type":141,"value":627},{"type":141,"value":2130}," file per rule, with a message that names the replacement. To verify, keep an invalid fixture that must fire an exact number of times and a valid fixture that must stay silent.",{"type":136,"tag":137,"props":2132,"children":2133},{},[2134,2139,2141,2146,2148,2154,2156,2161],{"type":136,"tag":2086,"props":2135,"children":2136},{},[2137],{"type":141,"value":2138},"Step 3: Lint each edited file.",{"type":141,"value":2140}," Add a ",{"type":136,"tag":170,"props":2142,"children":2144},{"className":2143},[],[2145],{"type":141,"value":1030},{"type":141,"value":2147}," hook with the matcher ",{"type":136,"tag":170,"props":2149,"children":2151},{"className":2150},[],[2152],{"type":141,"value":2153},"Edit|Write",{"type":141,"value":2155}," that runs the linter on the file path from the payload. To verify, have the agent write a ",{"type":136,"tag":170,"props":2157,"children":2159},{"className":2158},[],[2160],{"type":141,"value":1931},{"type":141,"value":2162}," line and confirm it receives the error in the same turn.",{"type":136,"tag":137,"props":2164,"children":2165},{},[2166,2171,2172,2177,2179,2184],{"type":136,"tag":2086,"props":2167,"children":2168},{},[2169],{"type":141,"value":2170},"Step 4: Block \"done\" while checks fail.",{"type":141,"value":2140},{"type":136,"tag":170,"props":2173,"children":2175},{"className":2174},[],[2176],{"type":141,"value":1055},{"type":141,"value":2178}," hook that runs PHPStan and the TypeScript compiler, and check ",{"type":136,"tag":170,"props":2180,"children":2182},{"className":2181},[],[2183],{"type":141,"value":1939},{"type":141,"value":2185}," so unfixable errors cannot trap the agent. To verify, rename a function in a call and confirm the agent is sent back to fix it.",{"type":136,"tag":137,"props":2187,"children":2188},{},[2189,2194],{"type":136,"tag":2086,"props":2190,"children":2191},{},[2192],{"type":141,"value":2193},"Step 5: Add git hooks as the backstop.",{"type":141,"value":2195}," Run formatters and fixers on pre-commit and the slow checks on pre-push. To verify, commit a file that breaks a rule and confirm the hook stops it.",{"type":136,"tag":137,"props":2197,"children":2198},{},[2199,2204,2206,2211],{"type":136,"tag":2086,"props":2200,"children":2201},{},[2202],{"type":141,"value":2203},"Step 6: Prove every check can fail.",{"type":141,"value":2205}," Turn on ",{"type":136,"tag":170,"props":2207,"children":2209},{"className":2208},[],[2210],{"type":141,"value":864},{"type":141,"value":2212},", run PHPStan from a cold cache, and check that every path in your skip lists exists. To verify, delete a file named in a skip list and confirm your config check fails.",{"type":136,"tag":158,"props":2214,"children":2216},{"id":2215},"key-takeaways",[2217],{"type":141,"value":2218},"Key takeaways",{"type":136,"tag":2220,"props":2221,"children":2222},"ul",{},[2223,2229,2241,2260],{"type":136,"tag":2224,"props":2225,"children":2226},"li",{},[2227],{"type":141,"value":2228},"PHPStan with stubs for every plugin you call catches invented functions and methods. Prompting does not.",{"type":136,"tag":2224,"props":2230,"children":2231},{},[2232,2234,2239],{"type":141,"value":2233},"Rules a pattern can match belong in Biome plugin rules whose messages name the replacement. ",{"type":136,"tag":170,"props":2235,"children":2237},{"className":2236},[],[2238],{"type":141,"value":607},{"type":141,"value":2240}," keeps the rest.",{"type":136,"tag":2224,"props":2242,"children":2243},{},[2244,2246,2251,2253,2258],{"type":141,"value":2245},"A ",{"type":136,"tag":170,"props":2247,"children":2249},{"className":2248},[],[2250],{"type":141,"value":1055},{"type":141,"value":2252}," hook runs the checks before the agent can say done, and a ",{"type":136,"tag":170,"props":2254,"children":2256},{"className":2255},[],[2257],{"type":141,"value":1030},{"type":141,"value":2259}," hook gives quick per-file feedback.",{"type":136,"tag":2224,"props":2261,"children":2262},{},[2263],{"type":141,"value":2264},"A check that cannot fail is worse than none. Test the rules and clear the cache, and check every path in your configs.",{"type":136,"tag":2266,"props":2267,"children":2268},"style",{},[2269],{"type":141,"value":2270},"html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}"]