Skip to content

Add prefix, wildcard, and hierarchical glob pattern builders - #12

Merged
Mearman merged 2 commits into
mainfrom
feat/pattern-matching-builders
Sep 3, 2026
Merged

Mearman merged 2 commits into
mainfrom
feat/pattern-matching-builders

Conversation

@Mearman

@Mearman Mearman commented Sep 3, 2026

Copy link
Copy Markdown
Member

Closes #11

Writing the regular expression for a prefix match or a glob by hand is where these go wrong -- get the escape-then-convert ordering backwards and either wildcards stop working, or a literal asterisk sitting in real data silently starts behaving like one. This adds three builder functions that do that compilation once, correctly, so a consumer never writes it again.

  • prefixPattern(text, prefix) -- word-boundary prefix. "ls" matches "ls" and "ls -la", never "lsof". The prefix is a plain literal throughout, so a * in it is just an asterisk.
  • wildcardPattern(text, pattern) -- flat wildcards, no notion of path segments. * is any run of characters, \* a literal asterisk, \\ a literal backslash. A pattern whose only unescaped wildcard is a trailing " *" also matches the bare prefix, so "git *" covers "git" as well as "git add file"; "git * *" still requires both.
  • hierarchicalGlobPattern(text, pattern) -- segment-aware, for path- or category-tree-shaped values. * stays inside one /-delimited segment, ** crosses them, ? is one within-segment character. No escape syntax, so a backslash is a backslash.

They are deliberately three dialects rather than one function with a mode argument: they answer different questions, and mixing them silently changes what a pattern means.

None of them is a new node kind. Each returns an ordinary textCompare node with op: "matches" and the compiled regex as a textLiteral, so there is no schema change and no evaluator branch -- a serialised tree is indistinguishable from a textCompare someone wrote out by hand, and the three-valued behaviour (an unresolvable subject is indeterminate, not a non-match) is inherited rather than re-decided. Ported from the design in Mearman/agent-permissions's src/evaluate.ts.

Three deliberate divergences from that reference, all behaviour-preserving:

  1. Compilation is a single left-to-right pass rather than successive replacement over sentinel placeholders, so a pattern that happens to contain a sentinel's own text cannot be corrupted by the restoration step. ** is consumed as a unit, so "***" still reads as ** then *, exactly as the replacement approach paired it.
  2. "Any character" is [\s\S] rather than ., because the evaluator compiles the stored pattern with new RegExp(value) and no flags -- nothing downstream can add the s the reference relies on at its own call site.
  3. The escape set is ECMAScript's SyntaxCharacter set plus /. The reference also escapes quotes, which are inert unflagged but a SyntaxError under u.

That third point is what the second commit fixes. The v flag reserves / inside a character class, so the glob dialect's [^/]* and [^/] were a SyntaxError under a v-flagged RegExp -- which is essentially every glob, since one without a * or ? is just a literal. The compiled pattern is meant to be portable to whatever the consumer compiles it with, and both the module comment and the README say so explicitly. [^\/] compiles unflagged, under u and under v, matching exactly what [^/] matched wherever it compiled at all. The original portability test only checked u, which cannot catch this -- u accepts a bare / in a class and v does not -- so it now checks all three, against a glob consisting of nothing but a wildcard.

Verified by hand beyond the test suite, driving the real evaluatePredicate end to end rather than eyeballing regex strings: escaped-vs-unescaped asterisks against data containing real asterisks, literal and dangling backslashes, the trailing-wildcard convenience against a bare prefix (including when an escaped \* appears earlier in the same pattern), *-does-not-cross-/ versus **-does against strings with real separators, empty patterns and bare */**/?, regex-injection attempts through the pattern string, and newline-smuggling against the anchors. A sweep over every printable ASCII character, singly and doubled, across all three builders confirms every compiled pattern now parses under no flag, u and v alike.

Compile a pattern string into an ordinary textCompare "matches" node
carrying a pre-compiled regular expression as a textLiteral, so the
three common narrower cases stop being hand-written per consumer.

prefixPattern enforces a word boundary, so "ls" matches "ls" and
"ls -la" but never "lsof".
wildcardPattern is a flat dialect where an unescaped * matches any run
of characters, \* matches a literal asterisk, \\ a literal backslash,
and a pattern whose only wildcard is a trailing " *" also matches the
bare prefix.
hierarchicalGlobPattern is a segment-aware dialect where * stays inside
one /-delimited segment, ** crosses segments, and ? matches one
within-segment character.

Each is a builder, not a node kind: no schema change, no evaluator
branch, and a serialised tree is indistinguishable from a textCompare
written out by hand, so three-valued behaviour is inherited rather than
re-decided.

Compilation happens in a single left-to-right pass rather than by
successive replacement over sentinel placeholders, so a pattern that
happens to contain a sentinel's own text cannot be corrupted by the
restoration step. The compiled pattern is fully anchored and flag-free:
"any character" is spelled [\s\S] because the stored string carries no s
flag and nothing downstream can add one, and only ECMAScript's
SyntaxCharacter set plus / is escaped, which keeps the pattern valid
under a u-flagged RegExp as well as an unflagged one.
…sses

A glob's `*` and `?` compiled to `[^/]*` and `[^/]`, whose bare `/` is a
ClassSetReservedPunctuator in `v` mode and therefore rejected inside a
character class.
Every `hierarchicalGlobPattern` output containing a wildcard was a
SyntaxError under a `v`-flagged RegExp -- in practice nearly all of them,
since a glob with no `*` or `?` is just a literal.

The builders exist to emit a portable regular-expression string that a
consumer compiles itself, and both the module's own escape-set comment and
the README state that a compiled pattern stays valid under a `u`- or
`v`-flagged RegExp as well as an unflagged one.
The glob dialect was the one construct not honouring that: the escape set
governs literal characters, and these two class constants bypassed it.
`[^\/]` compiles under `v`, `u` and no flags alike, and matches exactly
what `[^/]` matched wherever `[^/]` compiled at all, so the stored pattern's
meaning is unchanged everywhere it already worked.

The existing portability test checked only the `u` flag, which is why this
survived: `u` accepts a bare `/` in a class and `v` does not, so `u` alone
can never catch it.
It now checks unflagged, `u` and `v`, and covers a glob consisting of
nothing but a wildcard rather than only one carrying other literals
alongside.
@Mearman
Mearman marked this pull request as ready for review September 3, 2026 05:29
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review Completed 2026-09-03T05:36:13.442821Z bf5c1da Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Mearman
Mearman merged commit 0f9481b into main Sep 3, 2026
12 checks passed
@Mearman
Mearman deleted the feat/pattern-matching-builders branch September 3, 2026 06:40
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.3.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add portable pattern-matching builders (prefix, wildcard, hierarchical glob) compiling to textCompare

1 participant