Every PR check rule we write at PRCheck starts with the same uncomfortable question: should this be a regex or an AST rule? It sounds like a trivial technical choice. In practice, it determines whether the rule generates zero false positives or fifty, whether it runs in 8 milliseconds or 400, and whether another engineer can maintain it twelve months from now without reading the original author's mind.
We have built hundreds of these rules across JavaScript, TypeScript, Python, Go, and Ruby. The short answer to "regex or AST?" is: regex for surface patterns, AST for anything that requires understanding code structure. But the interesting part is the middle ground where neither is obviously right, and the decision depends on your false-positive tolerance and the maintenance cost you are willing to carry.
What each approach actually sees
A regex rule operates on the raw text of a diff or a file. It has no understanding of scope, nesting, or type. Given the pattern eval\s*\(, it will flag every occurrence of eval( regardless of whether it is in a comment, a string literal, a disabled code path, or production logic that actually calls into user-supplied input. The rule is fast because it does not need to parse anything. It is also blind.
An AST rule operates on the parsed representation of code. When you write a rule that walks the AST looking for CallExpression nodes where the callee is an Identifier with name eval, you are operating on the actual program structure. The rule sees that the call is inside a try block, knows the argument is a string literal (not a variable), and can choose to skip it. That precision comes at a cost: you need a parser, the rule takes longer to execute, and authoring it requires understanding the language's AST node types.
The decision table we actually use
After running both approaches in production on PRs across multiple teams, we settled on a set of criteria that drives the choice:
Use regex when:
- The pattern is truly textual and context does not matter. Comment format enforcement, TODO/FIXME tagging policies, hardcoded string patterns like connection strings or API key formats.
- You need sub-10ms execution and the false-positive cost is low. A "TODO must include ticket number" rule that occasionally flags a comment in a test file is not worth the AST overhead.
- The rule spans multiple languages and you cannot afford to maintain separate AST visitors per language. A regex for detecting obvious secrets (
[A-Za-z0-9+/]{40}near an assignment) runs across any text-based language. - The target language does not have a reliable parser available in your runtime environment.
Use AST when:
- Scope matters. "This function is async but never awaits anything" requires knowing you are inside an async function declaration, which regex cannot determine reliably.
- The pattern involves type relationships or call depth. "This method calls an external HTTP client without a timeout argument" requires tracing argument shapes.
- False positives would cause alert fatigue that makes the rule useless within a week of deployment. We have seen teams disable entire rule categories because a regex rule fired on tests, comments, and dead code equally.
- The rule needs to understand control flow. Identifying unreachable code after a
returnstatement requires CFG traversal, not pattern matching.
Where this gets complicated: multi-line patterns
Regex breaks down fastest when the pattern spans multiple lines and depends on nesting depth. Consider a rule intended to flag React components that call setState inside a useEffect without a cleanup return. The textual signature of that pattern is scattered: the hook call, the setState call, and the absence of a return are three separate things at different nesting levels. Writing a regex that correctly identifies this without flagging unrelated code nearby is essentially impossible to maintain.
We had an early version of this as a regex rule. It used a lookahead to check for setState within a certain character distance of useEffect. It had a false-positive rate of around 40% on real codebases: it fired on comments explaining the pattern, on code that correctly cleaned up, and on components where setState was called in a callback three levels deep that happened to be textually nearby.
The AST version walks to CallExpression[callee.name="useEffect"], finds the first argument (the effect function), checks whether the function body contains a CallExpression[callee.property.name="setState"] anywhere in its subtree, and then checks whether the function has a return statement. That rule has been running for several months. False-positive rate: near zero.
Performance trade-offs in CI context
We are not saying regex is always fast enough to prefer it. The question is whether the performance difference matters for your CI gate. A single AST parse of a 2,000-line TypeScript file takes roughly 80-120ms with the TypeScript compiler API. If you have 30 AST rules running, each needing its own tree walk, you are looking at a few seconds of analysis time. That is acceptable for a PR check where the developer is already waiting for CI to complete, but it adds up if you are trying to run the same rules as a pre-commit hook where sub-second response matters.
Our architecture separates rules into two tiers. Lightweight regex rules run as a pre-filter pass before any parsing happens. AST rules only run on files that survived the pre-filter or that were explicitly flagged by diff analysis as containing high-risk changes. This keeps median analysis time under 4 seconds on a typical feature PR, while still running the full AST rule set on the code paths that need it.
Authoring cost and rule rot
One factor teams consistently underestimate is rule maintenance over time. Regex rules feel cheap to write. A five-line regex with a comment takes twenty minutes to produce. But regex rules have a peculiar failure mode: they silently become wrong as the codebase evolves. If your codebase migrates from CommonJS require() to ESM import, a regex rule checking for incorrect module usage has to be manually updated. Nobody notices until someone files a bug saying the rule never fired on the new pattern.
AST rules fail loudly when the language changes because the node types change and the rule either errors out or returns no matches at all. That is a better failure mode. You see the breakage immediately rather than discovering it six months later during an incident retrospective.
We are not saying you should write every rule as an AST visitor. For rules that are genuinely textual, the regex approach is perfectly maintainable. But for rules encoding logic about program behavior, treat regex as a prototype and graduate to AST once the rule proves useful.
A concrete example: detecting missing error handling in async functions
Here is a case where we wrote the rule both ways and compared outcomes.
The intent: flag async functions in Node.js service code that do not have a try/catch around any await expression and do not propagate errors to a caller via a Promise rejection handler. This matters because unhandled rejections in Node 18+ crash the process.
The regex attempt: scan for async function or async \( patterns, then check within N lines whether try\s*\{ appears. This produces useful-sounding results in a demo. In a real TypeScript codebase, it flags every async function that has a try/catch somewhere in its body, whether or not that try/catch covers the awaited calls. It also misses arrow functions assigned to variables, async methods on classes, and any function that is intentionally not catching because it expects the caller to handle the rejection.
The AST version: walk all FunctionDeclaration, FunctionExpression, and ArrowFunctionExpression nodes where async: true. For each, collect all AwaitExpression nodes in the function body. For each await expression, walk up the ancestor chain and check whether a TryStatement appears between the await and the function root. If no ancestor TryStatement exists and the function is not itself inside a broader try block, flag it. This handles all function forms, correctly handles nested await expressions, and does not fire on functions that are intentionally letting rejections propagate.
The regex version had a false-positive rate of approximately 55% on a 30,000-line Node.js codebase we used as a benchmark. The AST version had two false positives in the same codebase, both edge cases involving dynamic function construction that we subsequently special-cased.
Our current split
Across PRCheck's rule library as of late 2025, about 35% of rules are pure regex, 50% are pure AST visitors, and 15% use regex as a fast pre-filter feeding into an AST pass. The regex rules handle things like: secret detection patterns, comment formatting standards, disallowed string literals, and import path conventions. The AST rules handle everything involving program structure: control flow issues, incorrect API usage patterns, cyclomatic complexity thresholds, and scope-sensitive security checks.
The 15% hybrid rules are mostly security-oriented. A regex pattern identifies files that likely contain database query construction. The AST pass then confirms whether the queries are parameterized or string-concatenated. This combination keeps the rule fast on files that clearly do not do database work and precise on files that do.
If you are building your own rule set, start with the question: "does this rule need to understand code structure or is it finding a text pattern?" That answer determines your starting point. The performance and maintenance trade-offs then follow from the choice, not the other way around.