Skip to content

Dummies: balancing groups and invalid group names are silently accepted by StringMatching #209

Description

@Reefact

Problem

SkipGroupName (RegexParser.cs:295-300) scans to the group-name terminator without validating the captured name:

while (!AtEnd && Peek() != terminator) { _index++; }

Anything between (?< and > (or between the quotes of the (?'name') form) is treated as an ordinary named group. Two well-formed .NET constructs slip through:

  • A balancing group (?<-name>…) / (?<name1-name2>…). This is non-regular — it manipulates a capture stack, the same family as backreferences, which the library proudly rejects. Any.StringMatching(@"(?<a>y)?(?<-a>x)") generates "x", which the real engine does not match (the pattern's language is exactly {"yx"} — the -a pop forces the optional a group to have fired).
  • An invalid group name — e.g. (?<a b>x) (a space) or (?<1a>x) (a digit-led name that is not a valid number). .NET rejects the name at compile time; Dummies accepts it.

Impact

This is the one place in the audited surface where the library's signature promise is broken. UnsupportedRegexException (and AnyPattern's docs, ADR-0025, and the package README) all state that a non-regular or malformed construct is "refused with a clear error rather than a silently non-matching value." Here a balancing-group pattern silently produces strings the pattern's real language excludes — the exact "silently non-matching value" the design forbids.

Direction

Validate the captured group name (in SkipGroupName or at its call sites):

  • If it contains -, throw Unsupported("a balancing group '(?<name1-name2>…)'", position) — non-regular, same treatment as backreferences. This applies even when the target group is undefined (e.g. (?<-a>x) on its own). .NET happens to reject that particular case as a malformed pattern ("reference to undefined group name"), because the group is never captured — but distinguishing a defined target (which .NET accepts, and which is genuinely non-regular) from an undefined one (which .NET rejects) would require tracking every captured group by name, a capture table this generator deliberately does not keep. We therefore treat any - in a group name as an unsupported balancing group. This is a deliberate, assumed divergence: for the undefined-target case the error kind differs from .NET (UnsupportedRegexException vs ArgumentException), but both reject the pattern and neither mis-generates — so the signature promise (never a silently non-matching value) holds.
  • Otherwise, validate the name exactly as the real engine does:
    • a name that opens with a digit is an explicit capture number, valid only as a positive integer with no leading zero1, 10 pass; 0 (reserved for the whole match), 01 and 1a are refused;
    • any other name must be word characters (letter, digit or underscore);
    • a violation throws Malformed(...), mirroring the real engine.

The earlier phrasing of this rule — "letter/digit/underscore, not starting with a digit" — was incorrect: it would reject valid explicitly-numbered groups such as (?<1>x) and (?'2'x), which .NET accepts. The corrected rule above keeps them working.

Acceptance criteria

  • Any.StringMatching(@"(?<-a>x)") (undefined target) and @"(?<a>y)?(?<-a>x)") (defined target — a pattern .NET accepts) both throw UnsupportedRegexException naming the balancing group.
  • Any.StringMatching(@"(?<1a>x)"), @"(?<0>x)" and @"(?<01>x)" throw ArgumentException (malformed), matching .NET's rejection.
  • Explicitly-numbered groups ((?<1>x), (?'2'x), (?<10>ab)) and ordinary named / non-capturing groups continue to parse and generate unchanged.
  • Tests cover both forms ((?<…>) and (?'…')) and both rejection channels.

Context

Surfaced by the 2026-07-20 architecture & design audit of the standalone Dummies library (doc/handwritten/for-maintainers/audit/2026-07-20-dummies-architecture-and-design-audit.md, §4.1(d); branch claude/dummies-architecture-audit-cru5aq).


Filed from the Claude Code session that produced the 2026-07-20 Dummies architecture & design audit: https://claude.ai/code/session_01GvrWFVoG74xiys3oyEft1G

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions