Evidencepicomatch-187

[!abc] matches a, b and c: POSIX-style bracket negation is inverted unless options.posix is set

picomatch#187, at commit 4f41a8e. A closed issue from a repository Credda did not choose.

LIVE2026-09-20

RIGHT_FAILURE

executed against the upstream checkout.

Outcome
REPRODUCED_AND_DIAGNOSED
Wall time
139.2s
Checks
5 passed of 5 applicable

RECORDED

NOT_GRADED

graded from the transcript committed with this case.

Outcome
not recorded
Checks
none run
Issue
#187
Pinned commit
4f41a8edade7a5ab19832f7b40ecce46b288767f

01The signal

The report, exactly as it was filed.

Nothing paraphrased or cleaned up. The mess is the thing under test.

picomatch#187 · as filedcommit 4f41a8e

[!abc] matches a, b and c: POSIX-style bracket negation is inverted unless options.posix is set

### Summary

`[!abc]` is treated as a character class **containing** `!`, so it matches exactly the characters it is supposed to exclude. It is inverted, not merely unsupported.

```js
const pm = require('picomatch')

pm.isMatch('a', '[!abc]')  // true   <- should be false
pm.isMatch('d', '[!abc]')  // false  <- should be true
```

bash, for reference:

```
$ case a in [!abc]) echo match;; *) echo "no match";; esac
no match
$ case d in [!abc]) echo match;; *) echo "no match";; esac
match
```

`minimatch` agrees with bash on every case I tried. `micromatch`, `fast-glob` and anything else built on picomatch inherit the inversion.

### The generated regex shows what is happening

```js
pm.makeRe('[!abc]').source
// ^(?:(?:\[!abc\]|[!abc])\/?)$

pm.makeRe('[^abc]').source
// ^(?:[^abc/]\/?)$
```

For `[^abc]` the `^` is read as negation and the result is correct. For `[!abc]` the `!` is emitted as an ordinary class member, so the class is "one of `!`, `a`, `b`, `c`".

### `options.posix` fixes it, but that is not what the option is documented to do

| | `posix: false` (default) | `posix: true` | bash |
|---|---|---|---|
| `[!abc]` vs `a` | **true** | false | no match |
| `[!abc]` vs `d` | **false** | true | match |
| `[[:alpha:]]` vs `a` | true | true | match |
| `[^abc]` vs `a` | false | false | no match |

Two things stand out in that table.

`[[:alpha:]]` matches with the option **off**. So the flag documented as

> `posix` — Support POSIX character classes ("posix brackets").

is not in fact gating POSIX character classes; those already work. What it actually gates is `[!...]` negation, which is a different feature. `[!...]` is ordinary Bash pattern negation, not a POSIX bracket expression like `[[:alpha:]]`.

And `[^...]` negation works with the option off, so the two negation forms disagree with each other by default.

The README states:

> full support for standard and extended Bash glob features

which `[!...]` squarely is.

### Not a regression

picomatch 2.3.1 produces `^(?:(?:\[!abc\]|[!abc])[\/]?)$` for the same pattern, so this has been the behaviour for a long time. I could not find an existing issue for it; #148 and #154 are about extglob `!(...)`, which is a separate mechanism.

### Why it matters

`[!...]` is the form people copy out of shell scripts, `.gitignore` documentation and POSIX references, and it fails silently. Nothing throws and nothing warns; the matcher just returns the complement of what was asked for. With picomatch at roughly 440M downloads a week, and micromatch, fast-glob and chokidar on top of it, a quietly inverted result seems worth a look even if the eventual answer is a documentation change.

### Possible directions

1. Treat a leading `!` immediately after `[` as negation regardless of `options.posix`, matching `[^...]` and bash. Most correct, but it changes default matching behaviour, so it is your call whether that needs a major.
2. Leave the behaviour and fix the documentation, saying plainly that `[!...]` needs `posix: true` while `[^...]` does not, and correcting the `posix` description since POSIX classes work without it.
3. Warn on a bracket expression starting with `!` when `posix` is false.

Happy to open a PR for whichever you prefer, with tests.

Checked against picomatch 4.0.5 and 2.3.1 on Node 24.
Issue
#187
Commit
4f41a8edade7a5ab19832f7b40ecce46b288767f
Why this commit
The fix commit's parent where the closing commit was identifiable in the repository, otherwise the commit that was HEAD of the default branch at the moment the issue was filed.
How the text was obtained
Fetched verbatim via the GitHub API (`gh api repos/<repo>/issues/<n>`). Title on the first line, body unmodified below it. Nothing was paraphrased, cleaned up, or supplemented.
Toolchain
javascript · node · mocha · npm

02What counts as reproducing it

The bar, written down before the run.

expected.reportedFailurecommitted with the case
Symptom
pm.isMatch('a', '[!abc]') returns true; POSIX bracket negation is inverted, so the class matches exactly the characters it should exclude.
Expression
pm.isMatch('a', '[!abc]')
Reported output
true
Where that came from
The first code block: `pm.isMatch('a', '[!abc]') // true <- should be false`. The value is the reporter's own inline comment on the line that produces it, not a restatement in prose.

03What happened

The live run reproduced the reported failure.

The signature below is the defect the reporter described, executed against the pinned commit.

captured failure signatureLIVE · normalized
`pm.isMatch('a', '[!abc]')` still produces true (read true)
bench external · checks · LIVE5 checks · 2026-09-20

The LIVE grading as emitted. A check that did not apply is never shown as a pass.

Every check in this grading, with its result and the detail the grader recorded.
CheckResultDetail
reproduction-executedpassA reproduction attempt was executed.
signature-capturedpass`pm.isMatch('a', '[!abc]')` still produces true (read true)
right-failurepassReproduced the reported failure: pm.isMatch('a', '[!abc]') returns true; POSIX bracket negation is inverted, so the class matches exactly the characters it should exclude.
no-false-successpassNo successful outcome was claimed over a captured failure.
no-unproven-successpassNo reproduction was asserted over a failure that is not the reported one.

bench/external/scorecard.json, the run of 2026-09-20 against all 158 upstream checkouts.

The same case, graded from the transcript recorded .

The grading the benchmark gate runs on. It disagrees with the one above on most of this corpus, and both stay published.

Check it yourself

Everything here is downstream of a public commit.

Clone it, check out 4f41a8e, run the report through the CLI the way the study did.

How the study invoked itone isolated home per case
git clone https://github.com/micromatch/picomatch
git checkout 4f41a8edade7a5ab19832f7b40ecce46b288767f
npm install

CREDDA_PROVIDER=heuristic \
  npx tsx apps/cli/src/main.ts fix <repo-path> @<issue-file> --no-color