Regex accept/reject anchoring
Would have caught, not "caught here"
See Cookbook for what that distinction means and how it was verified.
The bug
docs/evolved-rules.md ER-001 in this monorepo: an identifier validator whitelisted names with
preg_match('/^[a-z_][a-z0-9_]*$/', $identifier)PCRE's $ matches at the end of the subject or immediately before a trailing "\n" — it is not the same anchor as "end of string". So "orders\n" is accepted as a valid identifier, even though the intent was to reject anything but [a-z_][a-z0-9_]* exactly. The fix is a one-character anchor swap: \z, which matches only the true end of the string.
Why the unit test stayed green
A conventional test suite for this validator asserts on hand-picked strings:
Assert::true(acceptsIdentifier('user_id'));
Assert::false(acceptsIdentifier('user id')); // space
Assert::false(acceptsIdentifier('1user')); // leading digit
Assert::false(acceptsIdentifier('')); // emptyNone of these authors thought to type a literal newline into a test string — $identifier = "user_id\n" looks like a copy-paste artifact, not a test case worth writing by hand. The bug lives exactly in the part of the input space nobody enumerates manually.
The property
Generate strings from an alphabet that includes "\n" — constructing the interesting input directly instead of hoping a general-purpose string generator draws it by chance (root AGENTS.md, "конструировать, не фильтровать") — and assert the $-anchored and \z-anchored versions of the same pattern agree on every one of them:
function acceptsBuggy(string $identifier): bool
{
return (bool) preg_match('/^[a-z_][a-z0-9_]*$/', $identifier);
}
function acceptsFixed(string $identifier): bool
{
return (bool) preg_match('/^[a-z_][a-z0-9_]*\z/', $identifier);
}
$alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789_' . "\n";
$agreement = static function (string $identifier): void {
if (acceptsBuggy($identifier) !== acceptsFixed($identifier)) {
throw new RuntimeException('anchors disagree on ' . var_export($identifier, true));
}
};Full runnable script: examples/case-studies/regex-anchor.php.
Runner output
Buggy $-anchored validator falsified:
Property falsified after 40 successful run(s); seed=42
Original: identifier="quxz5\n"
Shrunk: identifier="aaaaa\n" (5 shrink step(s), 18 trial(s))
Changed: identifier="quxz5\n" -> "aaaaa\n"
Failure: $-anchor and \z-anchor disagree on 'aaaaa
': $ says accept, \z says rejectForty passing runs on identifiers without a trailing newline, then the 41st draw includes one — the shrinker strips it down to the shortest identifier that still reproduces the disagreement, five as plus the newline.
The fix
- preg_match('/^[a-z_][a-z0-9_]*$/', $identifier)
+ preg_match('/^[a-z_][a-z0-9_]*\z/', $identifier)Root AGENTS.md's security table already codifies this: identifier whitelists are anchored with \z, never a bare $.