1 Commits

Author SHA1 Message Date
4951c966d4 feat: publish Regex Tools 0.2.0 2026-07-27 01:06:57 +02:00
180 changed files with 35344 additions and 503 deletions

2
.gitignore vendored
View File

@@ -1,6 +1,7 @@
node_modules/ node_modules/
dist/ dist/
release/ release/
.engine-build/
coverage/ coverage/
playwright-report/ playwright-report/
test-results/ test-results/
@@ -9,4 +10,3 @@ test-results/
.env .env
.env.* .env.*
!.env.example !.env.example

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
public/engines/pcre2/pcre2.mjs

View File

@@ -1,5 +1,58 @@
# Changelog # Changelog
## Unreleased
## 0.2.0 — 2026-07-27
- Open Quick reference and Capabilities as populated, accessible in-viewport
dialogs with keyboard dismissal, focus handling and explicit empty states.
- Add flavour-neutral version, flag, option and execution-worker registries
and expose only the ECMAScript and fully wired PCRE2 runtimes.
- Validate flavour/version/options consistently across workbench requests,
projects and test suites, including legacy schema-v1 identities.
- Add a production local-first Corpus / Apply workspace with bounded UTF-8
multi-file and pasted-text input, sequential killable worker jobs, progress,
cancellation, explicit whole-document/independent-line semantics, preserved
line separators, bounded capture extraction and replacement previews,
per-document exact/partial summaries, content-free JSON/CSV/NDJSON reports
and privacy-gated exact-output downloads and bounded ZIP export.
- Keep corpus content and results ephemeral: project schema v1 stores only the
selected workspace mode and rejects injected corpus payloads.
- Add a deterministic, offline PCRE2 10.47 WebAssembly runtime with pinned
source/toolchain identities, provenance, licences and checksums.
- Add bounded PCRE2 compile, match and native-substitution ABI calls with
match/depth/heap caps, copied caller-owned records, cleanup on every exit,
UTF-8-to-UTF-16 range normalization and lone-surrogate rejection.
- Run PCRE2 in a dedicated killable/recoverable worker, expose its real
flavour-specific flags and options, and add lexical syntax/replacement
coverage, official quick reference, conformance and browser tests.
- Add an ABI-v3 PCRE2 automatic-callout operation with caller-owned event and
mark buffers, dual event/byte caps, native truncation stop, exact UTF-8 and
UTF-16 positions, a separate killable worker and a bounded viewer that keeps
reported fields distinct from derived movement labels.
- Add ECMAScript-scoped advisory static risk diagnostics plus cancellable
bounded growth probes and cold/warm native-engine benchmarks with median/p95,
separate timing/outcome plots, runtime identity and strict
input/output/wall limits.
- Add an ECMAScript/PCRE2 comparison orchestrator with exact per-side requests,
independent worker timeout outcomes, normalized-range match/capture
alignment, replacement differences and explicit non-comparable states.
- Add a reviewed PCRE2 10.47 8-bit C17 generator with exact UTF-8 byte arrays,
native/host limits, error handling, deterministic source identities and
compile/execute golden gates against the named toolchain.
- Add deterministic, cancellable failing-subject minimization for saved-test
failures, capture ranges, exact engine timeouts and ECMAScript/PCRE2
mismatches, with real-worker verification, explicit budgets and honest
transform-local minimality.
- Add deterministic, seed-addressed ECMAScript positive/negative case
generation from the normalized AST, with bounded synthesis, actual-engine
verification, explicit unsupported coverage, cancellable workers and
provenance-preserving unit-test integration.
- Add an idempotent grammar-backed ECMAScript literal/control formatter with an
exact preview, separate source/candidate syntax and engine workers, capture
shape checks, applicable unit-test replay, explicit inconclusive states and a
mandatory confirmation before apply.
## 0.1.0 — 2026-07-24 ## 0.1.0 — 2026-07-24
- Added the open-source ECMAScript 2025 syntax provider using regexpp 4.12.2. - Added the open-source ECMAScript 2025 syntax provider using regexpp 4.12.2.

View File

@@ -0,0 +1,19 @@
Copyright (c) 2018 Emscripten authors (see AUTHORS in Emscripten)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

104
LICENSES/PCRE2.txt Normal file
View File

@@ -0,0 +1,104 @@
PCRE2 Licence
=============
| SPDX-License-Identifier: | BSD-3-Clause WITH PCRE2-exception |
|---------|-------|
PCRE2 is a library of functions to support regular expressions whose syntax
and semantics are as close as possible to those of the Perl 5 language.
Releases 10.00 and above of PCRE2 are distributed under the terms of the "BSD"
licence, as specified below, with one exemption for certain binary
redistributions. The documentation for PCRE2, supplied in the "doc" directory,
is distributed under the same terms as the software itself. The data in the
testdata directory is not copyrighted and is in the public domain.
The basic library functions are written in C and are freestanding. Also
included in the distribution is a just-in-time compiler that can be used to
optimize pattern matching. This is an optional feature that can be omitted when
the library is built. The just-in-time compiler is separately licensed under the
"2-clause BSD" licence.
COPYRIGHT
---------
### The basic library functions
Written by: Philip Hazel
Email local part: Philip.Hazel
Email domain: gmail.com
Retired from University of Cambridge Computing Service,
Cambridge, England.
Copyright (c) 1997-2007 University of Cambridge
Copyright (c) 2007-2024 Philip Hazel
All rights reserved.
### PCRE2 Just-In-Time compilation support
Written by: Zoltan Herczeg
Email local part: hzmester
Email domain: freemail.hu
Copyright (c) 2010-2024 Zoltan Herczeg
All rights reserved.
### Stack-less Just-In-Time compiler
Written by: Zoltan Herczeg
Email local part: hzmester
Email domain: freemail.hu
Copyright (c) 2009-2024 Zoltan Herczeg
All rights reserved.
The code in the `deps/sljit` directory has its own LICENSE file.
### All other contributions
Many other contributors have participated in the authorship of PCRE2. As PCRE2
has never required a Contributor Licensing Agreement, or other copyright
assignment agreement, all contributions have copyright retained by each
original contributor or their employer.
THE "BSD" LICENCE
-----------------
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notices,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notices, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of the University of Cambridge nor the names of any
contributors may be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
EXEMPTION FOR BINARY LIBRARY-LIKE PACKAGES
------------------------------------------
The second condition in the BSD licence (covering binary redistributions) does
not apply all the way down a chain of software. If binary package A includes
PCRE2, it must respect the condition, but if package B is software that
includes package A, the condition is not imposed on package B unless it uses
PCRE2 independently.

197
README.md
View File

@@ -8,21 +8,45 @@ Patterns, replacement templates, test text and project files stay in the
browser. There is no account, backend, upload, remote execution, telemetry, browser. There is no account, backend, upload, remote execution, telemetry,
runtime CDN, remote font, or cloud-save service. runtime CDN, remote font, or cloud-save service.
## Current milestone ## Current release — 0.2.0
Version 0.1.0 is the first production flavour slice: ECMAScript. The v0.2.0 release contains two real production engine slices: ECMAScript and
PCRE2 10.47.
- Syntax acceptance: `@eslint-community/regexpp` 4.12.2, ECMAScript 2025 - Syntax acceptance: `@eslint-community/regexpp` 4.12.2, ECMAScript 2025
grammar; application-owned structural explanations are partial and grammar; application-owned structural explanations are partial and
feature-matrix tested. feature-matrix tested.
- Execution engine: the current browser's native ECMAScript `RegExp`. - Execution engine: the current browser's native ECMAScript `RegExp`.
- Native and editor offsets: UTF-16 code units. - Native and editor offsets: UTF-16 code units.
- PCRE2 execution and substitution: pinned official PCRE2 10.47 compiled to a
self-hosted 8-bit WebAssembly pack with UTF/UCP always enabled.
- PCRE2 native UTF-8 byte ranges are retained and normalized to exact editor
UTF-16 ranges; inputs with lone surrogates are rejected instead of rewritten.
- PCRE2 match-step, depth and heap limits are configurable inside hard caps.
- PCRE2 automatic callouts are copied through a separate ABI operation and
displayed from a dedicated killable trace worker under 50,000-event and
10 MiB serialized-data hard caps.
- Parsing and execution run in separate workers. - Parsing and execution run in separate workers.
- Actual execution can be terminated by killing its worker. - Actual execution can be terminated by killing its worker.
- The worker is recreated after timeout, crash or cancellation. - The worker is recreated after timeout, crash or cancellation.
- Capture ranges use the engine's `d` indices result. - Capture ranges use the engine's `d` indices result.
- Named, numbered, optional, empty and repeated-final captures are represented. - Named, numbered, optional, empty and repeated-final captures are represented.
- Match, replacement, typed list/extraction and unit-test modes are available. - Match, replacement, typed list/extraction, corpus/apply and unit-test modes
are available.
- ECMAScript-specific advisory static risk findings, bounded dynamic growth
probes and cold/warm benchmarks run locally; timing and growth execution is
isolated in a separately killable worker.
- ECMAScript and PCRE2 can be compared through two exact, independently timed
requests without translating patterns, flags or replacements. Incomplete or
rejected sides are explicitly not comparable.
- The reviewed PCRE2 C17 generator emits exact UTF-8 byte arrays, configured
limits and error handling for the PCRE2 10.47 8-bit API.
- A cancellable, deterministic subject minimizer verifies saved-test failures,
capture-range failures, exact timeouts and ECMAScript/PCRE2 mismatches against
the selected real workers under explicit evaluation and wall budgets.
- Deterministic ECMAScript test-case generation samples the normalized AST from
an explicit seed, reports structural coverage and unsupported constructs, and
labels cases only after verification by the selected real engine worker.
- Project JSON import/export and optional IndexedDB save are available. - Project JSON import/export and optional IndexedDB save are available.
- Interactive test text is not persisted by default. - Interactive test text is not persisted by default.
@@ -40,39 +64,67 @@ hierarchy uses syntactic capture nesting while preserving actual engine ranges.
ECMAScript exposes only the final retained capture of a repeated group; Regex ECMAScript exposes only the final retained capture of a repeated group; Regex
Tools says so and never invents capture history. Tools says so and never invents capture history.
Browser ECMAScript engines do not expose their internal backtracking trace. Browser ECMAScript exposes no actual engine trace. PCRE2 has a separate bounded
Version 0.1.0 therefore presents no ECMAScript execution trace and no automatic-callout viewer. Its event fields are reported by PCRE2, while
educational trace disguised as one. “forward”, “same position” and “apparent backtrack” are visibly derived only
from adjacent reported subject positions. The callout stream is not described
as a complete record of every internal engine action.
## Workbench modes ## Workbench modes
- **Match** — live highlighting, extraction tree and bounded capture table. - **Match** — live highlighting, extraction tree and bounded capture table.
- **Replace** — native ECMAScript `RegExp` matches with bounded, - **Replace** — engine-native bounded substitution for ECMAScript and PCRE2;
specification-compatible string substitution; output completeness is output completeness is reported separately from bounded per-match
reported separately from bounded per-match original/result, changed-range and original/result, changed-range and token-contribution previews that
token-contribution previews that synchronize the replacement, pattern, synchronize the replacement, pattern, subject and output editors.
subject and output editors.
- **List** — non-executable typed templates with text, CSV, JSON and NDJSON - **List** — non-executable typed templates with text, CSV, JSON and NDJSON
export; export is disabled when engine collection or bounded rendering is export; export is disabled when engine collection or bounded rendering is
incomplete. incomplete.
- **Corpus / Apply** — bounded multi-file UTF-8 and pasted-text input processed
sequentially in killable workers. Whole-document and independent-line
semantics are explicit; line apply preserves original separators. Progress,
cancellation, bounded capture samples, replacement previews, per-document
exact/partial summaries, content-free JSON/CSV/NDJSON reports and
privacy-gated exact-output ZIP/downloads are available. Original files are
never modified.
- **Unit tests** — editable and cloneable versioned presence, count, full-match, - **Unit tests** — editable and cloneable versioned presence, count, full-match,
capture, replacement, timing and expected-timeout assertions. Capture a capture, replacement, timing and expected-timeout assertions. Capture a
complete current result, select only cases to rerun, filter failures, and complete current result, select only cases to rerun, filter failures, and
import/export a standalone validated JSON suite; execution stays in isolated import/export a standalone validated JSON suite; execution stays in isolated
workers. workers.
Corpus, comparison, debugging, analysis, generation, minimization, formatting The auxiliary **PCRE trace**, ECMAScript **Analysis**, **Compare & code**,
and code generation remain roadmap items. Controls for them are not exposed in **Generate cases**, **Minimize** and **Format** workspaces are separate from
this release. normal matching, replacement, tests and corpus execution. Trace collection is
never included in benchmark measurements.
Comparison retains native ranges while aligning actual results by normalized
editor UTF-16 ranges. Agreement is described only as evidence for the current
subject, never as proof of pattern equivalence.
Case generation is bounded sampling, not a proof of language coverage. Version
1 supports only a complete accepted ECMAScript normalized AST; the partial
PCRE2 provider is reported as unavailable rather than passed through or
relabeled. See
[`docs/GENERATED_CASES.md`](docs/GENERATED_CASES.md).
Minimization verifies every accepted candidate against the exact selected
worker request and claims only local minimality under its documented
Unicode-scalar transforms; see
[`docs/MINIMIZATION.md`](docs/MINIMIZATION.md).
Formatting is currently ECMAScript-only and changes only parser-reported
literal slash/control representations. Apply requires independent reparse,
actual-engine current-subject/replacement comparison, every applicable exact
unit test and explicit confirmation; see
[`docs/FORMATTING.md`](docs/FORMATTING.md).
## Flags and iteration ## Flags and iteration
Pattern and flags are stored separately; delimiters are presentation only. Pattern and flags are stored separately; delimiters are presentation only.
Native `g` and `y` behavior is preserved. The optional **Scan all** action is ECMAScript preserves native `g` and `y`; PCRE2 offers the application-level
explicit. When neither `g` nor `y` is present it adds `g` only to that execution `g` iteration flag plus `i`, `m`, `s`, `x`, `U` and `J`. PCRE2 UTF/UCP mode is
request, visibly records the internal flag, and does not change the saved user mandatory because browser strings are Unicode and partial-byte ranges cannot be
flags. The `d` flag may likewise be added internally to obtain exact indices represented safely in the editor. The optional **Scan all** action adds only an
without changing matching semantics. internal iteration flag to that request and does not change saved flags.
ECMAScript may add internal `d` to obtain exact indices without changing match
semantics.
Zero-length global iteration advances using the selected Unicode semantics and Zero-length global iteration advances using the selected Unicode semantics and
cannot loop forever. cannot loop forever.
@@ -82,7 +134,7 @@ cannot loop forever.
Important defaults: Important defaults:
| Limit | Default | | Limit | Default |
| ------------------------------ | -----------------: | | ------------------------------- | -----------------: |
| Live parse debounce | 120 ms | | Live parse debounce | 120 ms |
| Live execution debounce | 220 ms | | Live execution debounce | 220 ms |
| Live execution timeout | 250 ms | | Live execution timeout | 250 ms |
@@ -90,6 +142,16 @@ Important defaults:
| Advanced maximum timeout | 10 s | | Advanced maximum timeout | 10 s |
| Pattern hard limit | 64 Ki UTF-16 units | | Pattern hard limit | 64 Ki UTF-16 units |
| Interactive subject hard limit | 16 MiB | | Interactive subject hard limit | 16 MiB |
| Corpus documents | 256 |
| Per corpus document | 16 MiB |
| Aggregate corpus input | 256 MiB |
| Independent corpus lines | 100,000 |
| Aggregate corpus matches | 100,000 |
| Capture summaries per document | 32 |
| Samples per capture | 3 |
| Retained applied output | 64 MiB |
| Applied ZIP | 68 MiB |
| Corpus batch wall time | 5 min |
| Replacement template | 64 Ki UTF-16 units | | Replacement template | 64 Ki UTF-16 units |
| List template | 16 Ki UTF-16 units | | List template | 16 Ki UTF-16 units |
| Per-match replacement preview | 200 matches | | Per-match replacement preview | 200 matches |
@@ -104,6 +166,30 @@ Important defaults:
| Tests per suite | 1,000 | | Tests per suite | 1,000 |
| Retained message per test | 2 KiB UTF-8 | | Retained message per test | 2 KiB UTF-8 |
| Unit-test suite wall time | 60 s | | Unit-test suite wall time | 60 s |
| PCRE2 trace events | 50,000 |
| PCRE2 serialized trace | 10 MiB |
| Comparison retained differences | 5,000 |
| Comparison retained alignments | 2,000 |
| Static analysis traversal | 20,000 nodes |
| Static analysis findings | 100 |
| Growth probe steps | 24 |
| Analysis aggregate wall time | 30 s |
| Generated verified cases | 48 |
| Generated candidate attempts | 192 |
| Generation aggregate wall time | 15 s |
| Generation aggregate candidates | 1 MiB |
| Minimizer subject | 1 MiB UTF-8 |
| Minimizer candidate evaluations | 2,000 |
| Minimizer aggregate wall time | 60 s |
| Formatter validation tests | 1,000 |
| Formatter test wall time | 60 s |
| Retained minimizer history | 200 |
Analysis defaults to three warm-up plus 15 measured samples (caps: 100 and
1,000), with a 2-second per-sample worker limit that can be raised to 10
seconds. Growth generation defaults to 1 MiB, preflights the central 16 MiB
subject cap and rejects more than 10,000,000 repetitions. Replacement timing
is skipped above an 8 Mi UTF-16-unit output estimate.
A timeout terminates actual execution. It is reported as **timed out**, never as A timeout terminates actual execution. It is reported as **timed out**, never as
“no match”. The next request creates a fresh worker. “no match”. The next request creates a fresh worker.
@@ -128,8 +214,12 @@ require separate explicit opt-ins for JSON export and IndexedDB save.
Standalone test-suite JSON likewise omits subjects by default, is schema- and Standalone test-suite JSON likewise omits subjects by default, is schema- and
field-validated on import, and appends at most 1,000 total tests. Add, update, field-validated on import, and appends at most 1,000 total tests. Add, update,
clone and append-import also preflight the complete in-memory suite against a clone and append-import also preflight the complete in-memory suite against a
32 MiB aggregate bound. Corpus content is not supported or persisted in this 32 MiB aggregate bound. Added generated tests retain their validated generator
release. Import, export and IndexedDB save enforce one 32 MiB aggregate UTF-8 version, seed, candidate ID and intended outcome; editing one removes that
provenance. Schema v1 can remember that the corpus workspace was selected, but
corpus documents, contents, outputs and results are always ephemeral and
excluded from JSON and IndexedDB. Imports that try to inject those fields are
rejected. Import, export and IndexedDB save enforce one 32 MiB aggregate UTF-8
JSON-document limit in addition to per-field limits. The latest local save is JSON-document limit in addition to per-field limits. The latest local save is
read through an `updatedAt` index cursor; loading it does not materialize the read through an `updatedAt` index cursor; loading it does not materialize the
complete project store. complete project store.
@@ -154,6 +244,9 @@ npm run test:conformance
npm run test:browser npm run test:browser
npm run engines:build npm run engines:build
npm run engines:verify npm run engines:verify
npm run engines:pcre2:build -- --source-dir /path/to/pcre2 --emcc /path/to/emcc
npm run engines:pcre2:verify
npm run engines:pcre2:install
npm run build npm run build
npm run toolbox:check npm run toolbox:check
npm run check npm run check
@@ -173,17 +266,34 @@ The deterministic release ZIP can be consumed by Toolbox Portal using an exact
artifact URL, manifest ID, version and SHA-256. Portal builds consume the artifact URL, manifest ID, version and SHA-256. Portal builds consume the
artifact; they do not build Regex Tools source. artifact; they do not build Regex Tools source.
Version 0.1.0 has no WebAssembly. Its minimum CSP needs self-hosted scripts and The PCRE2 worker loads self-hosted JavaScript and WebAssembly only. Deployment
workers, not `wasm-unsafe-eval`. See must serve `.wasm` as `application/wasm` and allow WebAssembly compilation in
[`docs/PORTAL_REQUIREMENTS.md`](docs/PORTAL_REQUIREMENTS.md). `script-src`; see [`docs/PORTAL_REQUIREMENTS.md`](docs/PORTAL_REQUIREMENTS.md).
## Flavour roadmap ## Flavour roadmap
The next flavour is official PCRE2 10.47 WebAssembly. A technical spike from the PCRE2 10.47 is the second implemented flavour. Its application-owned ABI
signed official tag has already demonstrated Unicode matching, named captures, provides bounded compilation, matching, native substitution and separate
substitution, automatic and explicit callouts, and match/depth/heap limits. automatic-callout tracing, copied caller-owned records, deterministic
PCRE2 is not shipped until the application-owned bridge, byte/UTF-16 offsets, allocation cleanup and explicit result completeness. The exact
trace caps, browser tests and source/licence material meet the acceptance gate. source/toolchain build is reproducible offline and the verified pack is bundled
with its metadata, checksums and licence.
Generated-case version 1 is ECMAScript-only. It traverses the complete
application-owned ECMAScript AST under node, depth, attempt, byte, repetition
and wall-time caps, then verifies every retained label through the actual
browser `RegExp` adapter. PCRE2 generation remains unavailable until its syntax
provider can expose a complete normalized tree.
Pattern formatting is likewise ECMAScript-only. It consumes the complete
regexpp token model and does not reinterpret the partial PCRE2 structure.
PCRE2 formatting remains unavailable until a complete grammar-backed
implementation passes equivalent idempotence, reparse, engine and test gates.
The first advertised generated-code target is the PCRE2 10.47 8-bit C API.
Its deterministic match and replacement fixtures compile and execute against
the exact official release. See
[`docs/COMPARISON_AND_CODEGEN.md`](docs/COMPARISON_AND_CODEGEN.md).
Python, Go, Rust, .NET, Java and legacy PCRE follow only through their actual Python, Go, Rust, .NET, Java and legacy PCRE follow only through their actual
named runtimes. No flavour is emulated through JavaScript and relabelled. named runtimes. No flavour is emulated through JavaScript and relabelled.
@@ -198,7 +308,7 @@ notices; see [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) and
No regex101 application source, branding, generated explanation prose, No regex101 application source, branding, generated explanation prose,
reference text or visual design is copied. RegexLib and RGXP.RU fixtures are not reference text or visual design is copied. RegexLib and RGXP.RU fixtures are not
redistributed because a suitable fixture licence was not established. No redistributed because a suitable fixture licence was not established. No
RegexHub fixture is shipped in v0.1.0. RegexHub fixture is shipped in v0.2.0.
## Known limitations ## Known limitations
@@ -211,7 +321,17 @@ RegexHub fixture is shipped in v0.1.0.
error node, not a fabricated multi-character span or partial provider AST. error node, not a fabricated multi-character span or partial provider AST.
- Native JavaScript compile errors do not consistently expose a machine-readable - Native JavaScript compile errors do not consistently expose a machine-readable
source offset; the provider diagnostic supplies the range. source offset; the provider diagnostic supplies the range.
- Capture history and actual ECMAScript execution tracing are unavailable. - Capture history is unavailable. Browser ECMAScript exposes no engine trace;
PCRE2 exposes bounded automatic callouts, not a complete internal trace.
Movement classifications are derived and labelled as such.
- Static risk heuristics and generated-input observations apply only to
ECMAScript 2025 and are advisory. They are never proof of safety or of a
patterns general time complexity; analysis settings and results are
ephemeral.
- PCRE2 structural explanations are deliberately partial. The application
recognizes common capture forms and PCRE2-only constructs for reference, but
the actual PCRE2 compiler is authoritative. Branch-reset capture numbering is
omitted from the explanation rather than guessed.
- Capture-table rendering is paged at 200 rows while aggregate collection is - Capture-table rendering is paged at 200 rows while aggregate collection is
bounded separately. bounded separately.
- Explanation/extraction trees and editor decorations show bounded 2,000-node - Explanation/extraction trees and editor decorations show bounded 2,000-node
@@ -235,8 +355,17 @@ RegexHub fixture is shipped in v0.1.0.
- Unit-test failure messages retain at most 2 KiB UTF-8 per case, so 1,000 - Unit-test failure messages retain at most 2 KiB UTF-8 per case, so 1,000
results stay below the 2 MiB log budget. Oversized exact replacement results results stay below the 2 MiB log budget. Oversized exact replacement results
offer a lightweight `should-match` draft when one is valid and fits the suite. offer a lightweight `should-match` draft when one is valid and fits the suite.
- Benchmark, risk analysis, generated cases, minimization and corpus processing - Corpus input accepts recognized UTF-8 text only. It does not auto-detect
are not in this release. legacy encodings or recursively traverse directories.
- Corpus capture extraction is a bounded per-document summary: full match plus
at most 31 capture groups, three 160-unit samples each. Content-free reports
include counts and clipping metadata, never the samples themselves.
- Generated cases deliberately sample only ECMAScript and report
backreferences, lookaround, boundaries, complex Unicode sets and other
unsupported constraints instead of claiming exhaustive coverage.
- Pattern formatting is intentionally narrow and ECMAScript-only. Its bounded
source/candidate and unit-test validation is evidence for the checked
snapshots, not a proof of equivalence over all subjects.
Architecture, security, performance, provenance and flavour details live in Architecture, security, performance, provenance and flavour details live in
[`docs/`](docs/). [`docs/`](docs/).

View File

@@ -1,9 +1,10 @@
# Source identity # Source identity
- Project: Regex Tools - Project: Regex Tools
- Version: 0.1.0 - Release version: `0.2.0`
- Release tag: `v0.2.0`
- Repository: <https://git.add-ideas.de/zemion/regex-tools> - Repository: <https://git.add-ideas.de/zemion/regex-tools>
- Release tag: `v0.1.0` - Previous release tag: `v0.1.0`
- Licence: GPL-3.0-or-later - Licence: GPL-3.0-or-later
- Copyright: © 2026 Albrecht Degering - Copyright: © 2026 Albrecht Degering
@@ -19,5 +20,14 @@ runtime or build-time CDN fetch. Complete preferred-form source is the tagged
repository. The release archive contains compiled static assets plus legal, repository. The release archive contains compiled static assets plus legal,
source-identity and attribution documents. source-identity and attribution documents.
No PCRE2, Python, Go, Rust, .NET, Java or legacy-PCRE runtime is included in The explicit offline PCRE2 build is documented in `docs/ENGINE_BUILDS.md`.
version 0.1.0. PCRE2 source is not vendored: the gate accepts only the pinned, clean official
10.47 checkout and Emscripten 6.0.4 compiler. Generated staging is ignored; the
verified runtime files, deterministic metadata, checksums and exact licence are
installed under `public/engines/pcre2/` and included in the application source
and build.
The v0.2.0 release includes PCRE2. Python, Go, Rust, .NET, Java and legacy PCRE
remain unimplemented. The v0.1.0 release remains available under its historical
tag and artifact coordinates. A future public artifact must bump package,
manifest, tag and source identity together; it must not reuse `v0.2.0`.

View File

@@ -1,18 +1,20 @@
# Third-party notices # Third-party notices
Regex Tools original source is GPL-3.0-or-later. The production browser bundle Regex Tools original source is GPL-3.0-or-later. The production browser bundle
also contains the compatible components below. Exact dependency resolution is contains the compatible runtime components below.
recorded in `package-lock.json`. Exact dependency resolution is recorded in `package-lock.json`.
| Component | Version | Licence | Shipped | Role and source | | Component | Version | Licence | Shipped | Role and source |
| -------------------------------- | ------------------------------------------------------------------------ | ---------- | ----------------- | ------------------------------------------------------------------------ | | -------------------------------- | ------------------------------------------------------------------------ | --------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------- |
| `@add-ideas/toolbox-contract` | 0.2.2 | Apache-2.0 | Runtime | Manifest contract; <https://git.add-ideas.de/zemion/toolbox-sdk> | | `@add-ideas/toolbox-contract` | 0.2.2 | Apache-2.0 | Runtime | Manifest contract; <https://git.add-ideas.de/zemion/toolbox-sdk> |
| `@add-ideas/toolbox-shell-react` | 0.2.2 | Apache-2.0 | Runtime | Shared shell; same source | | `@add-ideas/toolbox-shell-react` | 0.2.2 | Apache-2.0 | Runtime | Shared shell; same source |
| `@eslint-community/regexpp` | 4.12.2 | MIT | Syntax worker | ECMAScript parser; <https://github.com/eslint-community/regexpp> | | `@eslint-community/regexpp` | 4.12.2 | MIT | Syntax worker | ECMAScript parser; <https://github.com/eslint-community/regexpp> |
| CodeMirror packages | state 6.7.1; view 6.43.6; language 6.12.4; commands 6.10.4; search 6.7.1 | MIT | Runtime | Editors; <https://github.com/codemirror> | | CodeMirror packages | state 6.7.1; view 6.43.6; language 6.12.4; commands 6.10.4; search 6.7.1 | MIT | Runtime | Editors; <https://github.com/codemirror> |
| `@lezer/highlight` | 1.2.3 | MIT | Runtime | Editor highlighting support; <https://github.com/lezer-parser/highlight> | | `@lezer/highlight` | 1.2.3 | MIT | Runtime | Editor highlighting support; <https://github.com/lezer-parser/highlight> |
| React / React DOM | 19.2.7 | MIT | Runtime | User interface; <https://github.com/facebook/react> | | React / React DOM | 19.2.7 | MIT | Runtime | User interface; <https://github.com/facebook/react> |
| `fflate` | 0.8.3 | MIT | Packaging utility | Deterministic release ZIP; <https://github.com/101arrowz/fflate> | | `fflate` | 0.8.3 | MIT | Runtime and build dependency | Local corpus-output ZIP and deterministic release ZIP; <https://github.com/101arrowz/fflate> |
| Emscripten generated runtime | 6.0.4 | MIT | Generated WebAssembly glue | PCRE2 module loader/runtime; <https://github.com/emscripten-core/emscripten> |
| PCRE2 | 10.47 | BSD-3-Clause WITH PCRE2-exception | WebAssembly runtime | Pinned official 8-bit engine; <https://github.com/PCRE2Project/pcre2> |
| Vite | 8.1.5 | MIT | Generated helpers | Production build; <https://github.com/vitejs/vite> | | Vite | 8.1.5 | MIT | Generated helpers | Production build; <https://github.com/vitejs/vite> |
| Rolldown | 1.1.5 | MIT | Generated helpers | Production bundling; <https://github.com/rolldown/rolldown> | | Rolldown | 1.1.5 | MIT | Generated helpers | Production bundling; <https://github.com/rolldown/rolldown> |
@@ -20,8 +22,8 @@ recorded in `package-lock.json`.
development-only and are not part of the static runtime bundle. development-only and are not part of the static runtime bundle.
`recheck` 4.5.0 and `regexp-ast-analysis` 0.7.1 were inspected but deliberately `recheck` 4.5.0 and `regexp-ast-analysis` 0.7.1 were inspected but deliberately
not installed or shipped. PCRE2 10.47 was used only in an external feasibility not installed or shipped. PCRE2 source is not vendored; its generated
spike and is not present in the v0.1.0 artifact. WebAssembly pack is shipped with exact metadata, checksums and licence.
Corresponding licence texts and copyright notices are in `LICENSES/`. The Corresponding licence texts and copyright notices are in `LICENSES/`. The
release package additionally carries the exact Vite and Rolldown legal files release package additionally carries the exact Vite and Rolldown legal files

121
docs/ANALYSIS.md Normal file
View File

@@ -0,0 +1,121 @@
# Performance and risk analysis
Regex Tools provides one deliberately scoped analysis implementation:
ECMAScript 2025 patterns parsed into the application-owned normalized AST and
executed by the browser's native `RegExp` runtime. The analyser does not apply
ECMAScript findings to PCRE2 or any other flavour.
Analysis is advisory. The UI uses “potential risk”, “possibly” and “observed
under selected limits”. It never turns an empty finding list into “safe”,
“guaranteed linear” or a vulnerability proof.
## Static analysis
The application-owned `regex-tools-ecmascript-heuristics` analyser walks at
most 20,000 normalized nodes and returns at most 100 findings. Each finding
contains:
- flavour and UTF-16 source range;
- stable rule identifier;
- static or dynamically observed provenance;
- explanation and example risk;
- low, medium or high confidence;
- limitations;
- a suggested investigation.
The current rules review nested and nullable repetition, ambiguous repeated
alternatives, overlapping alternative prefixes, wildcards and backreferences
inside repetition, repeated lookaround bodies, unanchored amplification,
capture count, nesting depth and replacement-output expansion. These are
structural heuristics. They do not model the native engine's complete compiled
program, optimizations or calling context.
## Dynamic growth probes
The user defines a bounded subject family:
```text
prefix + repeatedFragment.repeat(repetitions) + suffix
```
The byte and UTF-16 size are calculated before the generated string is
allocated. The repeated fragment is limited to 4,096 UTF-16 units, each affix
to 16,384 units, the run to 24 samples, and repetitions to 10,000,000. The UI
defaults to a 1 MiB generated-subject cap even though the central interactive
hard cap remains 16 MiB.
Every probe executes in a dedicated worker. The supervisor terminates and
discards that worker on per-sample timeout, cancellation or crash. Runs also
stop at:
- the selected repetition bound;
- the selected generated-byte bound;
- the selected step bound;
- the 30-second aggregate wall bound;
- a selected normalized-growth threshold.
The growth threshold compares the time ratio with the input-size ratio for two
adjacent samples. Sub-millisecond measurements below a noise floor do not
trigger it. A threshold observation characterizes only those generated inputs
and that browser. Timeout, crash, compile rejection and non-match remain
distinct states.
The UI renders time and outcome in separate plots and keeps the exact sample
table alongside them.
## Benchmark method
A benchmark begins with a fresh analysis worker. Worker creation and its
identity response form the separate cold-start measurement. The first native
engine use is retained as a cold sample. Configured warm-up samples are
discarded; configured measured samples produce the warm statistics.
Each sample measures these phases separately:
- `RegExp` construction;
- first `exec`;
- bounded all-match iteration;
- native string replacement when its conservative output bound is acceptable;
- all-match throughput and match count.
All-match iteration retains only counts, not values or captures. It follows the
same explicit scan-all, global, sticky and Unicode zero-length advancement
semantics as the workbench. The application-added indices flag remains in the
effective flags so the measured operation reflects interactive execution.
Replacement timing is skipped when match collection reaches its bound or a
conservative estimate can exceed 8 Mi UTF-16 units. This prevents a benchmark
from using an unbounded native replacement merely to obtain a timing.
It is also skipped for explicit scan-all with sticky `y`: native
`String.replace` processes only one sticky match, while the workbench's
explicit scan-all operation can process a contiguous sequence. Reporting that
different operation as the same benchmark would be misleading.
Warm metrics include sample count, minimum, conventional median, nearest-rank
p95 and maximum. Milliseconds are shown to two decimal places, with values
below 0.01 ms labelled accordingly; this does not imply nanosecond accuracy.
The result retains subject size, match count, effective flags, browser runtime
identity and engine identity.
Defaults:
| Setting | Default | Hard bound |
| ----------------------- | ------: | ---------: |
| Warm-up samples | 3 | 100 |
| Measured samples | 15 | 1,000 |
| Per-sample timeout | 2 s | 10 s |
| Aggregate wall time | 30 s | 30 s |
| Matches per sample | 10,000 | 10,000 |
| Replacement output gate | 8 MiU | 8 MiU |
One subject is not a complete performance characterization. Results from
different algorithms, pattern ports, browser engines, WebAssembly runtimes or
machines are not directly comparable. Trace-enabled execution is never used
for benchmark results.
## Privacy and persistence
Patterns, subjects, generated inputs, findings and benchmark samples remain in
the browser. No analysis request is uploaded. Analysis settings and results are
currently ephemeral and are not added to project JSON or IndexedDB; this also
keeps large benchmark subjects out of persisted projects by default.

View File

@@ -5,9 +5,32 @@ boundaries.
```text ```text
React workbench React workbench
├─ Pattern SyntaxSupervisor → syntax.worker → Regexpp provider → normalized AST ├─ Pattern SyntaxSupervisor → syntax.worker
│ ├─ Regexpp ECMAScript provider → normalized AST
│ └─ PCRE2 lexical provider → partial normalized structure
├─ Replacement SyntaxSupervisor → syntax.worker → typed replacement tokens ├─ Replacement SyntaxSupervisor → syntax.worker → typed replacement tokens
├─ EngineSupervisor → ecmascript.worker → native RegExp → match DTOs ├─ EngineSupervisor
│ ├─ ecmascript.worker → native RegExp → match DTOs
│ └─ pcre2.worker → PCRE2 10.47 WASM ABI → match DTOs
├─ Pcre2TraceSupervisor → pcre2-trace.worker
│ └─ separate ABI-v3 PCRE2_AUTO_CALLOUT operation → bounded trace DTOs
├─ AnalysisPanel
│ ├─ normalized-AST ECMAScript static heuristics
│ └─ AnalysisSupervisor → analysis.worker → native RegExp benchmark/growth probes
├─ ComparisonOrchestrator
│ ├─ two exact syntax snapshots + independently killable engine workers
│ └─ normalized UTF-16 match/capture alignment + explicit incomplete states
├─ PCRE2 C17 generator → validated snapshot → exact UTF-8 byte-array source
├─ SubjectMinimizer
│ ├─ fixed syntax snapshots + exact real-engine failure or comparison oracle
│ └─ deterministic Unicode-scalar reducer → bounded local-minimal result
├─ CaseGenerationOrchestrator
│ ├─ generation.worker → deterministic bounded ECMAScript AST candidates
│ └─ EngineSupervisor → selected actual-engine label verification
├─ ECMAScript literal/control formatter
│ ├─ exact regexpp literal-token ranges → idempotent candidate preview
│ └─ PatternFormatValidator → paired syntax + actual-engine workers and exact tests
├─ Corpus EngineSupervisor → sequential bounded document jobs → summaries/outputs
├─ Test SyntaxSupervisor + EngineSupervisor → isolated cancellable test runs ├─ Test SyntaxSupervisor + EngineSupervisor → isolated cancellable test runs
└─ project validator / serializer / IndexedDB persistence └─ project validator / serializer / IndexedDB persistence
``` ```
@@ -21,6 +44,59 @@ request ID and worker generation. A supervisor allows one active request,
rejects stale responses, terminates on timeout/crash/cancel, and lazily creates rejects stale responses, terminates on timeout/crash/cancel, and lazily creates
a new generation. Timeout is a distinct error state. a new generation. Timeout is a distinct error state.
PCRE2 tracing is not an `EngineSupervisor` match or replacement operation. Its
own worker compiles with `PCRE2_AUTO_CALLOUT`, copies only complete fixed
records and bounded mark bytes, and stops natively at either trace cap.
Reported callout fields retain byte positions; movement classifications are a
separate derived UI layer. Normal execution, corpus, tests and benchmarks never
run through the trace operation.
Static analysis traverses the normalized ECMAScript tree on the UI side under
node/finding caps. Timing and generated-input growth probes use the independent
analysis worker. Timeout, cancellation or crash discards that worker; no trace
mode is benchmarked.
Comparison is a separate orchestration layer, not an `EngineSupervisor`
operation. It runs explicit ECMAScript and PCRE2 syntax/execution snapshots,
retains each engine identity and native range model, and aligns only normalized
editor ranges. Timeout, cancellation, compile rejection and truncation remain
distinct non-comparable outcomes. Shared patterns are sent unchanged;
per-flavour ports are user-owned variants.
The PCRE2 C generator is application-owned and never implements browser
execution. It validates one exact PCRE2 snapshot, emits user strings as
independent UTF-8 byte arrays, and states the host-level mappings that the
PCRE2 API cannot express directly. Its advertised C17 target is protected by
deterministic golden identities and an exact PCRE2 10.47 compile/execute gate.
Subject minimization is a main-thread bounded reducer whose expensive predicate
checks stay in independently terminable engine workers. Fixed pattern syntax
is parsed once for capture metadata. The baseline and every accepted candidate
retain the exact target and engine identity. Timeout, crash, cancellation,
worker failure and incomplete output are separate outcomes. Deterministic
ddmin deletion is followed by a fixed-point local sweep over Unicode-scalar
deletion and lower-rank canonical replacement; only completion of that sweep
permits a transform-local minimality claim.
Generated-case synthesis and verification are separate trust boundaries. The
dedicated generation worker receives only a validated, complete ECMAScript
normalized tree plus explicit settings and seed. The main-thread orchestrator
then sends each bounded candidate to `EngineSupervisor`; only the selected real
adapter can establish a `should-match` or `should-not-match` label. Worker
timeout, crash, cancellation, compile rejection, wrong outcome and ordinary
failure stay distinct. Settings/results are ephemeral, while explicitly added
unit tests retain field-validated generator provenance.
Formatting is not an engine operation and never rewrites from heuristic text
alone. The ECMAScript formatter consumes an exact accepted regexpp snapshot and
changes only parser-reported literal/control token ranges. Its validator
reparses source and candidate separately, preserves capture shape, runs both in
separate actual-engine workers against the current replacement snapshot, then
replays every enabled test tied to the exact active pattern/version/flags/options.
Any difference, incomplete output, one-sided timeout, identity change or
inconclusive worker outcome blocks apply. The preview and observations are
ephemeral, and user confirmation is required even after all bounded gates pass.
Pattern and replacement syntax use independent supervisors. A pattern snapshot Pattern and replacement syntax use independent supervisors. A pattern snapshot
is bound to its exact pattern, ordered flags and revision; execution cannot use is bound to its exact pattern, ordered flags and revision; execution cannot use
acceptance or capture metadata from an earlier input. Replacement output is acceptance or capture metadata from an earlier input. Replacement output is
@@ -32,10 +108,27 @@ over those actual matches, the complete replacement-token model and the bounded
output. They execute no replacement callback or user code and have separate output. They execute no replacement callback or user code and have separate
presentation and token-evaluation limits. presentation and token-evaluation limits.
Native ranges are retained in the engine's declared unit. Browser editor ranges Corpus documents are ephemeral React session state, not project state. A
dedicated engine supervisor processes one document at a time and is terminated
on cancellation, timeout or workspace exit. Batch orchestration retains only
per-document summaries and, for apply runs, bounded outputs. It enforces
document-count, per-document byte, aggregate input byte, aggregate match,
aggregate output and batch wall-time limits. Exact-output export is unavailable
if any document is partial, failed, cancelled or not run.
Whole-document mode sends each document to the engine once. Independent-line
mode segments CRLF, CR, LF, U+2028 and U+2029 without discarding them, executes
each logical line as its own subject, and reattaches the exact original
separator during apply. This makes anchor and cross-line behavior deliberately
different rather than silently rewriting the pattern. Match DTOs are reduced to
bounded per-group participation counts and samples before the next document.
Native ranges are retained in the engine's declared unit. PCRE2 always uses
UTF/UCP and exposes UTF-8 byte ranges; its ABI copies scalar records and names
out of WebAssembly before returning and retains no code pointer. Browser editor ranges
are half-open UTF-16 code-unit ranges. Reusable converters cover UTF-8 byte and are half-open UTF-16 code-unit ranges. Reusable converters cover UTF-8 byte and
Unicode code-point offsets for later flavours, including invalid-boundary Unicode code-point offsets, including invalid-boundary rejection and
rejection and lone-surrogate detection. lone-surrogate detection.
The release boundary is `dist/` plus checked-in legal/source documents. The The release boundary is `dist/` plus checked-in legal/source documents. The
Portal consumes the resulting immutable ZIP and never imports React source. Portal consumes the resulting immutable ZIP and never imports React source.

View File

@@ -0,0 +1,118 @@
# ECMAScript/PCRE2 comparison and PCRE2 C generation
## Comparison contract
The comparison panel sends two explicit requests: one to the native browser
ECMAScript worker and one to the bundled PCRE2 10.47 worker. It does not
translate patterns, flags, options or replacement templates.
Two pattern models are available:
- **Shared pattern** sends the same string unchanged to both engines.
- **Per-flavour variants** retains two explicit ports supplied by the user.
Flags are selected independently from each flavour's registered allowlist.
PCRE2 match, depth and heap bounds are part of its exact request. Both sides
share the selected subject, scan-all intent, host result bounds and wall-clock
worker timeout.
Each side retains:
- its syntax-provider request, acceptance, diagnostics and coverage;
- the exact execution or replacement request sent to its worker;
- a deterministic display key plus the complete authoritative request;
- completion, timeout, cancellation or worker-failure status;
- engine, runtime and adapter identity when the engine returns one;
- user and effective flags;
- native ranges and normalized editor UTF-16 ranges;
- compile acceptance, matches, captures and replacement output.
The orchestrator owns two syntax supervisors and the multi-engine execution
supervisor. The two engine calls run concurrently through independently
killable flavour workers. A side timeout kills only that worker. The shared
Cancel action terminates every active comparison worker. Comparison remains
outside `EngineSupervisor`, whose responsibility is one engine operation and
lifecycle boundary.
Match pairs are aligned by equal normalized UTF-16 ranges first. Unpaired
matches use an explicitly labelled ordinal fallback. Captures are aligned by
normalized range, then capture identity, then an explicitly labelled ordinal
fallback. Original native offsets remain attached to both results.
No equivalence claim is made when syntax or compilation is rejected, a worker
times out/fails/is cancelled, results or values are truncated, or replacement
output is incomplete. A completed matching result is labelled only as
agreement for the current subject; it is evidence, not a proof that two
patterns are equivalent. Timeout is never interpreted as “no match.”
The retained DTO is bounded to 5,000 displayed difference records and 2,000
match alignments while still counting all differences in the bounded engine
result. The panel renders at most 250 differences and 100 alignments at once.
## PCRE2 C17 generator
The first reviewed code-generation target is the PCRE2 10.47 8-bit C API.
Other languages remain unavailable until their generators have equivalent
escaping, semantic and toolchain gates.
The generator consumes a validated PCRE2 snapshot and:
- requires the registered PCRE2 10.47 version and flag/option allowlists;
- refuses unpaired UTF-16 surrogates rather than emitting lossy UTF-8;
- emits pattern, subject and replacement as independent explicit UTF-8 byte
arrays, so quotes, backslashes, newlines, NUL, Unicode, dollar signs and
braces cannot escape into C source syntax;
- enables mandatory `PCRE2_UTF` and `PCRE2_UCP`;
- maps `i`, `m`, `s`, `x`, `U` and `J` to named PCRE2 compile constants;
- implements application-level `g`/scan-all behavior explicitly;
- handles the PCRE2 empty-match anchored retry before advancing one UTF-8
character;
- configures native match, depth and heap limits;
- enforces match/capture row caps in the generated match loop;
- bounds replacement output with a host buffer and withholds output when its
post-run result-row bound is exceeded;
- reports compile offsets and native API errors and cleans up all PCRE2 state;
- rejects compilation against a PCRE2 header other than 10.47 and verifies the
runtime version.
The generated program states limitations that cannot be mapped honestly:
- PCRE2 has no `g` compile flag;
- match/capture rows and replacement-output size are host-application bounds,
not PCRE2 matching options;
- replacement result-row limits can be checked only after
`pcre2_substitute()` has completed;
- PCRE2 exposes no native wall-clock timeout, so hard elapsed-time
cancellation belongs to the containing process;
- C API offsets are UTF-8 bytes, not browser editor UTF-16 units.
Generated snippets are examples only and are never used to implement the
browser runtime.
## Golden and toolchain gate
`scripts/pcre2-codegen-golden.test.ts` pins deterministic SHA-256 identities
for match and replacement fixtures:
```text
match 22b91d578a449b0ed5c30e6eadad30f19a2bcb26ac9db355c8ec7a69328675da
replacement ab90a0d9831ea7b7145aa0072ac352b42baf9a795319d81f531b0f341b04a636
```
The compile/execute gate was run against a host static build from official
signed tag `pcre2-10.47`, commit
`f454e231fe5006dd7ff8f4693fd2b8eb94333429`. Both generated fixtures compile
under C17 with `-Wall -Wextra -Werror`; the match fixture verifies native UTF-8
ranges and named captures, and the replacement fixture verifies exact Unicode
output and substitution count.
To repeat the gate with an exact local PCRE2 installation:
```sh
PCRE2_CODEGEN_PREFIX=/path/to/pcre2-10.47-prefix \
npm test -- scripts/pcre2-codegen-golden.test.ts
```
The prefix must provide `include/pcre2.h` and
`lib64/libpcre2-8.a`. A different static-library location can be supplied
through `PCRE2_CODEGEN_LIBRARY`.

View File

@@ -1,28 +1,74 @@
# Engine builds # Engine builds
Version 0.1.0 ships no WebAssembly engine. The production application uses the browser's native `RegExp` for ECMAScript
and bundles a verified PCRE2 10.47 WebAssembly pack. Normal application builds
never download or compile an engine: the reviewed files under
`public/engines/pcre2/` are copied into `dist/` and verified there.
`npm run engines:build` verifies that this release has no external pack to ## Rebuilding PCRE2
build. After the production build, `npm run engines:verify` rejects any
undeclared engine asset and confirms the packaged ECMAScript-only notice.
Neither command accesses the network.
## PCRE2 feasibility record The reproducible build accepts only:
The next flavour was spiked outside the application repository against official - PCRE2 tag `pcre2-10.47`, signed tag object
PCRE2 10.47: `cd007b4466798f66d479d1442a407099e7c40050`, peeled commit
`f454e231fe5006dd7ff8f4693fd2b8eb94333429` and tree
`81a83a3552bd68d0ea7b7004f8bb6e7892f583ba`;
- Emscripten 6.0.4, compiler revision
`fe5be6afdff43ad58860d821fcc8572a23f92d19`, from emsdk commit
`224ec5f9f2f72f09f9ce0e26d66bae7dbd8b692f`;
- CMake 4.3.4 and Ninja 1.13.2;
- 8-bit PCRE2 with Unicode enabled and JIT, threads and filesystem disabled.
- signed tag object `cd007b4466798f66d479d1442a407099e7c40050`; The source and compiler checkouts must already exist locally and be completely
- peeled commit `f454e231fe5006dd7ff8f4693fd2b8eb94333429`; clean. No command clones, downloads or updates them:
- licence `BSD-3-Clause WITH PCRE2-exception`;
- Emscripten 6.0.4;
- 8-bit library, Unicode enabled, JIT disabled.
Node and Chromium tests demonstrated version/config reporting, Unicode, ```sh
named groups, global substitution, automatic and explicit callouts, and npm run engines:pcre2:build -- \
match/depth/heap errors. This was a technical spike only. No spike source, --source-dir /absolute/path/to/pcre2 \
binary or PCRE2 source is included in v0.1.0. --emcc /absolute/path/to/emscripten/emcc
npm run engines:pcre2:verify
npm run engines:pcre2:install
```
Production PCRE2 support remains gated on a reviewed bridge, copied DTOs, Use `--force` only to replace the exact ignored `.engine-build/pcre2` output.
allocation cleanup, exact source build, UTF-8/UTF-16 mapping, trace/output caps, The explicit install command first re-verifies that pack and then atomically
worker recovery, deterministic assets, licences and browser conformance. replaces only `public/engines/pcre2`.
The builder verifies Git objects and critical source/compiler hashes,
sanitizes build flags, sets `SOURCE_DATE_EPOCH=1760997684`, builds serially and
emits exactly:
- `pcre2.mjs` and `pcre2.wasm`;
- deterministic `engine-metadata.json`;
- the exact upstream `LICENSE.txt`;
- `SHA256SUMS`.
The verifier checks the closed file set, metadata, source bridge hashes and all
checksums. It validates and instantiates WebAssembly, checks ABI/engine/config
identity, then executes native Unicode compile, bounded match and bounded
substitution smoke cases plus normal and cap-stopped automatic-callout traces.
The immutable signed-tag object is pinned. OpenPGP trust validation remains a
release-operator step because the offline builder does not install or trust a
key.
## Runtime boundary
ABI version 3 has no retained native handles. Each call compiles, obtains
capture names, matches/substitutes or traces and releases its code, match data
and match context before returning. The caller supplies fixed-capacity
result/name/output/event/mark buffers. Hard maxima are 1 MiB pattern, 16 MiB
subject, 1,000 captures, 10,000 matches, 100,000 capture rows, 50,000 trace
events, 10 MiB serialized trace data and 128 MiB PCRE2 heap limit; the
workbench uses stricter pattern and replacement input limits where applicable.
PCRE2 always runs with UTF and UCP. Native UTF-8 byte offsets are retained in
DTOs and converted only at valid code-point boundaries to half-open editor
UTF-16 ranges. Lone UTF-16 surrogates are rejected before encoding.
Normal execution lives only in `pcre2.worker`. Instrumented
`PCRE2_AUTO_CALLOUT` collection is a separate ABI operation loaded only by
`pcre2-trace.worker`; normal match, replacement, tests, corpus and benchmarks
never call it. Each supervisor terminates its worker on timeout, crash,
cancellation or supersession and lazily creates a clean generation for the
next request.

View File

@@ -1,13 +1,39 @@
# Execution traces # Execution traces
Version 0.1.0 exposes no execution trace. Browser ECMAScript APIs expose compilation, match, capture and replacement
results, but no V8, SpiderMonkey or JavaScriptCore execution trace. Regex Tools
does not relabel structural explanations, static findings or timing
observations as an ECMAScript engine trace.
Browser ECMAScript APIs return compilation, match, capture and replacement PCRE2 10.47 has a separate actual automatic-callout vertical. ABI version 3
results but not the runtime's internal backtracking events. Regex Tools does compiles the requested pattern with `PCRE2_AUTO_CALLOUT`, registers an
not label structural explanations, static hints or adjacent-result inference application callback, and copies only:
as an actual engine trace.
The next PCRE2 slice will use actual automatic/explicit callout events from an - callout number;
application-owned bridge. Event count, serialized bytes, match/depth/heap - pattern byte position and next-item byte length;
limits and wall-clock termination must all be enforced. Classifications such - subject byte position;
as “apparent backtrack” will remain visibly derived from adjacent events. - capture-top and capture-last numbers;
- bounded current mark text.
These fields have `reported` provenance. Pattern and subject byte boundaries
are validated and normalized to exact editor UTF-16 positions while the native
values remain visible.
Collection runs in `pcre2-trace.worker`, not the normal execution worker. It
uses the selected PCRE2 match, depth and heap limits plus the supervisor wall
clock. The callback retains only complete 32-byte records and mark slices under
both a 50,000-event and 10 MiB serialized-data hard cap. At a cap it returns
PCRE2s reserved `PCRE2_ERROR_CALLOUT`, abandoning the native match; the result
retains that native status, the last complete event and an explicit truncation
flag. Timeout, cancellation or crash discards the complete worker.
The viewer derives “forward”, “same position” and “apparent backtrack” solely
from the difference between adjacent reported subject positions. Each label
has `derived` provenance. This is useful navigation, not a claim that the
stream reports every internal action. PCRE2 start optimizations can also
conclude some requests without any callout.
Tracing represents one exact `pcre2_match()` invocation. The application-level
`g` iteration flag is not applied, and that fact is reported. Normal match,
replacement, tests, corpus, comparisons and benchmarks never use the
instrumented trace path.

View File

@@ -1,9 +1,9 @@
# Explanation model # Explanation model
The community provider converts regexpp nodes into a stable application AST The ECMAScript community provider converts regexpp nodes into a stable
inside the syntax worker. Nodes carry deterministic IDs, UTF-16 ranges, raw application AST inside the syntax worker. Nodes carry deterministic IDs,
source, capture metadata, quantifier bounds, assertion kind, support status, UTF-16 ranges, raw source, capture metadata, quantifier bounds, assertion kind,
provider identity and provenance. support status, provider identity and provenance.
Explanations are original deterministic text selected by node type. Nullable Explanations are original deterministic text selected by node type. Nullable
and minimum/maximum consumed-length properties are derived recursively where and minimum/maximum consumed-length properties are derived recursively where
@@ -15,6 +15,13 @@ regexpp 4.12.2 does not expose tolerant recovery. A malformed pattern therefore
gets the provider's exact diagnostic plus an application-owned error node. No gets the provider's exact diagnostic plus an application-owned error node. No
partial regexpp AST is claimed. partial regexpp AST is claimed.
The PCRE2 provider currently emits a deliberately partial lexical tree for
common capture forms. It does not infer branch-reset numbering or claim full
nesting; authoritative compilation, capture counts, names and result ranges
come from PCRE2 itself.
The explanation tree is structural. It is never called an actual execution The explanation tree is structural. It is never called an actual execution
trace. Selecting a node selects and scrolls its exact editor range; selecting trace. Selecting a node selects and scrolls its exact editor range; selecting
pattern text chooses the smallest enclosing node. pattern text chooses the smallest enclosing node. PCRE2s separate trace viewer
contains actual bounded automatic callouts; its movement labels remain a
distinct derived layer and are never copied into the structural explanation.

View File

@@ -1,20 +1,55 @@
# Flavour support # Flavour support
| Flavour | Syntax | Execution | Replacement | Captures | Trace | Native offsets | | Flavour | Syntax | Execution | Replacement | Captures | Trace | Analysis | Generated cases | Native offsets |
| ------------ | ------------------------------------------------------------------ | ----------------------------------------------- | ---------------------------------------------------- | -------------------------------------------------- | ----------- | ------------------- | | ------------ | ------------------------------------------------------------ | ------------------------------------------------------ | ---------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------- | ------------------- |
| ECMAScript | regexpp 4.12.2 grammar; explanations partial/feature-matrix tested | Current browser `RegExp`; feature-matrix tested | Native matches; bounded ECMAScript `GetSubstitution` | Named/numbered; final repeated capture; no history | Unavailable | UTF-16 | | ECMAScript | regexpp 4.12.2; explanations partial/feature-matrix tested | Current browser `RegExp`; conformance tested | Bounded ECMAScript `GetSubstitution` | Named/numbered; final repeated capture; no history | Unavailable | Experimental static heuristics + bounded native probes | Bounded normalized-AST sampling; actual-engine verified | UTF-16 |
| PCRE2 | Unavailable in release | Technical spike only; not shipped | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes | | PCRE2 10.47 | Application lexical provider partial; compiler authoritative | Pinned 8-bit WASM; bounded/killable/conformance tested | Native bounded `pcre2_substitute()` loop | Native named/numbered records; no capture history | Bounded reported automatic callouts; derived movement labels | Unavailable; ECMAScript heuristics are never applied | Unavailable; structural syntax provider is partial | UTF-8 bytes |
| PCRE | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | | PCRE | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable |
| Python `re` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned code points | | Python `re` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned code points |
| Go `regexp` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes | | Go `regexp` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes |
| Rust `regex` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes | | Rust `regex` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes |
| .NET | Unavailable | Unavailable | Unavailable | Planned capture history | Unavailable | Planned UTF-16 | | .NET | Unavailable | Unavailable | Unavailable | Planned capture history | Unavailable | Unavailable | Unavailable | Planned UTF-16 |
| Java | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-16 | | Java | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-16 |
ECMAScript tests cover `g`, `y`, `u`, internal `d`, zero-length iteration, ECMAScript tests cover its complete exposed flag matrix, internal indices,
named groups, unmatched/empty/repeated groups, lookbehind, backreferences, zero-length iteration, captures, lookbehind, backreferences, Unicode properties
Unicode properties, astral input, replacement and result truncation. and bounded replacement.
Actual browser identity is displayed because ECMAScript behavior can evolve PCRE2 tests cover `g`, `i`, `m`, `s`, `x`, `U`, `J`, mandatory UTF/UCP,
with the runtime. Syntax acceptance and engine compilation are separate; branch-reset groups, recursion, `\K`, duplicate names, native substitution,
neither silently rewrites the pattern. astral byte/UTF-16 ranges, zero-length iteration, compile errors, match limits
and result/output truncation. Trace tests cover reported automatic callouts,
bounded mark copying, native stop at the complete-event cap, compile ranges,
worker loading and browser rendering.
ECMAScript static findings and generated-input timing observations are
advisory. They describe only the selected ECMAScript 2025 pattern, browser
runtime and bounded inputs; they are not proofs of safety or general
complexity.
ECMAScript generated-case version 1 uses only a complete accepted normalized
AST, an explicit deterministic seed and bounded sampling strategies. Every
retained positive or negative label is confirmed by the actual selected
browser engine. PCRE2 generation is unavailable because its current lexical
provider is intentionally partial; ECMAScript behavior is never relabelled as
PCRE2.
The PCRE2 explanation provider deliberately does not invent full structural
coverage. In particular, branch-reset numbering is omitted from the syntax tree
while actual capture numbers and names still come from PCRE2 results.
ECMAScript and PCRE2 support exact-request comparison for syntax acceptance,
engine compilation, matches, captures and engine-native replacement. Results
align by normalized UTF-16 ranges while retaining each native offset model.
Timeout, cancellation, rejection and truncation are never presented as
equivalent results.
Pattern formatting is available only for an exact accepted ECMAScript regexpp
snapshot. It canonicalizes literal slashes and raw control/line-separator
representations, then requires paired actual-engine and applicable exact-test
validation before apply. PCRE2 formatting is unavailable because the current
provider is deliberately partial; its syntax is never treated as ECMAScript.
PCRE2 is the only current code-generation target. The reviewed C17 generator is
pinned to the PCRE2 10.47 8-bit API; other language generators remain
unavailable until they pass equivalent escaping and named-toolchain gates.

57
docs/FORMATTING.md Normal file
View File

@@ -0,0 +1,57 @@
# Pattern formatting
Regex Tools includes a deliberately narrow, local ECMAScript formatter. It
canonicalizes representations that can be changed without inventing a visual
layout:
- an unescaped literal `/` becomes `\/`, making the pattern safe to copy into
an ECMAScript regex literal;
- raw NUL and control code units become fixed-width `\xNN` or their canonical
short escape (`\t`, `\n`, `\v`, `\f`, `\r`);
- raw U+2028 and U+2029 line separators become `\u2028` and `\u2029`;
- legacy escaped raw controls are replaced as one parser-reported literal
token, so the formatter does not introduce an extra backslash.
Existing explicit escape spellings, groups, alternatives, quantifiers, inline
flags and literal whitespace are otherwise preserved byte for byte. ECMAScript
regex whitespace is normally significant, so the tool does not insert
indentation, line breaks or comments and does not claim to be a structural
pretty-printer. The transform is idempotent.
## Eligibility
Formatting requires the exact current, accepted, non-recovered syntax snapshot
from the bundled `@eslint-community/regexpp` provider. Transformations are
derived from the provider's literal-token ranges rather than from a heuristic
text scan. A stale parse, a malformed pattern, a partial provider, PCRE2 or any
other flavour is reported as unavailable and is never silently rewritten.
## Mandatory validation before apply
The preview cannot be applied directly. Regex Tools first:
1. reparses source and candidate independently and checks capture numbering,
names, repetition and parent-capture shape;
2. compiles and runs the source and candidate in separate actual ECMAScript
engine workers against the current subject and replacement;
3. compares exact normalized match, capture and replacement output, including
the engine identity;
4. reruns every enabled unit test whose flavour, version, pattern, flags and
engine options exactly match the active source snapshot, and compares both
semantic output and assertion outcome.
A compile rejection, one-sided timeout, crash, worker error, cancellation,
truncated result, engine-identity change, incomplete test suite or semantic
difference blocks application. Matching fixed timeouts are comparable only for
an exact unit-test pair; two timeouts on the current interactive snapshot are
inconclusive. If no unit test is applicable, the UI says so and still requires
the current subject/replacement gate.
Validation is bounded to 1,000 tests, the normal worker/input limits and a
60-second aggregate test wall budget. A test is not started unless its complete
configured timeout still fits that budget. The user must explicitly confirm
the exact validated candidate before the Apply button is enabled.
Passing these gates is strong, reproducible evidence for the current snapshot
and exact tests. It is not a mathematical proof of equivalence for every
possible subject.

135
docs/GENERATED_CASES.md Normal file
View File

@@ -0,0 +1,135 @@
# Generated test cases
Regex Tools generator version 1 creates deterministic candidate subjects from
the application-owned normalized AST and then verifies every candidate through
the selected actual engine adapter. Generation is local-only and bounded. It is
sampling, not proof of language coverage, equivalence or safety.
## Supported scope
Version 1 is scoped to an accepted ECMAScript 2025 normalized AST from the
bundled regexpp-based provider. It requests:
- a shortest structural candidate using the shortest visible alternative and
each quantifier minimum;
- each structurally visible alternative;
- quantifier minimum, adjacent and bounded upper values;
- optional-absent and optional-present values;
- bounded character-class, dot and Unicode-property representatives;
- newline and non-boundary context around anchors;
- deletion, replacement and boundary mutations as likely near misses;
- deterministic random alternative, quantifier and class choices.
The coverage report says `covered`, `partial`, `unsupported` or
`not applicable` for each category. “Covered” means that the documented bounded
sampling strategy was requested. It does not mean every string in the regular
language was enumerated.
PCRE2 generation is unavailable in version 1. Its actual engine is complete for
the advertised execution operations, but its current structural syntax
provider is intentionally partial. Regex Tools does not feed PCRE2 syntax to
the ECMAScript generator or relabel ECMAScript behavior.
## Unsupported and partial constructs
The report includes exact normalized source ranges and a reason for every
recognized gap, capped at 250 rendered entries. Version 1 does not solve:
- capture-dependent backreferences;
- lookaround or word-boundary constraints symbolically;
- recursion, subroutine calls or conditional execution;
- branch-reset and atomic grouping;
- engine control verbs or callouts;
- complex Unicode-set algebra and properties of strings;
- recovered or provider-unsupported syntax.
Surrounding candidates may still happen to satisfy a lookaround or boundary.
They are retained only when the actual selected engine confirms the requested
match outcome. That confirmation does not upgrade the generators structural
coverage claim.
## Determinism and seed
The seed is explicit text of 1128 UTF-16 units. Generator version 1 hashes its
UTF-16 code units with a documented stable 32-bit FNV-1a step and drives an
application-owned 32-bit deterministic sequence. It never calls
`Math.random()`.
The same generator version, normalized AST, flags, settings and seed produce
the same candidate order and candidate identifiers. Verification timing is
environment-dependent, and strict wall-time or engine-timeout bounds can
therefore stop two runs at different points. The UI records both the original
seed and its eight-digit hash. Generated unit tests retain:
- generator id and version;
- the original seed;
- candidate id;
- intended positive or negative outcome.
That provenance survives project and test-suite JSON export when subjects are
included. Editing a generated test turns it into a user-authored test and
removes the generation provenance.
## Actual-engine verification
Candidate synthesis runs in a dedicated killable worker. The verifier then
sends each exact candidate, pattern, flavour version, flags, engine options and
scan mode through `EngineSupervisor`, which selects the registered real engine
worker.
- An intended positive is labelled `should-match` only after the engine returns
at least one match.
- An intended negative is labelled `should-not-match` only after the engine
returns no match.
- A candidate with the opposite result is discarded and retained only as a
bounded advanced diagnostic.
- Engine compile rejection, timeout, worker crash, cancellation and ordinary
execution error remain distinct outcomes.
- Cancellation terminates both candidate-synthesis and active engine workers.
- Configuration changes invalidate results immediately; old cases are never
displayed against a new pattern or engine configuration.
The engine name, version, adapter, runtime and native offset unit shown with the
result come from the actual execution result, not static marketing metadata.
## Bounds
Defaults:
| Bound | Default |
| -------------------------------------- | ------------------------: |
| Retained verified cases | 48 |
| Candidate attempts | 192 |
| Seeded random variants | 16 |
| Per-case engine timeout | 500 ms |
| Aggregate wall time | 15 s |
| Bytes per candidate subject | 64 KiB |
| Aggregate candidate subject bytes | 1 MiB |
| Synthesized repetitions per quantifier | 32 |
| Normalized AST traversal | 20,000 nodes / 512 levels |
Hard caps:
| Bound | Hard cap |
| -------------------------------------- | -------: |
| Retained verified cases | 1,000 |
| Candidate attempts | 4,000 |
| Per-case engine timeout | 10 s |
| Aggregate wall time | 30 s |
| Bytes per candidate subject | 16 MiB |
| Aggregate candidate subject bytes | 16 MiB |
| Synthesized repetitions per quantifier | 1,024 |
| Advanced discard diagnostics | 250 |
UTF-8 sizes are checked before a candidate is retained for verification.
Quantifier expansion is preflighted before `String.prototype.repeat` allocates
the output. Reaching a case, attempt, byte, AST, repetition or wall-time bound
is visible in the result; it is never reported as complete coverage.
## Privacy and persistence
No pattern, candidate, verified subject, diagnostic or seed is uploaded.
Results and settings are ephemeral until the user explicitly adds cases to the
unit-test suite or downloads the verified JSON artifact. Test-suite export
omits subjects by default because generated subjects may still encode literal
pattern content. Enabling subject export is an explicit user choice.

71
docs/MINIMIZATION.md Normal file
View File

@@ -0,0 +1,71 @@
# Subject minimization
The **Minimize** workspace reduces one failing subject locally against the
selected real ECMAScript or PCRE2 worker. It can preserve:
- the exact failure class of any supported saved unit-test assertion;
- an identified capture's wrong participation, value or UTF-16 range;
- an engine worker timeout at one fixed deadline;
- an ECMAScript/PCRE2 semantic mismatch with the same mismatch-kind set; or
- the same one-sided comparison timeout while the other engine completes with
authoritative, untruncated output.
The fixed pattern, ordered flags, options, replacement and engine versions form
the target identity. Syntax is parsed once to obtain capture metadata. Every
accepted subject candidate is then verified through the selected execution
worker; the reducer does not contain a substitute regex implementation.
## Deterministic transforms
Subjects must be well-formed Unicode. The reducer never splits a surrogate
pair and rejects input containing a lone surrogate.
Reduction order is stable:
1. contiguous ddmin chunk deletion, left to right;
2. single Unicode-scalar deletion, left to right; then
3. lower-rank replacement by `a`, `0`, space, newline and `!`, in that order.
The last two passes restart after every accepted change and continue to a fixed
point. A complete pass with no accepted candidate establishes only **local
minimality under those transforms**. It is not a proof of the shortest,
globally minimal or semantically simplest reproducer. Evaluation or wall-budget
exhaustion, an inconclusive candidate, cancellation and worker failure always
return a result without a minimality claim.
## Exact predicates
A baseline run must reproduce the requested failure before reduction starts.
Unit-test failures retain their assertion class; a wrong capture value cannot
turn into a missing capture, and a semantic comparison retains the exact sorted
set of baseline mismatch kinds. Rejected patterns and truncated match,
capture or replacement data are not accepted as semantic evidence.
Timeout is a worker deadline outcome, never a no-match. An explicit timeout
predicate accepts only `WorkerRequestError("timeout")` at the configured fixed
deadline. Comparison timeout reduction additionally requires the same flavour
to time out and the peer flavour to complete with the same engine identity and
complete data. Crash, unreadable worker response, ordinary worker error and
cancellation have separate statuses. Crash minimization is unsupported and a
crash never satisfies a timeout predicate.
For a `must-time-out` unit-test assertion, an ordinary completion is the test
failure being minimized; a timeout makes that assertion pass. The separate
**Exact engine timeout** target is used to minimize an observed timeout.
## Bounds and progress
- Subject: 1 MiB UTF-8, separate from the larger interactive editor cap.
- Candidate evaluations: at most 2,000, including the baseline.
- Aggregate syntax-setup and reduction wall time: at most 60 seconds.
- Candidate worker deadline: fixed for the complete run, at most 10 seconds.
- Retained accepted-transform history: 200 entries; totals remain exact.
A candidate starts only when its full fixed deadline fits inside the remaining
aggregate wall budget. This prevents the aggregate deadline from being
misreported as an engine timeout. Progress exposes phase, evaluation and wall
budgets, accepted reductions, current scalar/byte sizes and the latest
observation. Cancellation terminates syntax and execution workers.
The minimized subject can be copied or deliberately applied to the main editor.
Inputs and results stay in memory and are neither uploaded nor persisted.

View File

@@ -7,10 +7,13 @@ community
``` ```
It uses only reviewed open-source dependencies and builds without private It uses only reviewed open-source dependencies and builds without private
registry credentials. Version 0.1.0 parses ECMAScript with regexpp 4.12.2. registry credentials. ECMAScript uses regexpp 4.12.2. PCRE2 uses an
application-owned lexical provider for partial explanations and the actual
bundled PCRE2 compiler for authoritative acceptance.
The product deliberately does not implement or reference an `@r101/parser` The product deliberately does not implement or reference an `@r101/parser`
profile. No commercial package alias, import, tarball, credential, licence key profile. No commercial package alias, import, tarball, credential, licence key
or private CI path exists. Additional flavour syntax will be implemented or private CI path exists. Additional flavour syntax will be implemented
incrementally as open-source providers behind the same application-owned incrementally as open-source providers behind the same application-owned
interface. interface. Partial coverage is exposed as metadata and never presented as a
complete parser.

66
docs/PCRE2_NEXT_SLICES.md Normal file
View File

@@ -0,0 +1,66 @@
# PCRE2 advanced slices
This document records the reviewed boundaries for advanced PCRE2 work. A
feature is exposed only after its complete runtime and UI path passes the
stated gates.
## Automatic-callout trace — implemented
ABI version 3 and the dedicated trace worker now provide:
- a new ABI operation separate from normal matching, compiled with
`PCRE2_AUTO_CALLOUT`;
- a fixed copied event record containing only reported callout number, pattern
byte position, next-item length, subject byte position, capture top/last and
bounded mark text;
- caller-supplied event and text buffers capped by both
`maximumTraceEvents` and `maximumTraceBytes`;
- explicit `eventsTruncated`, native status and last-complete-event fields;
- the existing match/depth/heap limits and supervisor timeout;
- cleanup identical to normal ABI calls.
Tracing runs in its own worker and never replaces the normal result used by
Match, Replace, Tests, Corpus or Analysis. “Forward”, “same position” and
“apparent backtrack” are derived from adjacent reported positions only and
carry derived provenance. The viewer explicitly states that a callout stream
is not a complete account of every internal engine action.
## ECMAScript versus PCRE2 comparison — implemented
The comparison orchestrator issues two explicit requests with
`Promise.allSettled` through independently killable flavour workers. It does
not translate flags, patterns or replacement syntax.
The comparison DTO retains:
- each exact request identity, engine identity and independently complete or
timed-out result;
- match/capture differences aligned by editor UTF-16 ranges, while retaining
each native offset model;
- explicit “not comparable” states for syntax/compile failure, truncation,
timeout, cancellation, worker failure or flavour-only constructs;
- a shared wall-clock cancellation action that terminates both workers.
The orchestrator remains outside `EngineSupervisor`, preserving the
single-engine lifecycle boundary. Shared patterns are sent unchanged and
explicit per-flavour variants remain user-owned. Agreement is labelled only as
evidence for the current subject.
## PCRE2 C code generation — implemented
The first reviewed target consumes a validated request snapshot and uses an
application-owned C17 template. It:
- identifies exact PCRE2 10.47, 8-bit, UTF/UCP semantics and selected flags;
- emits pattern, subject and replacement independently as exact UTF-8 byte
arrays;
- includes match/depth/heap limits and error handling in generated C examples;
- states when a target binding cannot express an application-level `g` flag or
a Regex Tools output/result cap directly;
- ships deterministic golden match/replacement fixtures that compile and
execute against official PCRE2 10.47 before the target is advertised.
No generated snippet may be used as the implementation of the browser runtime.
Other target languages remain unavailable until their own escaping and
toolchain gates pass. See
[`COMPARISON_AND_CODEGEN.md`](COMPARISON_AND_CODEGEN.md).

View File

@@ -10,6 +10,18 @@ browser, engine version, pattern and subject. The UI therefore reports observed
elapsed time and exact runtime identity but makes no universal throughput or elapsed time and exact runtime identity but makes no universal throughput or
safety claim. safety claim.
PCRE2 cold execution includes loading and instantiating the self-hosted
WebAssembly module inside its worker; warm requests reuse that instance.
Every request still compiles and frees its pattern so no unbounded compiled-code
cache can accumulate. Native match-step, depth and heap limits complement the
supervisor wall clock, and cancellation discards the complete worker heap.
Automatic-callout tracing is deliberately excluded from every benchmark. It
compiles an independently instrumented PCRE2 pattern in a separate worker and
can materially change runtime work. The trace path retains at most 50,000
complete 32-byte event records and 10 MiB total serialized event/mark bytes;
the viewer renders at most 2,000 retained events.
The production bundle separates syntax and execution workers. Interactive The production bundle separates syntax and execution workers. Interactive
syntax/extraction trees and editor decoration sets render at most 2,000 items syntax/extraction trees and editor decoration sets render at most 2,000 items
each and report both the rendered and actual totals. These are presentation each and report both the rendered and actual totals. These are presentation
@@ -62,5 +74,57 @@ representation against a byte budget before materializing or writing it.
IndexedDB schema version 2 indexes `updatedAt`; latest-project loading opens one IndexedDB schema version 2 indexes `updatedAt`; latest-project loading opens one
descending cursor and validates only that record instead of calling `getAll()`. descending cursor and validates only that record instead of calling `getAll()`.
Formal cold/warm benchmarks, p95 statistics and growth charts are deferred to Corpus runs accept at most 256 documents, 16 MiB per document and 256 MiB of
the analysis milestone and will run in killable workers. aggregate UTF-8 input. Jobs run sequentially so only one engine request is
active at a time. A batch retains at most 100,000 matches and 64 MiB of applied
output, and stops after five minutes of aggregate wall time. Each document also
uses the selected manual timeout. Progress is reported after every document;
cancellation kills the current worker and labels all remaining documents.
Result tables contain summaries, not match/capture objects. ZIP export is
available only for an all-exact apply batch and is created asynchronously.
Independent-line mode preflights at most 100,000 logical lines and processes
each as an isolated engine subject while preserving line separators for exact
apply output. Per document, corpus extraction retains at most 32 group summaries
(including the full match), three 160-UTF-16-unit samples per group and 100
unique diagnostics. Output previews show 2,000 units. Content-free summary
exports are capped at 4 MiB; the applied archive is capped at 68 MiB in addition
to the 64 MiB uncompressed-output budget.
ECMAScript analysis reports cold compilation separately from warm native
execution. The default benchmark uses three warm-up and 15 measured samples;
user caps are 100 warm-ups and 1,000 samples. It reports median and
nearest-rank p95 values plus separate timing and outcome/growth plots with the
exact runtime identity. Each sample defaults to a 2-second killable-worker
limit (10-second cap), and the aggregate analysis wall time is 30 seconds.
Growth probes retain at most 24 steps, generate at most 1 MiB by default under
the central 16 MiB subject cap, and reject more than 10,000,000 repetitions
before string allocation. Replacement timing is skipped when match collection
is truncated or the estimated output exceeds 8 Mi UTF-16 units. Results are
observations for the chosen runtime and generated inputs, never a general
complexity proof. See [`ANALYSIS.md`](ANALYSIS.md).
Generated-case synthesis defaults to 48 retained cases from at most 192
candidates, 16 seeded-random variants, 64 KiB per candidate, 1 MiB aggregate
candidate bytes and 15 seconds of aggregate wall time. Every candidate then
uses a separate selected-engine request with a 500 ms default timeout.
Generation preflights UTF-8 size and repetition bounds, caps normalized-AST
traversal at 20,000 nodes / 512 levels and retains only bounded diagnostics.
Hard caps are documented in [`GENERATED_CASES.md`](GENERATED_CASES.md).
Subject minimization accepts at most 1 MiB UTF-8, performs at most 2,000 real
engine candidate evaluations and stops after at most 60 seconds including
syntax setup. One fixed worker timeout of at most 10 seconds applies to every
candidate. A candidate starts only when that complete deadline fits in the
remaining aggregate budget. Accepted-transform history retains 200 entries;
evaluation and accepted totals remain exact. See
[`MINIMIZATION.md`](MINIMIZATION.md).
Formatter validation uses two syntax supervisors and two actual-engine
supervisors so source/candidate outcomes cannot share compiled state. The
current subject/replacement snapshot always runs; at most 1,000 enabled tests
with the exact active identity are replayed. Test validation has a 60-second
aggregate wall budget, and a test starts only when its complete configured
worker timeout still fits. Preview details retain at most 10,000 transforms and
the UI renders the first 200. See [`FORMATTING.md`](FORMATTING.md).

View File

@@ -1,6 +1,6 @@
# Portal requirements # Portal requirements
Regex Tools 0.1.0 is a static nested-path-safe application. Regex Tools is a static nested-path-safe application.
Required delivery behavior: Required delivery behavior:
@@ -9,11 +9,17 @@ Required delivery behavior:
revalidation/no-cache resources. revalidation/no-cache resources.
- Hashed Vite assets may use immutable long-term caching. - Hashed Vite assets may use immutable long-term caching.
- CSP must allow `worker-src 'self' blob:`. - CSP must allow `worker-src 'self' blob:`.
- No WebAssembly MIME rule or `wasm-unsafe-eval` is required by v0.1.0. - `engines/pcre2/pcre2.wasm` must use `application/wasm`.
- `script-src` must permit self-hosted WebAssembly compilation, normally with
`'wasm-unsafe-eval'`; broad `'unsafe-eval'` is neither needed nor recommended.
- `engines/pcre2/pcre2.mjs`, metadata, checksums, licence and WASM should use
revalidation or a release-scoped immutable cache. They are not
content-hashed filenames and must never drift under one release identity.
The Portal must consume the immutable release ZIP, verify its SHA-256, verify The Portal must consume the immutable release ZIP, verify its SHA-256, verify
manifest ID `de.add-ideas.regex-tools` and version `0.1.0`, and mount it at a manifest ID `de.add-ideas.regex-tools` and version `0.2.0`, and mount it at a
relative target such as `apps/regex/`. relative target such as `apps/regex/`.
When PCRE2 ships later, the smallest additional change will be The package, manifest, tag, artifact URL and checksum must all use the v0.2.0
`application/wasm` delivery and `script-src 'self' 'wasm-unsafe-eval'`. identity. The historical v0.1.0 artifact coordinates remain immutable and must
not be overwritten or reused.

View File

@@ -8,7 +8,7 @@ Inspection date: 2026-07-24.
| Toolbox SDK | `53c40a61ba1581246f65773fcbb1c1cfd31ac98e` / 0.2.2 | Apache-2.0 | Contract, shell and release conventions; package APIs used, no source adapted. | | Toolbox SDK | `53c40a61ba1581246f65773fcbb1c1cfd31ac98e` / 0.2.2 | Apache-2.0 | Contract, shell and release conventions; package APIs used, no source adapted. |
| Toolbox Portal | `a9c31c8986c40a0097966318e925083302e91e13` / 0.5.0 | AGPL-3.0-only | Assembly/lock conventions inspected; no source copied into the app. | | Toolbox Portal | `a9c31c8986c40a0097966318e925083302e91e13` / 0.5.0 | AGPL-3.0-only | Assembly/lock conventions inspected; no source copied into the app. |
| regexpp | 4.12.2; npm integrity `sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==` | MIT | Pinned ECMAScript syntax provider. Provider AST is normalized in a worker. | | regexpp | 4.12.2; npm integrity `sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==` | MIT | Pinned ECMAScript syntax provider. Provider AST is normalized in a worker. |
| PCRE2 | signed tag 10.47; commit `f454e231fe5006dd7ff8f4693fd2b8eb94333429` | BSD-3-Clause WITH PCRE2-exception | External technical spike only; not shipped or copied. | | PCRE2 | signed tag 10.47; commit `f454e231fe5006dd7ff8f4693fd2b8eb94333429` | BSD-3-Clause WITH PCRE2-exception | Pinned offline build; generated verified WebAssembly pack is shipped, source is not vendored. |
| ECMAScript specification | <https://tc39.es/ecma262/>; living specification inspected 2026-07-24 | ECMA terms | Semantics reference; no prose copied. | | ECMAScript specification | <https://tc39.es/ecma262/>; living specification inspected 2026-07-24 | ECMA terms | Semantics reference; no prose copied. |
| regex101 | <https://regex101.com/> and public parser documentation | Product/commercial terms | Product reference only. No source, branding, explanations, reference prose or visual design copied. | | regex101 | <https://regex101.com/> and public parser documentation | Product/commercial terms | Product reference only. No source, branding, explanations, reference prose or visual design copied. |
| `recheck` | 4.5.0 | MIT | Metadata inspected; not installed or shipped. | | `recheck` | 4.5.0 | MIT | Metadata inspected; not installed or shipped. |
@@ -17,5 +17,5 @@ Inspection date: 2026-07-24.
| RegexLib | Website inspected | Redistribution licence not established | No fixture copied. | | RegexLib | Website inspected | Redistribution licence not established | No fixture copied. |
| RGXP.RU | Website inspected | Redistribution licence not established | No fixture copied. | | RGXP.RU | Website inspected | Redistribution licence not established | No fixture copied. |
All v0.1.0 conformance cases are project-authored. See All v0.1.0 and v0.2.0 conformance cases are project-authored. See
`tests/fixtures/README.md`. `tests/fixtures/README.md`.

View File

@@ -18,6 +18,9 @@ on publication failure, replaces an existing exact version only with
`--force`, and produces: `--force`, and produces:
```text ```text
release/regex-tools-0.1.0.zip release/regex-tools-0.2.0.zip
release/regex-tools-0.1.0.zip.sha256 release/regex-tools-0.2.0.zip.sha256
``` ```
The existing `release/regex-tools-0.1.0.zip` and matching sidecar are historical
immutable artifacts. Creating v0.2.0 must not replace, rename or remove them.

View File

@@ -9,6 +9,16 @@ untrusted.
- A terminated worker is never reused. - A terminated worker is never reused.
- Pattern, subject, replacement/list template, match, capture and output sizes - Pattern, subject, replacement/list template, match, capture and output sizes
are bounded. are bounded.
- PCRE2 adds native match-step, depth and heap caps. Its C ABI returns only
caller-owned bounded records and releases code, match data and match context
on every exit path.
- PCRE2 automatic-callout tracing is a separate ABI and worker path. It copies
only complete fixed records and bounded mark text, stops matching at the
50,000-event or 10 MiB serialized trace cap, and is killed on timeout,
cancellation or crash.
- PCRE2 runs only in mandatory UTF/UCP mode. Exact UTF-8 byte boundaries are
normalized to editor UTF-16 offsets; lone surrogates are rejected instead of
being silently replaced by browser encoding.
- Replacement templates are strings; executable callbacks are unavailable. - Replacement templates are strings; executable callbacks are unavailable.
- Replacement/list token decorations, rows, chips, diagnostics and list - Replacement/list token decorations, rows, chips, diagnostics and list
evaluation work have separate presentation/operation caps; incomplete list evaluation work have separate presentation/operation caps; incomplete list
@@ -25,14 +35,51 @@ untrusted.
- Unit-test assertion diagnostics never stringify complete large values and - Unit-test assertion diagnostics never stringify complete large values and
retain at most 2 KiB UTF-8 per case. Oversized replacement output is not retain at most 2 KiB UTF-8 per case. Oversized replacement output is not
copied into an exact current-result draft. copied into an exact current-result draft.
- Corpus file input is local, recognized UTF-8 text only. It is bounded before
decoding, rejects NUL/binary input, runs sequentially in a dedicated
cancellable supervisor and never modifies source files.
- Corpus JSON/CSV/NDJSON summaries never contain source, capture-sample or
applied-output content. CSV cells are formula-injection protected. Local UI
previews are explicitly bounded. Applied files require an explicit
content-export acknowledgement; names are normalized against archive path
traversal and collisions. Incomplete applied output cannot be downloaded or
included in the all-document ZIP, whose bytes have a separate hard limit.
- Corpus documents, contents, outputs and results are never placed in project
JSON or IndexedDB. Schema-v1 imports that attempt to inject corpus payloads
are rejected.
- ECMAScript growth analysis preflights repetition count and UTF-8 bytes before
allocating a generated string. The analysis worker is terminated on timeout,
cancellation or crash; replacement timing also preflights its output bound.
Analysis settings and results are neither persisted nor uploaded.
- Generated-case synthesis runs in its own killable worker under AST, depth,
attempt, repetition and UTF-8 byte caps. Labels come only from bounded
requests to the selected real engine worker; a wrong outcome, timeout, crash,
cancellation or compile rejection is never converted into a verified case.
Settings, discarded candidates and unselected results are ephemeral and
never uploaded.
- Imported generated-test provenance is accepted only for the exact supported
generator id/version and must agree with the test assertion outcome. Unknown
provenance fields and future versions are rejected; editing a case removes
its provenance.
- ECMAScript formatting consumes only exact accepted regexpp literal-token
ranges and never treats PCRE2 as ECMAScript. Source/candidate syntax and
execution use separate killable workers; compile failure, truncation,
timeout asymmetry, engine-identity drift, incomplete tests or any semantic
difference fails closed. Preview/results are ephemeral and apply requires an
explicit confirmation.
- Subject minimization rejects lone surrogates, caps input at 1 MiB UTF-8,
evaluation count at 2,000 and aggregate wall time at 60 seconds. Each
predicate check runs in the selected killable engine worker. Timeout, crash,
cancellation and worker error remain distinct; truncated results fail
closed. Subjects and results are ephemeral and never uploaded or persisted.
- No backend, uploads, telemetry, remote corpus URL, CDN, remote font, `eval`, - No backend, uploads, telemetry, remote corpus URL, CDN, remote font, `eval`,
`Function`, arbitrary command line or unsafe HTML rendering exists. `Function`, arbitrary command line or unsafe HTML rendering exists.
Version 0.1.0 needs a CSP equivalent to: The PCRE2-enabled build needs a CSP equivalent to:
```text ```text
default-src 'self'; default-src 'self';
script-src 'self'; script-src 'self' 'wasm-unsafe-eval';
worker-src 'self' blob:; worker-src 'self' blob:;
connect-src 'self'; connect-src 'self';
img-src 'self' data:; img-src 'self' data:;
@@ -44,8 +91,9 @@ base-uri 'none';
form-action 'none'; form-action 'none';
``` ```
The Toolbox shell currently requires inline styles. No `unsafe-eval` or The Toolbox shell currently requires inline styles. Corpus ZIP generation and
`wasm-unsafe-eval` is required until an actual WebAssembly flavour ships. PCRE2 use self-hosted modules and local workers under the existing
`worker-src` policy. Broad `unsafe-eval` is not required.
Report vulnerabilities privately to the repository owner before public issue Report vulnerabilities privately to the repository owner before public issue
details are posted. details are posted.

View File

@@ -0,0 +1,71 @@
# SPDX-License-Identifier: GPL-3.0-or-later
cmake_minimum_required(VERSION 3.25)
project(regex_pcre2_bridge LANGUAGES C)
if(NOT EMSCRIPTEN)
message(FATAL_ERROR "The staged PCRE2 pack must be built with Emscripten.")
endif()
if(NOT DEFINED PCRE2_SOURCE_DIR OR NOT IS_DIRECTORY "${PCRE2_SOURCE_DIR}")
message(FATAL_ERROR "PCRE2_SOURCE_DIR must identify the verified PCRE2 source checkout.")
endif()
if(NOT DEFINED REGEX_PCRE2_OUTPUT_DIR)
message(FATAL_ERROR "REGEX_PCRE2_OUTPUT_DIR must identify the private staging directory.")
endif()
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(BUILD_STATIC_LIBS ON CACHE BOOL "" FORCE)
set(PCRE2_BUILD_PCRE2_8 ON CACHE BOOL "" FORCE)
set(PCRE2_BUILD_PCRE2_16 OFF CACHE BOOL "" FORCE)
set(PCRE2_BUILD_PCRE2_32 OFF CACHE BOOL "" FORCE)
set(PCRE2_BUILD_PCRE2GREP OFF CACHE BOOL "" FORCE)
set(PCRE2_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(PCRE2_DEBUG OFF CACHE STRING "" FORCE)
set(PCRE2_REBUILD_CHARTABLES OFF CACHE BOOL "" FORCE)
set(PCRE2_SHOW_REPORT OFF CACHE BOOL "" FORCE)
set(PCRE2_STATIC_PIC OFF CACHE BOOL "" FORCE)
set(PCRE2_SUPPORT_JIT OFF CACHE BOOL "" FORCE)
set(PCRE2_SUPPORT_UNICODE ON CACHE BOOL "" FORCE)
add_subdirectory("${PCRE2_SOURCE_DIR}" pcre2-source EXCLUDE_FROM_ALL)
add_executable(regex-pcre2-pack pcre2_bridge.c)
target_compile_features(regex-pcre2-pack PRIVATE c_std_11)
target_compile_definitions(regex-pcre2-pack PRIVATE PCRE2_CODE_UNIT_WIDTH=8)
target_compile_options(
regex-pcre2-pack
PRIVATE
-O3
-fno-ident
-fvisibility=hidden
)
target_link_libraries(regex-pcre2-pack PRIVATE pcre2-8-static)
target_link_options(
regex-pcre2-pack
PRIVATE
-O3
--no-entry
"SHELL:-s ALLOW_MEMORY_GROWTH=1"
"SHELL:-s ASSERTIONS=0"
"SHELL:-s ENVIRONMENT=web,worker,node"
"SHELL:-s EXPORT_ES6=1"
"SHELL:-s EXPORT_NAME=createRegexPcre2"
"SHELL:-s EXPORTED_FUNCTIONS=['_regex_pcre2_bridge_abi_version','_regex_pcre2_compile_probe','_regex_pcre2_config_flags','_regex_pcre2_error_message','_regex_pcre2_execute','_regex_pcre2_self_test','_regex_pcre2_substitute','_regex_pcre2_trace','_regex_pcre2_version','_malloc','_free']"
"SHELL:-s EXPORTED_RUNTIME_METHODS=['HEAPU8','UTF8ToString']"
"SHELL:-s FILESYSTEM=0"
"SHELL:-s INITIAL_MEMORY=16777216"
"SHELL:-s MALLOC=emmalloc"
"SHELL:-s MAXIMUM_MEMORY=268435456"
"SHELL:-s MODULARIZE=1"
"SHELL:-s NO_EXIT_RUNTIME=1"
"SHELL:-s STRICT=1"
)
set_target_properties(
regex-pcre2-pack
PROPERTIES
OUTPUT_NAME pcre2
RUNTIME_OUTPUT_DIRECTORY "${REGEX_PCRE2_OUTPUT_DIR}"
SUFFIX ".mjs"
)

27
engines/pcre2/README.md Normal file
View File

@@ -0,0 +1,27 @@
# PCRE2 engine bridge
This directory contains the GPL-3.0-or-later application bridge and
deterministic build description for the bundled PCRE2 flavour. It deliberately
does not vendor the upstream PCRE2 source tree or generated binaries.
ABI version 3 exposes:
- exact engine identity and configuration;
- compile diagnostics with UTF-8 byte offsets;
- bounded non-overlapping matching with copied group-zero/capture records and
copied capture-name metadata;
- bounded native `pcre2_substitute()` semantics over the same retained match
sequence;
- bounded automatic-callout tracing from one native `pcre2_match()` invocation,
with copied event and mark data and an immediate native stop at either trace
cap;
- match-step, depth, heap, match-count, capture-row and output limits;
- no native pointer or compiled-code handle across a call boundary.
All PCRE2 allocations are released before an ABI call returns. JavaScript owns
and frees every input/result buffer in `finally`, and the containing worker is
terminated on timeout, crash, cancellation or supersession.
The explicit offline source/toolchain identity, build, verification and local
asset-install workflow is documented in
[`docs/ENGINE_BUILDS.md`](../../docs/ENGINE_BUILDS.md).

View File

@@ -0,0 +1,910 @@
/* SPDX-License-Identifier: GPL-3.0-or-later */
#define PCRE2_CODE_UNIT_WIDTH 8
#include "pcre2_bridge.h"
#include <pcre2.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#define REGEX_PCRE2_COMPILE_FLAGS \
(REGEX_PCRE2_CASELESS | REGEX_PCRE2_MULTILINE | REGEX_PCRE2_DOTALL | \
REGEX_PCRE2_EXTENDED | REGEX_PCRE2_UNGREEDY | REGEX_PCRE2_UTF | \
REGEX_PCRE2_UCP | REGEX_PCRE2_DUPNAMES)
#define REGEX_PCRE2_ALL_FLAGS \
(REGEX_PCRE2_COMPILE_FLAGS | REGEX_PCRE2_GLOBAL)
_Static_assert(sizeof(regex_pcre2_compile_result) == 20,
"Compile result is part of ABI version 3.");
_Static_assert(sizeof(regex_pcre2_limits) == 20,
"Limits record is part of ABI version 3.");
_Static_assert(sizeof(regex_pcre2_match_record) == 16,
"Match record is part of ABI version 3.");
_Static_assert(sizeof(regex_pcre2_name_record) == 12,
"Name record is part of ABI version 3.");
_Static_assert(sizeof(regex_pcre2_run_result) == 60,
"Run result is part of ABI version 3.");
_Static_assert(sizeof(regex_pcre2_trace_limits) == 20,
"Trace limits are part of ABI version 3.");
_Static_assert(sizeof(regex_pcre2_trace_event) == 32,
"Trace event is part of ABI version 3.");
_Static_assert(sizeof(regex_pcre2_trace_result) == 52,
"Trace result is part of ABI version 3.");
typedef struct regex_pcre2_run_buffers {
regex_pcre2_match_record *records;
uint32_t record_capacity;
regex_pcre2_name_record *name_records;
uint32_t name_record_capacity;
uint8_t *name_bytes;
uint32_t name_bytes_capacity;
uint8_t *output;
uint32_t output_capacity;
} regex_pcre2_run_buffers;
typedef struct regex_pcre2_trace_state {
regex_pcre2_trace_event *events;
uint32_t event_capacity;
uint8_t *mark_bytes;
uint32_t mark_bytes_capacity;
uint32_t maximum_events;
uint32_t maximum_trace_bytes;
uint32_t event_count;
uint32_t total_event_count;
uint32_t mark_bytes_length;
uint32_t events_truncated;
uint32_t marks_truncated;
} regex_pcre2_trace_state;
static const uint8_t empty_bytes[1] = {0};
static uint32_t to_pcre2_options(uint32_t application_flags) {
uint32_t options = 0;
if ((application_flags & REGEX_PCRE2_CASELESS) != 0) {
options |= PCRE2_CASELESS;
}
if ((application_flags & REGEX_PCRE2_MULTILINE) != 0) {
options |= PCRE2_MULTILINE;
}
if ((application_flags & REGEX_PCRE2_DOTALL) != 0) {
options |= PCRE2_DOTALL;
}
if ((application_flags & REGEX_PCRE2_EXTENDED) != 0) {
options |= PCRE2_EXTENDED;
}
if ((application_flags & REGEX_PCRE2_UNGREEDY) != 0) {
options |= PCRE2_UNGREEDY;
}
if ((application_flags & REGEX_PCRE2_UTF) != 0) {
options |= PCRE2_UTF;
}
if ((application_flags & REGEX_PCRE2_UCP) != 0) {
options |= PCRE2_UCP;
}
if ((application_flags & REGEX_PCRE2_DUPNAMES) != 0) {
options |= PCRE2_DUPNAMES;
}
return options;
}
static int32_t fail_compile(regex_pcre2_compile_result *result,
int32_t status) {
if (result != NULL) {
memset(result, 0, sizeof(*result));
result->status = status;
}
return status;
}
static int32_t fail_run(regex_pcre2_run_result *result,
int32_t status,
uint32_t phase,
uint32_t offset) {
if (result != NULL) {
memset(result, 0, sizeof(*result));
result->status = status;
result->error_phase = phase;
result->error_offset = offset;
}
return status;
}
static int32_t fail_trace(regex_pcre2_trace_result *result,
int32_t status,
uint32_t phase,
uint32_t offset) {
if (result != NULL) {
memset(result, 0, sizeof(*result));
result->status = status;
result->error_phase = phase;
result->error_offset = offset;
result->last_complete_event = UINT32_MAX;
}
return status;
}
static int input_pointer_valid(const uint8_t *value, uint32_t length) {
return value != NULL || length == 0;
}
static const uint8_t *nonnull_input(const uint8_t *value) {
return value == NULL ? empty_bytes : value;
}
static int limits_valid(const regex_pcre2_limits *limits) {
return limits != NULL && limits->maximum_matches > 0 &&
limits->maximum_matches <= REGEX_PCRE2_MAX_MATCHES &&
limits->maximum_capture_rows > 0 &&
limits->maximum_capture_rows <= REGEX_PCRE2_MAX_CAPTURE_ROWS &&
limits->match_limit > 0 &&
limits->match_limit <= REGEX_PCRE2_MAX_MATCH_LIMIT &&
limits->depth_limit > 0 &&
limits->depth_limit <= REGEX_PCRE2_MAX_DEPTH_LIMIT &&
limits->heap_limit_kib > 0 &&
limits->heap_limit_kib <= REGEX_PCRE2_MAX_HEAP_LIMIT_KIB;
}
static int buffers_valid(const regex_pcre2_run_buffers *buffers,
int substitution) {
if (buffers == NULL ||
(buffers->records == NULL && buffers->record_capacity != 0) ||
(buffers->name_records == NULL &&
buffers->name_record_capacity != 0) ||
(buffers->name_bytes == NULL && buffers->name_bytes_capacity != 0)) {
return 0;
}
/*
* PCRE2 writes a trailing NUL even when the semantic output capacity is
* zero, so the bridge contract always requires the advertised +1 byte.
*/
if (substitution && buffers->output == NULL) {
return 0;
}
return 1;
}
static uint32_t bounded_offset(PCRE2_SIZE value) {
return value > UINT32_MAX ? UINT32_MAX : (uint32_t)value;
}
static int32_t copy_names(const pcre2_code *code,
regex_pcre2_run_buffers *buffers,
regex_pcre2_run_result *result) {
uint32_t entry_size = 0;
PCRE2_SPTR table = NULL;
uint32_t index = 0;
uint32_t name_offset = 0;
if (result->name_count == 0) {
return 0;
}
if (pcre2_pattern_info(code, PCRE2_INFO_NAMEENTRYSIZE, &entry_size) != 0 ||
pcre2_pattern_info(code, PCRE2_INFO_NAMETABLE, &table) != 0 ||
entry_size < 3 || table == NULL) {
return REGEX_PCRE2_ERROR_CONFIG;
}
for (index = 0; index < result->name_count; index += 1) {
const uint8_t *entry = table + (index * entry_size);
uint32_t group_number =
((uint32_t)entry[0] << 8) | (uint32_t)entry[1];
uint32_t maximum_name = entry_size - 2;
uint32_t name_length = 0;
while (name_length < maximum_name && entry[2 + name_length] != 0) {
name_length += 1;
}
if (result->name_record_count >= buffers->name_record_capacity ||
name_length > buffers->name_bytes_capacity - name_offset) {
result->names_truncated = 1;
break;
}
buffers->name_records[result->name_record_count].group_number =
group_number;
buffers->name_records[result->name_record_count].name_offset =
name_offset;
buffers->name_records[result->name_record_count].name_length =
name_length;
if (name_length > 0) {
memcpy(buffers->name_bytes + name_offset, entry + 2, name_length);
}
result->name_record_count += 1;
name_offset += name_length;
}
result->name_bytes_length = name_offset;
return 0;
}
static int append_utf8_prefix(regex_pcre2_run_buffers *buffers,
regex_pcre2_run_result *result,
const uint8_t *source,
uint32_t length) {
uint32_t remaining = buffers->output_capacity - result->output_length;
uint32_t copied = length < remaining ? length : remaining;
if (copied < length) {
while (copied > 0 && (source[copied] & 0xc0u) == 0x80u) {
copied -= 1;
}
result->output_truncated = 1;
}
if (copied > 0) {
memcpy(buffers->output + result->output_length, source, copied);
result->output_length += copied;
}
return copied == length;
}
static int32_t configure_match_context_values(pcre2_match_context *context,
uint32_t match_limit,
uint32_t depth_limit,
uint32_t heap_limit_kib) {
if (pcre2_set_match_limit(context, match_limit) != 0 ||
pcre2_set_depth_limit(context, depth_limit) != 0 ||
pcre2_set_heap_limit(context, heap_limit_kib) != 0) {
return REGEX_PCRE2_ERROR_CONFIG;
}
return 0;
}
static int32_t configure_match_context(pcre2_match_context *context,
const regex_pcre2_limits *limits) {
return configure_match_context_values(
context, limits->match_limit, limits->depth_limit,
limits->heap_limit_kib);
}
static int trace_limits_valid(const regex_pcre2_trace_limits *limits) {
return limits != NULL && limits->maximum_events > 0 &&
limits->maximum_events <= REGEX_PCRE2_MAX_TRACE_EVENTS &&
limits->maximum_trace_bytes >= sizeof(regex_pcre2_trace_event) &&
limits->maximum_trace_bytes <= REGEX_PCRE2_MAX_TRACE_BYTES &&
limits->match_limit > 0 &&
limits->match_limit <= REGEX_PCRE2_MAX_MATCH_LIMIT &&
limits->depth_limit > 0 &&
limits->depth_limit <= REGEX_PCRE2_MAX_DEPTH_LIMIT &&
limits->heap_limit_kib > 0 &&
limits->heap_limit_kib <= REGEX_PCRE2_MAX_HEAP_LIMIT_KIB;
}
static uint32_t bounded_mark_length(PCRE2_SPTR mark, uint32_t *truncated) {
uint32_t length = 0;
if (mark == NULL) {
return 0;
}
while (length < REGEX_PCRE2_MAX_TRACE_MARK_BYTES && mark[length] != 0) {
length += 1;
}
if (length == REGEX_PCRE2_MAX_TRACE_MARK_BYTES && mark[length] != 0) {
*truncated = 1;
while (length > 0 && (mark[length] & 0xc0u) == 0x80u) {
length -= 1;
}
}
return length;
}
static int trace_callout(pcre2_callout_block *block, void *data) {
regex_pcre2_trace_state *state = (regex_pcre2_trace_state *)data;
regex_pcre2_trace_event *event = NULL;
uint32_t mark_length = 0;
uint32_t mark_was_truncated = 0;
uint64_t required_trace_bytes = 0;
if (block == NULL || state == NULL) {
return PCRE2_ERROR_CALLOUT;
}
if (state->total_event_count < UINT32_MAX) {
state->total_event_count += 1;
}
mark_length = bounded_mark_length(block->mark, &mark_was_truncated);
required_trace_bytes =
((uint64_t)state->event_count + 1u) *
sizeof(regex_pcre2_trace_event) +
state->mark_bytes_length + mark_length;
if (state->event_count >= state->maximum_events ||
state->event_count >= state->event_capacity ||
required_trace_bytes > state->maximum_trace_bytes ||
mark_length > state->mark_bytes_capacity - state->mark_bytes_length) {
state->events_truncated = 1;
return PCRE2_ERROR_CALLOUT;
}
event = &state->events[state->event_count];
event->callout_number = block->callout_number;
event->pattern_position_byte = bounded_offset(block->pattern_position);
event->next_item_length_byte = bounded_offset(block->next_item_length);
event->subject_position_byte = bounded_offset(block->current_position);
event->capture_top = block->capture_top;
event->capture_last = block->capture_last;
event->mark_offset = state->mark_bytes_length;
event->mark_length = mark_length;
if (mark_length > 0) {
memcpy(state->mark_bytes + state->mark_bytes_length, block->mark,
mark_length);
state->mark_bytes_length += mark_length;
}
state->marks_truncated |= mark_was_truncated;
state->event_count += 1;
return 0;
}
static int32_t run_bounded(const uint8_t *pattern,
uint32_t pattern_length,
const uint8_t *subject,
uint32_t subject_length,
const uint8_t *replacement,
uint32_t replacement_length,
uint32_t application_flags,
const regex_pcre2_limits *limits,
regex_pcre2_run_buffers *buffers,
int substitution,
regex_pcre2_run_result *result) {
pcre2_code *code = NULL;
pcre2_match_data *match_data = NULL;
pcre2_match_context *match_context = NULL;
int compile_error = 0;
PCRE2_SIZE compile_offset = 0;
uint32_t capture_count = 0;
uint32_t name_count = 0;
uint32_t options = 0;
uint32_t search_offset = 0;
uint32_t match_options = 0;
uint32_t copied_until = 0;
uint32_t capture_rows = 0;
int32_t status = 0;
const uint8_t *safe_pattern = nonnull_input(pattern);
const uint8_t *safe_subject = nonnull_input(subject);
const uint8_t *safe_replacement = nonnull_input(replacement);
if (result == NULL || !input_pointer_valid(pattern, pattern_length) ||
!input_pointer_valid(subject, subject_length) ||
(substitution &&
!input_pointer_valid(replacement, replacement_length)) ||
!buffers_valid(buffers, substitution)) {
return fail_run(result, REGEX_PCRE2_ERROR_INVALID_ARGUMENT,
REGEX_PCRE2_PHASE_BRIDGE, 0);
}
memset(result, 0, sizeof(*result));
if ((application_flags & ~REGEX_PCRE2_ALL_FLAGS) != 0) {
return fail_run(result, REGEX_PCRE2_ERROR_UNSUPPORTED_FLAGS,
REGEX_PCRE2_PHASE_BRIDGE, 0);
}
if (pattern_length > REGEX_PCRE2_MAX_PATTERN_BYTES) {
return fail_run(result, REGEX_PCRE2_ERROR_PATTERN_TOO_LARGE,
REGEX_PCRE2_PHASE_BRIDGE, 0);
}
if (subject_length > REGEX_PCRE2_MAX_SUBJECT_BYTES) {
return fail_run(result, REGEX_PCRE2_ERROR_SUBJECT_TOO_LARGE,
REGEX_PCRE2_PHASE_BRIDGE, 0);
}
if (substitution &&
replacement_length > REGEX_PCRE2_MAX_REPLACEMENT_BYTES) {
return fail_run(result, REGEX_PCRE2_ERROR_REPLACEMENT_TOO_LARGE,
REGEX_PCRE2_PHASE_BRIDGE, 0);
}
if (!limits_valid(limits)) {
return fail_run(result, REGEX_PCRE2_ERROR_LIMIT_OUT_OF_RANGE,
REGEX_PCRE2_PHASE_BRIDGE, 0);
}
options = to_pcre2_options(application_flags);
result->effective_options = options;
code = pcre2_compile(safe_pattern, pattern_length, options, &compile_error,
&compile_offset, NULL);
if (code == NULL) {
status = compile_error;
result->status = status;
result->error_phase = REGEX_PCRE2_PHASE_COMPILE;
result->error_offset = bounded_offset(compile_offset);
goto cleanup;
}
if (pcre2_pattern_info(code, PCRE2_INFO_CAPTURECOUNT, &capture_count) != 0 ||
pcre2_pattern_info(code, PCRE2_INFO_NAMECOUNT, &name_count) != 0) {
status = REGEX_PCRE2_ERROR_CONFIG;
result->status = status;
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
goto cleanup;
}
result->capture_count = capture_count;
result->name_count = name_count;
if (capture_count > REGEX_PCRE2_MAX_CAPTURE_GROUPS) {
status = REGEX_PCRE2_ERROR_CAPTURE_LIMIT;
result->status = status;
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
goto cleanup;
}
status = copy_names(code, buffers, result);
if (status != 0) {
result->status = status;
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
goto cleanup;
}
match_data = pcre2_match_data_create_from_pattern(code, NULL);
match_context = pcre2_match_context_create(NULL);
if (match_data == NULL || match_context == NULL) {
status = PCRE2_ERROR_NOMEMORY;
result->status = status;
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
goto cleanup;
}
status = configure_match_context(match_context, limits);
if (status != 0) {
result->status = status;
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
goto cleanup;
}
while (search_offset <= subject_length) {
int match_status =
pcre2_match(code, safe_subject, subject_length, search_offset,
match_options, match_data, match_context);
PCRE2_SIZE *ovector = NULL;
uint32_t required_records = capture_count + 1;
uint32_t required_capture_rows = capture_count == 0 ? 1 : capture_count;
uint32_t group = 0;
uint32_t start = 0;
uint32_t end = 0;
if (match_status == PCRE2_ERROR_NOMATCH) {
break;
}
if (match_status < 0) {
status = match_status;
result->status = status;
result->error_phase = REGEX_PCRE2_PHASE_MATCH;
result->error_offset = search_offset;
goto cleanup;
}
if (match_status == 0) {
status = REGEX_PCRE2_ERROR_CONFIG;
result->status = status;
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
goto cleanup;
}
ovector = pcre2_get_ovector_pointer(match_data);
start = bounded_offset(ovector[0]);
end = bounded_offset(ovector[1]);
if (start > end || end > subject_length) {
status = REGEX_PCRE2_ERROR_CONFIG;
result->status = status;
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
goto cleanup;
}
if (result->match_count >= limits->maximum_matches ||
capture_rows + required_capture_rows >
limits->maximum_capture_rows ||
required_records > buffers->record_capacity - result->record_count) {
result->results_truncated = 1;
break;
}
for (group = 0; group <= capture_count; group += 1) {
regex_pcre2_match_record *record =
&buffers->records[result->record_count];
PCRE2_SIZE native_start = ovector[group * 2];
PCRE2_SIZE native_end = ovector[group * 2 + 1];
record->match_number = result->match_count + 1;
record->group_number = group;
if (native_start == PCRE2_UNSET || native_end == PCRE2_UNSET) {
record->start_byte = REGEX_PCRE2_UNSET_OFFSET;
record->end_byte = REGEX_PCRE2_UNSET_OFFSET;
} else {
record->start_byte = bounded_offset(native_start);
record->end_byte = bounded_offset(native_end);
}
result->record_count += 1;
}
result->match_count += 1;
capture_rows += required_capture_rows;
if (substitution && result->output_truncated == 0) {
PCRE2_SIZE replacement_output_length =
buffers->output_capacity - result->output_length + 1;
int substitute_status = 0;
if (!append_utf8_prefix(buffers, result, safe_subject + copied_until,
start - copied_until)) {
break;
}
replacement_output_length =
buffers->output_capacity - result->output_length + 1;
substitute_status = pcre2_substitute(
code, safe_subject, subject_length, search_offset,
match_options | PCRE2_SUBSTITUTE_MATCHED |
PCRE2_SUBSTITUTE_REPLACEMENT_ONLY |
PCRE2_SUBSTITUTE_UNSET_EMPTY,
match_data, match_context, safe_replacement, replacement_length,
buffers->output + result->output_length, &replacement_output_length);
if (substitute_status == PCRE2_ERROR_NOMEMORY) {
result->output_truncated = 1;
break;
}
if (substitute_status < 0) {
status = substitute_status;
result->status = status;
result->error_phase = REGEX_PCRE2_PHASE_SUBSTITUTE;
result->error_offset = bounded_offset(replacement_output_length);
goto cleanup;
}
result->output_length += bounded_offset(replacement_output_length);
result->substitution_count += (uint32_t)substitute_status;
copied_until = end;
}
if ((application_flags & REGEX_PCRE2_GLOBAL) == 0) {
break;
}
{
PCRE2_SIZE next_offset = search_offset;
uint32_t next_options = 0;
if (!pcre2_next_match(match_data, &next_offset, &next_options)) {
break;
}
search_offset = bounded_offset(next_offset);
match_options = next_options;
}
}
if (substitution && result->output_truncated == 0) {
(void)append_utf8_prefix(buffers, result, safe_subject + copied_until,
subject_length - copied_until);
}
cleanup:
if (match_context != NULL) {
pcre2_match_context_free(match_context);
}
if (match_data != NULL) {
pcre2_match_data_free(match_data);
}
if (code != NULL) {
pcre2_code_free(code);
}
return status;
}
uint32_t regex_pcre2_bridge_abi_version(void) {
return REGEX_PCRE2_BRIDGE_ABI_VERSION;
}
uint32_t regex_pcre2_config_flags(void) {
uint32_t unicode = 0;
uint32_t jit = 0;
uint32_t flags = 0;
if (pcre2_config(PCRE2_CONFIG_UNICODE, &unicode) != 0 ||
pcre2_config(PCRE2_CONFIG_JIT, &jit) != 0) {
return 0;
}
if (unicode != 0) {
flags |= REGEX_PCRE2_CONFIG_UNICODE;
}
if (jit != 0) {
flags |= REGEX_PCRE2_CONFIG_JIT;
}
return flags;
}
const uint8_t *regex_pcre2_version(void) {
static uint8_t version[64];
if (pcre2_config(PCRE2_CONFIG_VERSION, version) <= 0) {
version[0] = '\0';
}
return version;
}
int32_t regex_pcre2_error_message(int32_t error_code,
uint8_t *output,
uint32_t output_capacity) {
int status = 0;
if (output == NULL || output_capacity == 0) {
return REGEX_PCRE2_ERROR_INVALID_ARGUMENT;
}
status = pcre2_get_error_message(error_code, output, output_capacity);
if (status < 0) {
output[0] = '\0';
}
return status;
}
int32_t regex_pcre2_compile_probe(const uint8_t *pattern,
uint32_t pattern_length,
uint32_t application_flags,
regex_pcre2_compile_result *result) {
pcre2_code *code = NULL;
int error_code = 0;
PCRE2_SIZE error_offset = 0;
uint32_t capture_count = 0;
uint32_t name_count = 0;
uint32_t options = 0;
if (result == NULL || !input_pointer_valid(pattern, pattern_length)) {
return fail_compile(result, REGEX_PCRE2_ERROR_INVALID_ARGUMENT);
}
memset(result, 0, sizeof(*result));
if ((application_flags & ~REGEX_PCRE2_COMPILE_FLAGS) != 0) {
return fail_compile(result, REGEX_PCRE2_ERROR_UNSUPPORTED_FLAGS);
}
if (pattern_length > REGEX_PCRE2_MAX_PATTERN_BYTES) {
return fail_compile(result, REGEX_PCRE2_ERROR_PATTERN_TOO_LARGE);
}
options = to_pcre2_options(application_flags);
result->effective_options = options;
code = pcre2_compile(nonnull_input(pattern), pattern_length, options,
&error_code, &error_offset, NULL);
if (code == NULL) {
result->status = error_code;
result->error_offset = bounded_offset(error_offset);
return error_code;
}
if (pcre2_pattern_info(code, PCRE2_INFO_CAPTURECOUNT, &capture_count) != 0 ||
pcre2_pattern_info(code, PCRE2_INFO_NAMECOUNT, &name_count) != 0) {
pcre2_code_free(code);
return fail_compile(result, REGEX_PCRE2_ERROR_CONFIG);
}
result->capture_count = capture_count;
result->name_count = name_count;
pcre2_code_free(code);
return 0;
}
int32_t regex_pcre2_execute(
const uint8_t *pattern,
uint32_t pattern_length,
const uint8_t *subject,
uint32_t subject_length,
uint32_t application_flags,
const regex_pcre2_limits *limits,
regex_pcre2_match_record *records,
uint32_t record_capacity,
regex_pcre2_name_record *name_records,
uint32_t name_record_capacity,
uint8_t *name_bytes,
uint32_t name_bytes_capacity,
regex_pcre2_run_result *result) {
regex_pcre2_run_buffers buffers = {
records, record_capacity, name_records, name_record_capacity,
name_bytes, name_bytes_capacity, NULL, 0};
return run_bounded(pattern, pattern_length, subject, subject_length, NULL, 0,
application_flags, limits, &buffers, 0, result);
}
int32_t regex_pcre2_substitute(
const uint8_t *pattern,
uint32_t pattern_length,
const uint8_t *subject,
uint32_t subject_length,
const uint8_t *replacement,
uint32_t replacement_length,
uint32_t application_flags,
const regex_pcre2_limits *limits,
regex_pcre2_match_record *records,
uint32_t record_capacity,
regex_pcre2_name_record *name_records,
uint32_t name_record_capacity,
uint8_t *name_bytes,
uint32_t name_bytes_capacity,
uint8_t *output,
uint32_t output_capacity,
regex_pcre2_run_result *result) {
regex_pcre2_run_buffers buffers = {
records, record_capacity, name_records, name_record_capacity,
name_bytes, name_bytes_capacity, output, output_capacity};
return run_bounded(pattern, pattern_length, subject, subject_length,
replacement, replacement_length, application_flags,
limits, &buffers, 1, result);
}
int32_t regex_pcre2_trace(
const uint8_t *pattern,
uint32_t pattern_length,
const uint8_t *subject,
uint32_t subject_length,
uint32_t application_flags,
const regex_pcre2_trace_limits *limits,
regex_pcre2_trace_event *events,
uint32_t event_capacity,
uint8_t *mark_bytes,
uint32_t mark_bytes_capacity,
regex_pcre2_trace_result *result) {
pcre2_code *code = NULL;
pcre2_match_data *match_data = NULL;
pcre2_match_context *match_context = NULL;
regex_pcre2_trace_state state;
int compile_error = 0;
PCRE2_SIZE compile_offset = 0;
uint32_t capture_count = 0;
uint32_t options = 0;
int32_t match_status = 0;
int32_t status = 0;
if (result == NULL || !input_pointer_valid(pattern, pattern_length) ||
!input_pointer_valid(subject, subject_length) || events == NULL ||
event_capacity == 0 ||
(mark_bytes == NULL && mark_bytes_capacity != 0)) {
return fail_trace(result, REGEX_PCRE2_ERROR_INVALID_ARGUMENT,
REGEX_PCRE2_PHASE_BRIDGE, 0);
}
memset(result, 0, sizeof(*result));
result->last_complete_event = UINT32_MAX;
if ((application_flags & ~REGEX_PCRE2_ALL_FLAGS) != 0) {
return fail_trace(result, REGEX_PCRE2_ERROR_UNSUPPORTED_FLAGS,
REGEX_PCRE2_PHASE_BRIDGE, 0);
}
if (pattern_length > REGEX_PCRE2_MAX_PATTERN_BYTES) {
return fail_trace(result, REGEX_PCRE2_ERROR_PATTERN_TOO_LARGE,
REGEX_PCRE2_PHASE_BRIDGE, 0);
}
if (subject_length > REGEX_PCRE2_MAX_SUBJECT_BYTES) {
return fail_trace(result, REGEX_PCRE2_ERROR_SUBJECT_TOO_LARGE,
REGEX_PCRE2_PHASE_BRIDGE, 0);
}
if (!trace_limits_valid(limits) ||
event_capacity < limits->maximum_events ||
event_capacity > REGEX_PCRE2_MAX_TRACE_EVENTS ||
mark_bytes_capacity > REGEX_PCRE2_MAX_TRACE_BYTES) {
return fail_trace(result, REGEX_PCRE2_ERROR_LIMIT_OUT_OF_RANGE,
REGEX_PCRE2_PHASE_BRIDGE, 0);
}
options = to_pcre2_options(application_flags) | PCRE2_AUTO_CALLOUT;
result->effective_options = options;
code = pcre2_compile(nonnull_input(pattern), pattern_length, options,
&compile_error, &compile_offset, NULL);
if (code == NULL) {
status = compile_error;
result->status = status;
result->error_phase = REGEX_PCRE2_PHASE_COMPILE;
result->error_offset = bounded_offset(compile_offset);
goto cleanup;
}
if (pcre2_pattern_info(code, PCRE2_INFO_CAPTURECOUNT, &capture_count) != 0) {
status = REGEX_PCRE2_ERROR_CONFIG;
result->status = status;
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
goto cleanup;
}
if (capture_count > REGEX_PCRE2_MAX_CAPTURE_GROUPS) {
status = REGEX_PCRE2_ERROR_CAPTURE_LIMIT;
result->status = status;
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
goto cleanup;
}
match_data = pcre2_match_data_create_from_pattern(code, NULL);
match_context = pcre2_match_context_create(NULL);
if (match_data == NULL || match_context == NULL) {
status = PCRE2_ERROR_NOMEMORY;
result->status = status;
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
goto cleanup;
}
memset(&state, 0, sizeof(state));
state.events = events;
state.event_capacity = event_capacity;
state.mark_bytes = mark_bytes;
state.mark_bytes_capacity = mark_bytes_capacity;
state.maximum_events = limits->maximum_events;
state.maximum_trace_bytes = limits->maximum_trace_bytes;
status = configure_match_context_values(
match_context, limits->match_limit, limits->depth_limit,
limits->heap_limit_kib);
if (status != 0 ||
pcre2_set_callout(match_context, trace_callout, &state) != 0) {
status = REGEX_PCRE2_ERROR_CONFIG;
result->status = status;
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
goto cleanup;
}
/*
* The global flag is intentionally application-level for normal execution.
* A trace represents one exact pcre2_match() invocation.
*/
match_status = pcre2_match(code, nonnull_input(subject), subject_length, 0, 0,
match_data, match_context);
result->native_match_status = match_status;
result->event_count = state.event_count;
result->total_event_count = state.total_event_count;
result->mark_bytes_length = state.mark_bytes_length;
result->trace_bytes_length =
state.event_count * (uint32_t)sizeof(regex_pcre2_trace_event) +
state.mark_bytes_length;
result->events_truncated = state.events_truncated;
result->marks_truncated = state.marks_truncated;
result->last_complete_event =
state.event_count == 0 ? UINT32_MAX : state.event_count - 1;
if (match_status >= 0) {
result->matched = 1;
} else if (match_status == PCRE2_ERROR_NOMATCH ||
(match_status == PCRE2_ERROR_CALLOUT &&
state.events_truncated != 0)) {
status = 0;
} else {
status = match_status;
result->status = status;
result->error_phase = REGEX_PCRE2_PHASE_MATCH;
if (state.event_count > 0) {
result->error_offset =
events[state.event_count - 1].subject_position_byte;
}
}
cleanup:
if (match_context != NULL) {
pcre2_match_context_free(match_context);
}
if (match_data != NULL) {
pcre2_match_data_free(match_data);
}
if (code != NULL) {
pcre2_code_free(code);
}
return status;
}
int32_t regex_pcre2_self_test(void) {
static const uint8_t pattern[] = "(?<word>\\p{L}+)-(?<number>\\d+)";
static const uint8_t subject[] = "x Grüße-42 y";
static const uint8_t replacement[] = "$<number>:$<word>";
static const uint8_t trace_pattern[] = "a+b";
static const uint8_t trace_subject[] = "aaab";
regex_pcre2_compile_result compile_result;
regex_pcre2_run_result run_result;
regex_pcre2_trace_result trace_result;
regex_pcre2_limits limits = {10, 100, 1000000, 1000, 32768};
regex_pcre2_trace_limits trace_limits = {100, 4096, 1000000, 1000,
32768};
regex_pcre2_match_record records[3];
regex_pcre2_name_record names[2];
regex_pcre2_trace_event trace_events[100];
uint8_t name_bytes[64];
uint8_t trace_marks[64];
uint8_t output[65];
int32_t status = regex_pcre2_compile_probe(
pattern, (uint32_t)(sizeof(pattern) - 1),
REGEX_PCRE2_UTF | REGEX_PCRE2_UCP, &compile_result);
if (status != 0 || compile_result.capture_count != 2 ||
compile_result.name_count != 2) {
return 1;
}
status = regex_pcre2_substitute(
pattern, (uint32_t)(sizeof(pattern) - 1), subject,
(uint32_t)(sizeof(subject) - 1), replacement,
(uint32_t)(sizeof(replacement) - 1),
REGEX_PCRE2_UTF | REGEX_PCRE2_UCP, &limits, records, 3, names, 2,
name_bytes, sizeof(name_bytes), output, sizeof(output) - 1, &run_result);
if (status != 0 || run_result.match_count != 1 ||
run_result.record_count != 3 || run_result.substitution_count != 1 ||
run_result.output_truncated != 0 ||
run_result.output_length != sizeof("x 42:Grüße y") - 1 ||
memcmp(output, "x 42:Grüße y", sizeof("x 42:Grüße y") - 1) != 0) {
return 2;
}
status = regex_pcre2_trace(
trace_pattern, (uint32_t)(sizeof(trace_pattern) - 1), trace_subject,
(uint32_t)(sizeof(trace_subject) - 1),
REGEX_PCRE2_UTF | REGEX_PCRE2_UCP, &trace_limits, trace_events, 100,
trace_marks, sizeof(trace_marks), &trace_result);
if (status != 0 || trace_result.status != 0 ||
trace_result.native_match_status <= 0 || trace_result.matched != 1 ||
trace_result.event_count == 0 || trace_result.events_truncated != 0 ||
trace_result.last_complete_event != trace_result.event_count - 1) {
return 3;
}
if (regex_pcre2_bridge_abi_version() != REGEX_PCRE2_BRIDGE_ABI_VERSION ||
regex_pcre2_config_flags() != REGEX_PCRE2_CONFIG_UNICODE) {
return 4;
}
if (strcmp((const char *)regex_pcre2_version(), "10.47 2025-10-21") != 0) {
return 5;
}
return 0;
}

View File

@@ -0,0 +1,236 @@
/* SPDX-License-Identifier: GPL-3.0-or-later */
#ifndef REGEX_TOOLS_PCRE2_BRIDGE_H
#define REGEX_TOOLS_PCRE2_BRIDGE_H
#include <stdint.h>
#define REGEX_PCRE2_BRIDGE_ABI_VERSION 3u
#define REGEX_PCRE2_MAX_PATTERN_BYTES 1048576u
#define REGEX_PCRE2_MAX_SUBJECT_BYTES 16777216u
#define REGEX_PCRE2_MAX_REPLACEMENT_BYTES 262144u
#define REGEX_PCRE2_MAX_MATCHES 10000u
#define REGEX_PCRE2_MAX_CAPTURE_ROWS 100000u
#define REGEX_PCRE2_MAX_CAPTURE_GROUPS 1000u
#define REGEX_PCRE2_MAX_MATCH_LIMIT 100000000u
#define REGEX_PCRE2_MAX_DEPTH_LIMIT 100000u
#define REGEX_PCRE2_MAX_HEAP_LIMIT_KIB 131072u
#define REGEX_PCRE2_MAX_TRACE_EVENTS 50000u
#define REGEX_PCRE2_MAX_TRACE_BYTES 10485760u
#define REGEX_PCRE2_MAX_TRACE_MARK_BYTES 1024u
#define REGEX_PCRE2_UNSET_OFFSET UINT32_MAX
enum regex_pcre2_flag {
REGEX_PCRE2_CASELESS = 1u << 0,
REGEX_PCRE2_MULTILINE = 1u << 1,
REGEX_PCRE2_DOTALL = 1u << 2,
REGEX_PCRE2_EXTENDED = 1u << 3,
REGEX_PCRE2_UNGREEDY = 1u << 4,
REGEX_PCRE2_UTF = 1u << 5,
REGEX_PCRE2_UCP = 1u << 6,
REGEX_PCRE2_DUPNAMES = 1u << 7,
REGEX_PCRE2_GLOBAL = 1u << 8
};
enum regex_pcre2_config_flag {
REGEX_PCRE2_CONFIG_UNICODE = 1u << 0,
REGEX_PCRE2_CONFIG_JIT = 1u << 1
};
enum regex_pcre2_bridge_error {
REGEX_PCRE2_ERROR_INVALID_ARGUMENT = -10001,
REGEX_PCRE2_ERROR_UNSUPPORTED_FLAGS = -10002,
REGEX_PCRE2_ERROR_PATTERN_TOO_LARGE = -10003,
REGEX_PCRE2_ERROR_CONFIG = -10004,
REGEX_PCRE2_ERROR_SUBJECT_TOO_LARGE = -10005,
REGEX_PCRE2_ERROR_REPLACEMENT_TOO_LARGE = -10006,
REGEX_PCRE2_ERROR_LIMIT_OUT_OF_RANGE = -10007,
REGEX_PCRE2_ERROR_CAPTURE_LIMIT = -10008,
REGEX_PCRE2_ERROR_OUTPUT_BUFFER = -10009
};
enum regex_pcre2_error_phase {
REGEX_PCRE2_PHASE_NONE = 0,
REGEX_PCRE2_PHASE_BRIDGE = 1,
REGEX_PCRE2_PHASE_COMPILE = 2,
REGEX_PCRE2_PHASE_MATCH = 3,
REGEX_PCRE2_PHASE_SUBSTITUTE = 4
};
/*
* This 20-byte, five-u32 record is retained for deterministic compile-only
* build verification. Runtime callers should use the bounded APIs below.
*/
typedef struct regex_pcre2_compile_result {
int32_t status;
uint32_t error_offset;
uint32_t capture_count;
uint32_t name_count;
uint32_t effective_options;
} regex_pcre2_compile_result;
/* All values are required and validated against the bridge hard maxima. */
typedef struct regex_pcre2_limits {
uint32_t maximum_matches;
uint32_t maximum_capture_rows;
uint32_t match_limit;
uint32_t depth_limit;
uint32_t heap_limit_kib;
} regex_pcre2_limits;
/*
* One record is emitted for group zero and every capture group of each
* retained match. Unset captures use REGEX_PCRE2_UNSET_OFFSET for both bounds.
*/
typedef struct regex_pcre2_match_record {
uint32_t match_number;
uint32_t group_number;
uint32_t start_byte;
uint32_t end_byte;
} regex_pcre2_match_record;
/* Names are UTF-8 slices into the caller-owned name byte buffer. */
typedef struct regex_pcre2_name_record {
uint32_t group_number;
uint32_t name_offset;
uint32_t name_length;
} regex_pcre2_name_record;
/*
* Trace collection has an independent bound from match materialization. The
* byte cap covers the fixed event records plus copied mark bytes.
*/
typedef struct regex_pcre2_trace_limits {
uint32_t maximum_events;
uint32_t maximum_trace_bytes;
uint32_t match_limit;
uint32_t depth_limit;
uint32_t heap_limit_kib;
} regex_pcre2_trace_limits;
/*
* Every field except the mark slice is copied directly from a PCRE2 callout
* block. Positions and lengths are offsets in the 8-bit UTF-8 pattern or
* subject supplied to the engine.
*/
typedef struct regex_pcre2_trace_event {
uint32_t callout_number;
uint32_t pattern_position_byte;
uint32_t next_item_length_byte;
uint32_t subject_position_byte;
uint32_t capture_top;
uint32_t capture_last;
uint32_t mark_offset;
uint32_t mark_length;
} regex_pcre2_trace_event;
/*
* A zero status means that a bounded trace was returned. native_match_status
* retains pcre2_match()'s exact result, including PCRE2_ERROR_CALLOUT when the
* bridge deliberately stopped at a trace cap. last_complete_event is
* UINT32_MAX when no complete event was copied.
*/
typedef struct regex_pcre2_trace_result {
int32_t status;
uint32_t error_phase;
uint32_t error_offset;
int32_t native_match_status;
uint32_t effective_options;
uint32_t event_count;
uint32_t total_event_count;
uint32_t mark_bytes_length;
uint32_t trace_bytes_length;
uint32_t events_truncated;
uint32_t marks_truncated;
uint32_t last_complete_event;
uint32_t matched;
} regex_pcre2_trace_result;
/*
* This fixed 60-byte record contains no native pointers. `status` is zero on
* success. Otherwise `error_phase` distinguishes bridge validation, compile,
* match and replacement errors; `error_offset` is a UTF-8 byte offset when the
* phase provides one.
*/
typedef struct regex_pcre2_run_result {
int32_t status;
uint32_t error_phase;
uint32_t error_offset;
uint32_t capture_count;
uint32_t name_count;
uint32_t match_count;
uint32_t record_count;
uint32_t name_record_count;
uint32_t name_bytes_length;
uint32_t effective_options;
uint32_t results_truncated;
uint32_t names_truncated;
uint32_t output_length;
uint32_t substitution_count;
uint32_t output_truncated;
} regex_pcre2_run_result;
uint32_t regex_pcre2_bridge_abi_version(void);
uint32_t regex_pcre2_config_flags(void);
const uint8_t *regex_pcre2_version(void);
int32_t regex_pcre2_error_message(int32_t error_code,
uint8_t *output,
uint32_t output_capacity);
int32_t regex_pcre2_compile_probe(const uint8_t *pattern,
uint32_t pattern_length,
uint32_t application_flags,
regex_pcre2_compile_result *result);
int32_t regex_pcre2_execute(
const uint8_t *pattern,
uint32_t pattern_length,
const uint8_t *subject,
uint32_t subject_length,
uint32_t application_flags,
const regex_pcre2_limits *limits,
regex_pcre2_match_record *records,
uint32_t record_capacity,
regex_pcre2_name_record *name_records,
uint32_t name_record_capacity,
uint8_t *name_bytes,
uint32_t name_bytes_capacity,
regex_pcre2_run_result *result);
int32_t regex_pcre2_substitute(
const uint8_t *pattern,
uint32_t pattern_length,
const uint8_t *subject,
uint32_t subject_length,
const uint8_t *replacement,
uint32_t replacement_length,
uint32_t application_flags,
const regex_pcre2_limits *limits,
regex_pcre2_match_record *records,
uint32_t record_capacity,
regex_pcre2_name_record *name_records,
uint32_t name_record_capacity,
uint8_t *name_bytes,
uint32_t name_bytes_capacity,
/* `output` must have output_capacity + 1 writable bytes for PCRE2's NUL. */
uint8_t *output,
uint32_t output_capacity,
regex_pcre2_run_result *result);
int32_t regex_pcre2_trace(
const uint8_t *pattern,
uint32_t pattern_length,
const uint8_t *subject,
uint32_t subject_length,
uint32_t application_flags,
const regex_pcre2_trace_limits *limits,
regex_pcre2_trace_event *events,
uint32_t event_capacity,
uint8_t *mark_bytes,
uint32_t mark_bytes_capacity,
regex_pcre2_trace_result *result);
int32_t regex_pcre2_self_test(void);
#endif

View File

@@ -12,6 +12,7 @@ export default tseslint.config(
"coverage", "coverage",
"test-results", "test-results",
"playwright-report", "playwright-report",
".engine-build",
], ],
}, },
{ {

7
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "regex-tools", "name": "regex-tools",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "regex-tools", "name": "regex-tools",
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.2", "@add-ideas/toolbox-contract": "0.2.2",
@@ -18,6 +18,7 @@
"@codemirror/view": "6.43.6", "@codemirror/view": "6.43.6",
"@eslint-community/regexpp": "4.12.2", "@eslint-community/regexpp": "4.12.2",
"@lezer/highlight": "1.2.3", "@lezer/highlight": "1.2.3",
"fflate": "0.8.3",
"react": "19.2.7", "react": "19.2.7",
"react-dom": "19.2.7" "react-dom": "19.2.7"
}, },
@@ -35,7 +36,6 @@
"eslint": "10.7.0", "eslint": "10.7.0",
"eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.2", "eslint-plugin-react-refresh": "0.5.2",
"fflate": "0.8.3",
"globals": "17.7.0", "globals": "17.7.0",
"jsdom": "29.1.1", "jsdom": "29.1.1",
"prettier": "3.9.5", "prettier": "3.9.5",
@@ -2442,7 +2442,6 @@
"version": "0.8.3", "version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "regex-tools", "name": "regex-tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Develop, explain, test and apply regular expressions locally in the browser.", "description": "Develop, explain, test and apply regular expressions locally in the browser.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
@@ -32,6 +32,9 @@
"test:conformance": "vitest run tests/conformance", "test:conformance": "vitest run tests/conformance",
"test:browser": "playwright test", "test:browser": "playwright test",
"engines:build": "node scripts/build-engines.mjs", "engines:build": "node scripts/build-engines.mjs",
"engines:pcre2:build": "node scripts/build-engines.mjs --pcre2",
"engines:pcre2:install": "node scripts/install-pcre2-engine.mjs",
"engines:pcre2:verify": "node scripts/verify-engine-assets.mjs --pcre2-pack .engine-build/pcre2",
"engines:verify": "node scripts/verify-engine-assets.mjs", "engines:verify": "node scripts/verify-engine-assets.mjs",
"manifest:generate": "node scripts/generate-toolbox-manifest.mjs", "manifest:generate": "node scripts/generate-toolbox-manifest.mjs",
"manifest:check": "node scripts/generate-toolbox-manifest.mjs --check", "manifest:check": "node scripts/generate-toolbox-manifest.mjs --check",
@@ -51,6 +54,7 @@
"@codemirror/view": "6.43.6", "@codemirror/view": "6.43.6",
"@eslint-community/regexpp": "4.12.2", "@eslint-community/regexpp": "4.12.2",
"@lezer/highlight": "1.2.3", "@lezer/highlight": "1.2.3",
"fflate": "0.8.3",
"react": "19.2.7", "react": "19.2.7",
"react-dom": "19.2.7" "react-dom": "19.2.7"
}, },
@@ -68,7 +72,6 @@
"eslint": "10.7.0", "eslint": "10.7.0",
"eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.2", "eslint-plugin-react-refresh": "0.5.2",
"fflate": "0.8.3",
"globals": "17.7.0", "globals": "17.7.0",
"jsdom": "29.1.1", "jsdom": "29.1.1",
"prettier": "3.9.5", "prettier": "3.9.5",

View File

@@ -1,7 +1,9 @@
# Engine assets # Engine assets
Regex Tools 0.1.0 executes ECMAScript through the browser's native `RegExp` Regex Tools executes ECMAScript through the browser's native `RegExp` engine.
engine. It therefore ships no external engine binary or WebAssembly pack. It also ships the declared, self-hosted PCRE2 10.47 WebAssembly pack in
`pcre2/`.
Future flavour packs will be built from pinned, documented open-source The PCRE2 pack is built from pinned, documented open-source code and
revisions and placed in versioned subdirectories here. toolchain revisions. `engine-metadata.json`, `SHA256SUMS` and `LICENSE.txt`
travel with the runtime files and are verified by the release gate.

View File

@@ -0,0 +1,104 @@
PCRE2 Licence
=============
| SPDX-License-Identifier: | BSD-3-Clause WITH PCRE2-exception |
|---------|-------|
PCRE2 is a library of functions to support regular expressions whose syntax
and semantics are as close as possible to those of the Perl 5 language.
Releases 10.00 and above of PCRE2 are distributed under the terms of the "BSD"
licence, as specified below, with one exemption for certain binary
redistributions. The documentation for PCRE2, supplied in the "doc" directory,
is distributed under the same terms as the software itself. The data in the
testdata directory is not copyrighted and is in the public domain.
The basic library functions are written in C and are freestanding. Also
included in the distribution is a just-in-time compiler that can be used to
optimize pattern matching. This is an optional feature that can be omitted when
the library is built. The just-in-time compiler is separately licensed under the
"2-clause BSD" licence.
COPYRIGHT
---------
### The basic library functions
Written by: Philip Hazel
Email local part: Philip.Hazel
Email domain: gmail.com
Retired from University of Cambridge Computing Service,
Cambridge, England.
Copyright (c) 1997-2007 University of Cambridge
Copyright (c) 2007-2024 Philip Hazel
All rights reserved.
### PCRE2 Just-In-Time compilation support
Written by: Zoltan Herczeg
Email local part: hzmester
Email domain: freemail.hu
Copyright (c) 2010-2024 Zoltan Herczeg
All rights reserved.
### Stack-less Just-In-Time compiler
Written by: Zoltan Herczeg
Email local part: hzmester
Email domain: freemail.hu
Copyright (c) 2009-2024 Zoltan Herczeg
All rights reserved.
The code in the `deps/sljit` directory has its own LICENSE file.
### All other contributions
Many other contributors have participated in the authorship of PCRE2. As PCRE2
has never required a Contributor Licensing Agreement, or other copyright
assignment agreement, all contributions have copyright retained by each
original contributor or their employer.
THE "BSD" LICENCE
-----------------
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notices,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notices, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of the University of Cambridge nor the names of any
contributors may be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
EXEMPTION FOR BINARY LIBRARY-LIKE PACKAGES
------------------------------------------
The second condition in the BSD licence (covering binary redistributions) does
not apply all the way down a chain of software. If binary package A includes
PCRE2, it must respect the condition, but if package B is software that
includes package A, the condition is not imposed on package B unless it uses
PCRE2 independently.

View File

@@ -0,0 +1,4 @@
197d8a73ffee0d6b09adba2f9c677b5f5aede24edf89258a68e48248d010d811 LICENSE.txt
8c7f67ad85893fcc545c40d496abcc82d1009b76c9b7bcc7bd9ab92828852d76 engine-metadata.json
d31a5f1e0839955ae8321430eafe65da8cbdef832a1245bb71ccc5bb0d4b6b9c pcre2.mjs
cc7c4b2b0038e137c86d2037e73f2302e56432cb09bc082e4f35bba221dd1889 pcre2.wasm

View File

@@ -0,0 +1,90 @@
{
"schemaVersion": 1,
"engine": "pcre2",
"engineVersion": "10.47 2025-10-21",
"status": "production-runtime",
"bridge": {
"abiVersion": 3,
"maximumPatternBytes": 1048576,
"maximumSubjectBytes": 16777216,
"maximumReplacementBytes": 262144,
"maximumMatches": 10000,
"maximumCaptureRows": 100000,
"maximumCaptureGroups": 1000,
"maximumTraceEvents": 50000,
"maximumTraceBytes": 10485760,
"maximumTraceMarkBytes": 1024,
"sourceFiles": [
{
"path": "engines/pcre2/CMakeLists.txt",
"sha256": "670397b070a908b414d4d8ffa7c0a9ed2549b9ee27815a90de5fc9622c6df8fa",
"bytes": 2583
},
{
"path": "engines/pcre2/pcre2_bridge.c",
"sha256": "ab5929472d9981eb2d1cc635a4963a7243d5cbb9db6c5ba8460136d4c79b3246",
"bytes": 32530
},
{
"path": "engines/pcre2/pcre2_bridge.h",
"sha256": "b8767c0c59229a92d092915fc7b862a22d088a7f184485e4d67e14d6071d7d1c",
"bytes": 7388
}
],
"exports": [
"_free",
"_malloc",
"_regex_pcre2_bridge_abi_version",
"_regex_pcre2_compile_probe",
"_regex_pcre2_config_flags",
"_regex_pcre2_error_message",
"_regex_pcre2_execute",
"_regex_pcre2_self_test",
"_regex_pcre2_substitute",
"_regex_pcre2_trace",
"_regex_pcre2_version"
]
},
"source": {
"repository": "https://github.com/PCRE2Project/pcre2.git",
"tag": "pcre2-10.47",
"tagObjectSha1": "cd007b4466798f66d479d1442a407099e7c40050",
"commitSha1": "f454e231fe5006dd7ff8f4693fd2b8eb94333429",
"treeSha1": "81a83a3552bd68d0ea7b7004f8bb6e7892f583ba",
"license": "BSD-3-Clause WITH PCRE2-exception"
},
"toolchain": {
"emscriptenVersion": "6.0.4",
"emscriptenCompilerRevision": "fe5be6afdff43ad58860d821fcc8572a23f92d19",
"emsdkCommitSha1": "224ec5f9f2f72f09f9ce0e26d66bae7dbd8b692f",
"cmakeVersion": "4.3.4",
"ninjaVersion": "1.13.2"
},
"configuration": {
"codeUnitWidth": 8,
"unicode": true,
"jit": false,
"threads": false,
"filesystem": false,
"initialMemoryBytes": 16777216,
"maximumMemoryBytes": 268435456
},
"sourceDateEpoch": 1760997684,
"files": [
{
"path": "LICENSE.txt",
"sha256": "197d8a73ffee0d6b09adba2f9c677b5f5aede24edf89258a68e48248d010d811",
"bytes": 4011
},
{
"path": "pcre2.mjs",
"sha256": "d31a5f1e0839955ae8321430eafe65da8cbdef832a1245bb71ccc5bb0d4b6b9c",
"bytes": 7513
},
{
"path": "pcre2.wasm",
"sha256": "cc7c4b2b0038e137c86d2037e73f2302e56432cb09bc082e4f35bba221dd1889",
"bytes": 339494
}
]
}

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.regex-tools", "id": "de.add-ideas.regex-tools",
"name": "Regex Tools", "name": "Regex Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Develop, explain, test and apply regular expressions locally in the browser.", "description": "Develop, explain, test and apply regular expressions locally in the browser.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",

View File

@@ -1,8 +1,37 @@
import { readFile, readdir } from "node:fs/promises"; import { readFile, readdir } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import {
buildPcre2Pack,
parsePcre2BuildArguments,
} from "./pcre2-engine-pack.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const options = parsePcre2BuildArguments(process.argv.slice(2));
if (options.mode === "help") {
console.log(`Usage:
node scripts/build-engines.mjs
node scripts/build-engines.mjs --pcre2 --source-dir PATH --emcc PATH [--force]
The PCRE2 command is an explicit offline build gate. It verifies exact local
source and compiler identities, writes .engine-build/pcre2, and never
downloads or publishes an engine pack. Install the verified local pack with
npm run engines:pcre2:install.`);
process.exit(0);
}
if (options.mode === "pcre2") {
const result = await buildPcre2Pack(options, root);
console.log(
`Built and verified staged PCRE2 ${result.metadata.engineVersion} pack at ${result.output}.`,
);
console.log(
"The verified pack remains in private staging until the explicit local install command is run.",
);
process.exit(0);
}
const packageJson = JSON.parse( const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"), await readFile(path.join(root, "package.json"), "utf8"),
); );
@@ -10,15 +39,16 @@ const engineDirectory = path.join(root, "public", "engines");
const entries = (await readdir(engineDirectory)).sort(); const entries = (await readdir(engineDirectory)).sort();
if ( if (
packageJson.version !== "0.1.0" || typeof packageJson.version !== "string" ||
entries.length !== 1 || entries.length !== 2 ||
entries[0] !== "README.md" entries[0] !== "README.md" ||
entries[1] !== "pcre2"
) { ) {
throw new Error( throw new Error(
"The ECMAScript release must contain only the documented native-browser engine placeholder.", "The production build requires the documented PCRE2 runtime pack.",
); );
} }
console.log( console.log(
"Regex Tools 0.1.0 uses the native browser RegExp engine; no external engine pack needs building.", "The checked-in PCRE2 runtime pack is present; no release-time download or engine compilation is needed.",
); );

View File

@@ -7,14 +7,23 @@ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const sourcePath = join(root, "src", "toolbox", "manifest.source.json"); const sourcePath = join(root, "src", "toolbox", "manifest.source.json");
const outputPath = join(root, "public", "toolbox-app.json"); const outputPath = join(root, "public", "toolbox-app.json");
const packagePath = join(root, "package.json"); const packagePath = join(root, "package.json");
const applicationVersionPath = join(root, "src", "version.ts");
const checkOnly = process.argv.includes("--check"); const checkOnly = process.argv.includes("--check");
const source = JSON.parse(await readFile(sourcePath, "utf8")); const source = JSON.parse(await readFile(sourcePath, "utf8"));
const packageJson = JSON.parse(await readFile(packagePath, "utf8")); const packageJson = JSON.parse(await readFile(packagePath, "utf8"));
const applicationVersionSource = await readFile(applicationVersionPath, "utf8");
const applicationVersion =
/^export const APPLICATION_VERSION = "([^"]+)";$/mu.exec(
applicationVersionSource,
)?.[1];
if (source.version !== packageJson.version) { if (
source.version !== packageJson.version ||
applicationVersion !== packageJson.version
) {
throw new Error( throw new Error(
`Manifest version ${source.version} differs from package version ${packageJson.version}`, `Version drift: manifest ${source.version}, application ${String(applicationVersion)}, package ${packageJson.version}`,
); );
} }

View File

@@ -0,0 +1,16 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { installPcre2Pack } from "./pcre2-engine-pack.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const arguments_ = process.argv.slice(2);
if (arguments_.length > 1) {
throw new Error(
"Usage: node scripts/install-pcre2-engine.mjs [.engine-build/pcre2]",
);
}
const source = path.resolve(root, arguments_[0] ?? ".engine-build/pcre2");
const result = await installPcre2Pack(source, root);
console.log(
`Installed verified PCRE2 ${result.metadata.engineVersion} runtime assets at ${result.output}.`,
);

View File

@@ -18,6 +18,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const zipEpoch = new Date(1980, 0, 1, 0, 0, 0); const zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
const gplVersion3TextSha256 = const gplVersion3TextSha256 =
"fb981668c18a279e285fc4d83fba1e836cc84dd4daa73c9697d3cfd2d8aca6e0"; "fb981668c18a279e285fc4d83fba1e836cc84dd4daa73c9697d3cfd2d8aca6e0";
const expectedApplicationVersion = "0.2.0";
const requiredRootFiles = [ const requiredRootFiles = [
"LICENSE", "LICENSE",
"README.md", "README.md",
@@ -332,13 +333,27 @@ async function collectReleaseEntries() {
await readFile(path.join(root, "package.json"), "utf8"), await readFile(path.join(root, "package.json"), "utf8"),
); );
if ( if (
packageJson.version !== "0.1.0" || packageJson.version !== expectedApplicationVersion ||
packageJson.license !== "GPL-3.0-or-later" || packageJson.license !== "GPL-3.0-or-later" ||
packageJson.repository?.url !== packageJson.repository?.url !==
"git+https://git.add-ideas.de/zemion/regex-tools.git" "git+https://git.add-ideas.de/zemion/regex-tools.git"
) { ) {
throw new Error("Package version, licence or source identity drifted."); throw new Error("Package version, licence or source identity drifted.");
} }
const sourceIdentity = await readFile(path.join(root, "SOURCE.md"), "utf8");
if (
!sourceIdentity.includes(
`- Release version: \`${expectedApplicationVersion}\``,
) ||
!sourceIdentity.includes(
`- Release tag: \`v${expectedApplicationVersion}\``,
) ||
!sourceIdentity.includes(
"- Repository: <https://git.add-ideas.de/zemion/regex-tools>",
)
) {
throw new Error("SOURCE.md release identity drifted.");
}
const licence = await readFile(path.join(root, "LICENSE"), "utf8"); const licence = await readFile(path.join(root, "LICENSE"), "utf8");
if ( if (
!licence.includes("GNU GENERAL PUBLIC LICENSE") || !licence.includes("GNU GENERAL PUBLIC LICENSE") ||
@@ -371,6 +386,14 @@ async function collectReleaseEntries() {
"CHANGELOG.md", "CHANGELOG.md",
"SOURCE.md", "SOURCE.md",
"THIRD_PARTY_NOTICES.md", "THIRD_PARTY_NOTICES.md",
"engines/README.md",
"engines/pcre2/LICENSE.txt",
"engines/pcre2/SHA256SUMS",
"engines/pcre2/engine-metadata.json",
"engines/pcre2/pcre2.mjs",
"engines/pcre2/pcre2.wasm",
"LICENSES/Emscripten-MIT.txt",
"LICENSES/PCRE2.txt",
"LICENSES/build/rolldown-1.1.5/LICENSE", "LICENSES/build/rolldown-1.1.5/LICENSE",
"LICENSES/build/vite-8.1.5/LICENSE.md", "LICENSES/build/vite-8.1.5/LICENSE.md",
]) { ]) {

View File

@@ -0,0 +1,110 @@
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import {
generatePcre2C,
type Pcre2CGenerationRequest,
} from "../src/regex/codegen/pcre2-c";
function request(operation: "match" | "replace"): Pcre2CGenerationRequest {
return {
flavour: "pcre2",
flavourVersion: "PCRE2 10.47 8-bit WebAssembly",
operation,
pattern: String.raw`(?<word>\p{L}+)-(\d+)`,
flags: ["g"],
options: {
matchLimit: 1_000_000,
depthLimit: 1_000,
heapLimitKib: 32_768,
},
subject: "x Grüße-42 y alpha-7",
scanAll: true,
...(operation === "replace" ? { replacement: "$2:$<word>" } : {}),
maximumMatches: 100,
maximumCaptureRows: 1_000,
maximumOutputBytes: 4_096,
};
}
function sha256(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
const exactToolchainPrefix = process.env.PCRE2_CODEGEN_PREFIX;
const compileWithExactPcre2 = exactToolchainPrefix ? it : it.skip;
describe("PCRE2 C code-generation golden", () => {
it("retains a reviewed deterministic source identity", () => {
expect(sha256(generatePcre2C(request("match")).source)).toBe(
"22b91d578a449b0ed5c30e6eadad30f19a2bcb26ac9db355c8ec7a69328675da",
);
expect(sha256(generatePcre2C(request("replace")).source)).toBe(
"ab90a0d9831ea7b7145aa0072ac352b42baf9a795319d81f531b0f341b04a636",
);
});
compileWithExactPcre2(
"compiles and executes against the explicitly supplied PCRE2 10.47 C toolchain",
async () => {
if (!exactToolchainPrefix) return;
const header = path.join(exactToolchainPrefix, "include", "pcre2.h");
const library =
process.env.PCRE2_CODEGEN_LIBRARY ??
path.join(exactToolchainPrefix, "lib64", "libpcre2-8.a");
await expect(readFile(header)).resolves.toBeInstanceOf(Buffer);
await expect(readFile(library)).resolves.toBeInstanceOf(Buffer);
const directory = await mkdtemp(
path.join(tmpdir(), "regex-tools-codegen-"),
);
try {
for (const operation of ["match", "replace"] as const) {
const generated = generatePcre2C(request(operation));
const source = path.join(directory, generated.fileName);
const executable = path.join(directory, `pcre2-${operation}`);
await writeFile(source, generated.source);
const compiled = spawnSync(
process.env.CC ?? "cc",
[
"-std=c17",
"-Wall",
"-Wextra",
"-Werror",
`-I${path.join(exactToolchainPrefix, "include")}`,
source,
library,
"-o",
executable,
],
{ encoding: "utf8" },
);
expect(
`${compiled.stdout}${compiled.stderr}`,
`${operation} fixture did not compile`,
).toBe("");
expect(compiled.status).toBe(0);
const executed = spawnSync(executable, [], { encoding: "utf8" });
expect(
executed.status,
`${operation} fixture failed: ${executed.stdout}${executed.stderr}`,
).toBe(0);
if (operation === "match") {
expect(executed.stdout).toContain("match 1: UTF-8 bytes 2..12");
expect(executed.stdout).toContain("group 1 / word");
expect(executed.stdout).toContain(
"completed: 2 match(es), 4 capture row(s)",
);
} else {
expect(executed.stdout).toBe("x 42:Grüße y 7:alpha");
expect(executed.stderr).toContain("completed: 2 substitution(s)");
}
}
} finally {
await rm(directory, { recursive: true, force: true });
}
},
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,89 @@
// @vitest-environment node
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
PCRE2_LOCK,
parsePcre2BuildArguments,
verifyPcre2Pack,
} from "./pcre2-engine-pack.mjs";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true })),
);
});
describe("PCRE2 engine-pack gate", () => {
it("keeps the default build on the checked-in production-assets path", () => {
expect(parsePcre2BuildArguments([])).toEqual({ mode: "ecmascript" });
});
it("requires explicit offline source and compiler paths", () => {
expect(() => parsePcre2BuildArguments(["--pcre2"])).toThrow(
"requires both --source-dir and --emcc",
);
expect(() =>
parsePcre2BuildArguments([
"--source-dir",
"/source",
"--emcc",
"/compiler",
]),
).toThrow("explicit --pcre2 gate");
});
it("parses the exact staged-build contract", () => {
expect(
parsePcre2BuildArguments([
"--pcre2",
"--source-dir",
"/source",
"--emcc",
"/compiler",
"--force",
]),
).toEqual({
mode: "pcre2",
sourceDirectory: "/source",
emccFile: "/compiler",
force: true,
});
});
it.each([
["--unknown"],
["--pcre2", "--pcre2"],
["--pcre2", "--source-dir"],
["--pcre2", "--force", "--force"],
])("rejects ambiguous arguments %j", (...arguments_) => {
expect(() => parsePcre2BuildArguments(arguments_)).toThrow();
});
it("pins source, signed-tag object and compiler identities", () => {
expect(PCRE2_LOCK.source.tag).toBe("pcre2-10.47");
expect(PCRE2_LOCK.source.tagObject).toHaveLength(40);
expect(PCRE2_LOCK.source.commit).toHaveLength(40);
expect(PCRE2_LOCK.source.tree).toHaveLength(40);
expect(PCRE2_LOCK.emscripten.version).toBe("6.0.4");
expect(PCRE2_LOCK.emscripten.emccSha256).toHaveLength(64);
expect(PCRE2_LOCK.bridgeAbi).toBe(3);
expect(PCRE2_LOCK.maximumSubjectBytes).toBe(16 * 1024 * 1024);
expect(PCRE2_LOCK.maximumMatches).toBe(10_000);
});
it("rejects an incomplete staged pack before attempting to load it", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "regex-pcre2-test-"));
temporaryDirectories.push(directory);
await expect(verifyPcre2Pack(directory, process.cwd())).rejects.toThrow(
"must contain only",
);
});
});

View File

@@ -13,6 +13,7 @@ const mediaTypes = new Map([
[".css", "text/css; charset=utf-8"], [".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"], [".html", "text/html; charset=utf-8"],
[".js", "text/javascript; charset=utf-8"], [".js", "text/javascript; charset=utf-8"],
[".mjs", "text/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"], [".json", "application/json; charset=utf-8"],
[".svg", "image/svg+xml"], [".svg", "image/svg+xml"],
[".wasm", "application/wasm"], [".wasm", "application/wasm"],

View File

@@ -1,8 +1,25 @@
import { lstat, readFile, readdir } from "node:fs/promises"; import { lstat, readFile, readdir } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { verifyPcre2Pack } from "./pcre2-engine-pack.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const arguments_ = process.argv.slice(2);
if (arguments_.length > 0) {
if (arguments_.length !== 2 || arguments_[0] !== "--pcre2-pack") {
throw new Error(
"Usage: node scripts/verify-engine-assets.mjs [--pcre2-pack PATH]",
);
}
const pack = path.resolve(root, arguments_[1]);
const metadata = await verifyPcre2Pack(pack, root);
console.log(
`Verified staged PCRE2 ${metadata.engineVersion} pack and WebAssembly bridge smoke test.`,
);
process.exit(0);
}
const engineDirectory = path.join(root, "dist", "engines"); const engineDirectory = path.join(root, "dist", "engines");
const details = await lstat(engineDirectory); const details = await lstat(engineDirectory);
if (details.isSymbolicLink() || !details.isDirectory()) { if (details.isSymbolicLink() || !details.isDirectory()) {
@@ -14,21 +31,21 @@ const entries = (await readdir(engineDirectory, { withFileTypes: true })).sort(
left.name < right.name ? -1 : left.name > right.name ? 1 : 0, left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
); );
if ( if (
entries.length !== 1 || entries.length !== 2 ||
!entries[0]?.isFile() || !entries[0]?.isFile() ||
entries[0].name !== "README.md" entries[0].name !== "README.md" ||
!entries[1]?.isDirectory() ||
entries[1].name !== "pcre2"
) { ) {
throw new Error( throw new Error(
"Regex Tools 0.1.0 must not ship an undeclared external engine pack.", "The production build must contain only README.md and the declared PCRE2 pack.",
); );
} }
const notice = await readFile(path.join(engineDirectory, "README.md"), "utf8"); const notice = await readFile(path.join(engineDirectory, "README.md"), "utf8");
if ( if (!notice.includes("native `RegExp`") || !notice.includes("PCRE2 10.47")) {
!notice.includes("native `RegExp`") ||
!notice.includes("no external engine")
) {
throw new Error("The engine asset notice does not describe this release."); throw new Error("The engine asset notice does not describe this release.");
} }
await verifyPcre2Pack(path.join(engineDirectory, "pcre2"), root);
console.log("Verified ECMAScript-only engine assets (no WebAssembly pack)."); console.log("Verified native ECMAScript and bundled PCRE2 engine assets.");

View File

@@ -0,0 +1,521 @@
.analysis-dialog {
width: min(90rem, calc(100% - 2rem));
}
.analysis-panel {
--analysis-high: var(--toolbox-danger);
--analysis-warning: var(--regex-warning);
--analysis-notice: var(--toolbox-accent);
min-height: 0;
}
.analysis-view-tabs {
display: flex;
gap: 0.25rem;
padding: 0.65rem 0.75rem 0;
border-bottom: 1px solid var(--toolbox-border);
background: var(--toolbox-surface-soft);
}
.analysis-view-tabs button {
border: 1px solid transparent;
border-radius: calc(var(--toolbox-radius) * 0.7)
calc(var(--toolbox-radius) * 0.7) 0 0;
padding: 0.55rem 0.8rem;
background: transparent;
color: var(--toolbox-muted);
font-weight: 750;
}
.analysis-view-tabs button[aria-selected="true"] {
border-color: var(--toolbox-border);
border-bottom-color: var(--toolbox-surface);
background: var(--toolbox-surface);
color: var(--toolbox-accent);
}
.analysis-content {
display: grid;
gap: 0.8rem;
padding: 0.75rem;
}
.analysis-section,
.analysis-result-block {
display: grid;
gap: 0.75rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.82);
background: var(--toolbox-surface);
}
.analysis-section > header,
.analysis-result-block > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem;
border-bottom: 1px solid var(--toolbox-border);
background: var(--toolbox-surface-soft);
}
.analysis-section > header h3,
.analysis-result-block > header h3 {
margin: 0.12rem 0 0;
font-size: 0.96rem;
}
.analysis-section > header > span,
.analysis-result-block > header > span {
color: var(--toolbox-muted);
font-size: 0.72rem;
font-weight: 700;
text-align: right;
}
.analysis-summary,
.analysis-advisory,
.analysis-stop-reason,
.analysis-error,
.analysis-unavailable {
margin: 0;
padding: 0.7rem 0.75rem;
color: var(--toolbox-muted);
font-size: 0.79rem;
line-height: 1.5;
}
.analysis-advisory,
.analysis-stop-reason {
border-left: 3px solid var(--toolbox-accent);
background: var(--toolbox-accent-soft);
}
.analysis-unavailable,
.analysis-error {
margin: 0.75rem 0.75rem 0;
border: 1px solid
color-mix(in srgb, var(--toolbox-danger) 45%, var(--toolbox-border));
border-radius: calc(var(--toolbox-radius) * 0.72);
background: color-mix(
in srgb,
var(--toolbox-danger) 8%,
var(--toolbox-surface)
);
color: color-mix(in srgb, var(--toolbox-danger) 78%, var(--toolbox-text));
}
.analysis-status {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 0.6rem;
margin: 0.75rem 0.75rem 0;
padding: 0.55rem 0.7rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.72);
background: var(--toolbox-surface-soft);
font-size: 0.78rem;
}
.analysis-status > span {
border-radius: 99rem;
padding: 0.2rem 0.42rem;
background: var(--toolbox-accent-soft);
color: var(--toolbox-accent);
font-size: 0.65rem;
font-weight: 800;
text-transform: uppercase;
}
.analysis-status > strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.analysis-progress {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 0.6rem;
margin: 0.5rem 0.75rem 0;
color: var(--toolbox-muted);
font-size: 0.7rem;
}
.analysis-progress progress {
width: 100%;
accent-color: var(--toolbox-accent);
}
.analysis-findings {
display: grid;
gap: 0.55rem;
padding: 0 0.75rem;
}
.analysis-finding {
overflow: hidden;
border: 1px solid var(--toolbox-border);
border-left-width: 4px;
border-radius: calc(var(--toolbox-radius) * 0.72);
background: var(--toolbox-surface-soft);
}
.analysis-finding.is-high {
border-left-color: var(--analysis-high);
}
.analysis-finding.is-warning {
border-left-color: var(--analysis-warning);
}
.analysis-finding.is-notice {
border-left-color: var(--analysis-notice);
}
.analysis-finding-title {
display: grid;
width: 100%;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 0.55rem;
border: 0;
padding: 0.65rem;
background: transparent;
color: var(--toolbox-text);
text-align: left;
}
button.analysis-finding-title:hover {
background: var(--toolbox-accent-soft);
}
.analysis-finding-title > span:last-child {
display: grid;
gap: 0.12rem;
}
.analysis-finding-title small {
color: var(--toolbox-muted);
font-size: 0.67rem;
font-weight: 500;
}
.analysis-risk-level,
.analysis-run-badge {
border: 1px solid currentColor;
border-radius: 99rem;
padding: 0.2rem 0.42rem;
font-size: 0.62rem;
font-weight: 850;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.analysis-risk-level.is-high {
color: var(--analysis-high);
}
.analysis-risk-level.is-warning {
color: color-mix(in srgb, var(--analysis-warning) 78%, var(--toolbox-text));
}
.analysis-risk-level.is-notice {
color: var(--analysis-notice);
}
.analysis-run-badge.is-complete {
color: color-mix(in srgb, var(--regex-mint) 72%, var(--toolbox-text));
}
.analysis-run-badge.is-timeout,
.analysis-run-badge.is-error {
color: var(--toolbox-danger);
}
.analysis-run-badge.is-cancelled,
.analysis-run-badge.is-wall-time-limit {
color: var(--analysis-warning);
}
.analysis-finding > p {
padding: 0 0.65rem 0.65rem;
color: var(--toolbox-text);
font-size: 0.78rem;
line-height: 1.45;
}
.analysis-finding dl {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
margin: 0;
border-top: 1px solid var(--toolbox-border);
}
.analysis-finding dl > div {
padding: 0.6rem;
}
.analysis-finding dt,
.analysis-result-meta dt,
.analysis-input-summary dt {
color: var(--toolbox-muted);
font-size: 0.64rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.analysis-finding dd,
.analysis-result-meta dd,
.analysis-input-summary dd {
margin: 0.2rem 0 0;
overflow-wrap: anywhere;
font-size: 0.74rem;
line-height: 1.4;
}
.analysis-details {
margin: 0 0.75rem 0.75rem;
color: var(--toolbox-muted);
font-size: 0.75rem;
}
.analysis-details summary {
cursor: pointer;
font-weight: 750;
}
.analysis-details li,
.analysis-limitations li {
margin-block: 0.35rem;
line-height: 1.4;
}
.analysis-growth-text-controls,
.analysis-number-controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: 0.55rem;
padding: 0 0.75rem;
}
.analysis-growth-text-controls > label,
.analysis-number-controls > label {
display: grid;
align-content: start;
gap: 0.25rem;
}
.analysis-growth-text-controls label > span,
.analysis-number-controls label > span {
color: var(--toolbox-muted);
font-size: 0.68rem;
font-weight: 750;
}
.analysis-run-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0 0.75rem 0.75rem;
}
.analysis-result-block {
margin: 0 0.75rem 0.75rem;
}
.analysis-result-meta,
.analysis-input-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
margin: 0;
padding: 0 0.75rem;
}
.analysis-result-meta > div,
.analysis-input-summary > div {
padding: 0.55rem;
border-bottom: 1px solid var(--toolbox-border);
}
.analysis-table-scroll {
overflow: auto;
border-block: 1px solid var(--toolbox-border);
}
.analysis-table-scroll table {
min-width: 48rem;
}
.analysis-table-scroll th,
.analysis-table-scroll td {
white-space: nowrap;
}
.analysis-chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: 0.65rem;
padding: 0 0.75rem;
}
.analysis-chart {
display: grid;
min-width: 0;
gap: 0.5rem;
margin: 0;
padding: 0.65rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.72);
background: var(--toolbox-surface-soft);
}
.analysis-chart figcaption {
display: grid;
gap: 0.12rem;
}
.analysis-chart figcaption strong {
font-size: 0.78rem;
}
.analysis-chart figcaption span {
color: var(--toolbox-muted);
font-size: 0.65rem;
}
.analysis-chart svg {
width: 100%;
height: 10rem;
overflow: visible;
}
.analysis-chart-axis {
fill: none;
stroke: var(--toolbox-border);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.analysis-chart-line {
fill: none;
stroke: var(--toolbox-accent);
stroke-width: 2;
vector-effect: non-scaling-stroke;
}
.analysis-chart circle {
fill: var(--toolbox-surface);
stroke: var(--toolbox-accent);
stroke-width: 2;
}
.analysis-empty-chart {
display: grid;
min-height: 10rem;
place-items: center;
color: var(--toolbox-muted);
font-size: 0.75rem;
}
.analysis-outcome-plot {
display: flex;
min-height: 10rem;
align-items: end;
gap: 0.22rem;
}
.analysis-outcome-plot > span {
min-width: 0.35rem;
max-width: 2rem;
height: 45%;
flex: 1;
border-radius: 0.2rem 0.2rem 0 0;
background: var(--toolbox-muted);
}
.analysis-outcome-plot > span.is-match {
height: 88%;
background: var(--regex-mint);
}
.analysis-outcome-plot > span.is-no-match {
background: var(--toolbox-accent);
}
.analysis-outcome-plot > span.is-timeout {
height: 100%;
background: var(--toolbox-danger);
}
.analysis-outcome-plot > span.is-crash,
.analysis-outcome-plot > span.is-compile-error {
height: 100%;
background: var(--analysis-warning);
}
.analysis-outcome-key {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.7rem;
color: var(--toolbox-muted);
font-size: 0.64rem;
}
.analysis-outcome-key span::before {
display: inline-block;
width: 0.55rem;
height: 0.55rem;
margin-right: 0.25rem;
border-radius: 0.15rem;
background: var(--toolbox-muted);
content: "";
}
.analysis-outcome-key .is-match::before {
background: var(--regex-mint);
}
.analysis-outcome-key .is-no-match::before {
background: var(--toolbox-accent);
}
.analysis-outcome-key .is-timeout::before {
background: var(--toolbox-danger);
}
.analysis-outcome-key .is-crash::before {
background: var(--analysis-warning);
}
.analysis-limitations {
margin: 0;
padding: 0 1.6rem 0.75rem 2rem;
color: var(--toolbox-muted);
font-size: 0.7rem;
}
@media (max-width: 40rem) {
.analysis-section > header,
.analysis-result-block > header {
align-items: start;
flex-direction: column;
}
.analysis-section > header > span,
.analysis-result-block > header > span {
text-align: left;
}
.analysis-status {
grid-template-columns: 1fr;
}
.analysis-status > strong {
white-space: normal;
}
}

View File

@@ -0,0 +1,248 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AnalysisWorkerClient } from "../regex/analysis/AnalysisSupervisor";
import type {
BenchmarkWorkerSample,
GrowthWorkerSample,
} from "../regex/analysis/analysis.types";
import { WorkerRequestError } from "../regex/execution/WorkerSupervisor";
import { EcmaScriptSyntaxProvider } from "../regex/syntax/providers/ecmascript/EcmaScriptSyntaxProvider";
import { AnalysisPanel } from "./AnalysisPanel";
const provider = new EcmaScriptSyntaxProvider();
function benchmarkSample(milliseconds = 1): BenchmarkWorkerSample {
return {
accepted: true,
effectiveFlags: "dg",
subjectBytes: 5,
subjectUtf16: 5,
compileMs: milliseconds,
firstMatchMs: milliseconds + 1,
allMatchesMs: milliseconds + 2,
replacementMs: milliseconds + 3,
throughputBytesPerSecond: 1024 * 1024,
matchCount: 1,
matched: true,
matchCollectionTruncated: false,
replacementOutputUtf16: 1,
};
}
function growthSample(executionMs: number): GrowthWorkerSample {
return {
accepted: true,
effectiveFlags: "d",
subjectBytes: 5,
subjectUtf16: 5,
executionMs,
matchCount: 0,
matched: false,
matchCollectionTruncated: false,
};
}
function worker(
overrides: Partial<AnalysisWorkerClient> = {},
): AnalysisWorkerClient {
return {
identity: vi.fn().mockResolvedValue({
flavour: "ecmascript",
engineName: "Native ECMAScript RegExp",
engineVersion: "Fixture Browser 1",
runtimeVersion: "Fixture Browser 1",
nativeOffsetUnit: "utf16",
}),
benchmarkSample: vi.fn().mockResolvedValue(benchmarkSample()),
growthProbe: vi.fn().mockResolvedValue(growthSample(1)),
cancel: vi.fn(),
dispose: vi.fn(),
...overrides,
};
}
async function renderPanel(
overrides: Partial<Parameters<typeof AnalysisPanel>[0]> = {},
) {
const pattern = overrides.pattern ?? "(a+)+$";
const flavour = overrides.flavour ?? "ecmascript";
const syntax =
overrides.syntax ??
(await provider.parsePattern({
flavour: "ecmascript",
flavourVersion: "2025",
pattern,
flags: [],
options: {},
}));
const currentWorker = worker();
const rendered = render(
<AnalysisPanel
active
flavour={flavour}
pattern={pattern}
flags={[]}
subject="aaaa!"
replacement="x"
scanAll={false}
syntax={syntax}
createSupervisor={() => currentWorker}
{...overrides}
/>,
);
return { ...rendered, currentWorker, syntax };
}
afterEach(() => {
cleanup();
});
describe("AnalysisPanel", () => {
it("shows flavour-scoped advisory static findings and selects their source range", async () => {
const onSelectPatternRange = vi.fn();
await renderPanel({ onSelectPatternRange });
expect(
screen.getByRole("heading", { name: "Potential risk findings" }),
).toBeInTheDocument();
expect(screen.getByText("Potential nested-quantifier risk")).toBeVisible();
expect(screen.getByText(/potential risks found/u)).toBeVisible();
await userEvent.click(
screen.getByRole("button", {
name: /Potential nested-quantifier risk/u,
}),
);
expect(onSelectPatternRange).toHaveBeenCalledWith({
startUtf16: 0,
endUtf16: 5,
});
});
it("runs bounded cold and warm benchmark samples and renders p95", async () => {
const { currentWorker } = await renderPanel();
await userEvent.click(screen.getByRole("tab", { name: "Benchmark" }));
await userEvent.clear(
screen.getByRole("spinbutton", { name: "Measured samples" }),
);
await userEvent.type(
screen.getByRole("spinbutton", { name: "Measured samples" }),
"3",
);
await userEvent.click(
screen.getByRole("button", { name: "Run bounded benchmark" }),
);
const table = await screen.findByRole("table", {
name: "Cold and warm benchmark metrics",
});
expect(table).toHaveTextContent("Warm p95");
expect(table).toHaveTextContent("Compile");
expect(screen.getByText(/Fixture Browser 1/u)).toBeVisible();
expect(screen.getAllByText(/3 measured/u)).toHaveLength(2);
expect(currentWorker.identity).toHaveBeenCalled();
expect(currentWorker.benchmarkSample).toHaveBeenCalledTimes(7);
});
it("renders separate timing and outcome plots for bounded growth", async () => {
const samples = [growthSample(0.5), growthSample(10)];
const currentWorker = worker({
growthProbe: vi
.fn()
.mockImplementation(() => Promise.resolve(samples.shift()!)),
});
await renderPanel({ createSupervisor: () => currentWorker });
await userEvent.click(
screen.getByRole("button", { name: "Run bounded growth probe" }),
);
expect(
await screen.findByRole("img", {
name: /Execution-time growth plot/u,
}),
).toBeVisible();
expect(
screen.getByRole("img", { name: /Outcome plot with 2 samples/u }),
).toBeVisible();
expect(screen.getByText("Observed disproportionate growth")).toBeVisible();
expect(
screen.getByRole("table", { name: "Dynamic growth samples" }),
).toHaveTextContent("Normalized growth");
});
it("does not apply ECMAScript analysis to PCRE2", async () => {
const currentWorker = worker();
await renderPanel({
flavour: "pcre2",
createSupervisor: () => currentWorker,
});
expect(screen.getByRole("alert")).toHaveTextContent(
"supports ECMAScript 2025 only",
);
expect(
screen.getByRole("button", { name: "Run bounded growth probe" }),
).toBeDisabled();
expect(currentWorker.identity).not.toHaveBeenCalled();
});
it("never renders benchmark results against a changed subject", async () => {
const rendered = await renderPanel();
await userEvent.click(screen.getByRole("tab", { name: "Benchmark" }));
await userEvent.click(
screen.getByRole("button", { name: "Run bounded benchmark" }),
);
expect(
await screen.findByRole("table", {
name: "Cold and warm benchmark metrics",
}),
).toBeVisible();
rendered.rerender(
<AnalysisPanel
active
flavour="ecmascript"
pattern="(a+)+$"
flags={[]}
subject="changed subject"
replacement="x"
scanAll={false}
syntax={rendered.syntax}
createSupervisor={() => rendered.currentWorker}
/>,
);
expect(
screen.queryByRole("table", {
name: "Cold and warm benchmark metrics",
}),
).not.toBeInTheDocument();
});
it("terminates the active worker when the user cancels", async () => {
let rejectSample: ((reason: Error) => void) | undefined;
const pending = new Promise<BenchmarkWorkerSample>((_resolve, reject) => {
rejectSample = reject;
});
const currentWorker = worker({
benchmarkSample: vi.fn().mockReturnValue(pending),
cancel: vi.fn().mockImplementation(() => {
rejectSample?.(
new WorkerRequestError("cancelled", "fixture cancellation"),
);
}),
});
await renderPanel({ createSupervisor: () => currentWorker });
await userEvent.click(screen.getByRole("tab", { name: "Benchmark" }));
await userEvent.click(
screen.getByRole("button", { name: "Run bounded benchmark" }),
);
await userEvent.click(
await screen.findByRole("button", { name: "Cancel benchmark" }),
);
await waitFor(() => expect(currentWorker.cancel).toHaveBeenCalled());
expect(await screen.findAllByText(/Benchmark cancelled/u)).toHaveLength(2);
expect(currentWorker.dispose).toHaveBeenCalled();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { RegexExecutionResult } from "../regex/model/match"; import type { RegexExecutionResult } from "../regex/model/match";
import type { RegexSyntaxResult } from "../regex/model/syntax"; import type { RegexSyntaxResult } from "../regex/model/syntax";
import { CapabilityPanel } from "./CapabilityPanel"; import { CapabilityPanel } from "./CapabilityPanel";
@@ -78,11 +79,66 @@ describe("CapabilityPanel", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("Fixture engine")).toBeInTheDocument(); expect(screen.getByText("Fixture engine")).toBeInTheDocument();
expect(screen.getByText("123")).toBeInTheDocument(); expect(screen.getByText("123")).toBeInTheDocument();
expect(
screen.getByText("Not separately reported by this adapter"),
).toBeInTheDocument();
expect(screen.getByText("1", { selector: "dd" })).toBeInTheDocument();
expect(screen.getByText("UTF-8 bytes")).toBeInTheDocument(); expect(screen.getByText("UTF-8 bytes")).toBeInTheDocument();
expect(screen.getByText("fixture")).toBeInTheDocument();
const replacement = screen.getByText("Replacement").closest("div"); const replacement = screen.getByText("Replacement").closest("div");
expect(replacement).toHaveTextContent("Unavailable"); expect(replacement).toHaveTextContent("Unavailable");
const compilation = screen.getByText("Compilation").closest("div"); const compilation = screen.getByText("Compilation").closest("div");
expect(compilation).toHaveTextContent("Available"); expect(compilation).toHaveTextContent("Available");
const generatedCases = screen.getByText("Generated cases").closest("div");
expect(generatedCases).toHaveTextContent(
"deterministic AST candidates, retained only after actual-engine verification",
);
const formatting = screen.getByText("Pattern formatting").closest("div");
expect(formatting).toHaveTextContent(
"grammar-backed literal/control escaping with mandatory exact-snapshot validation",
);
});
it("does not advertise ECMAScript generation for the partial PCRE2 syntax provider", () => {
render(
<CapabilityPanel
syntax={syntax}
execution={{
...execution,
engine: {
...execution.engine,
flavour: "pcre2",
engineName: "PCRE2",
},
}}
/>,
);
const generatedCases = screen.getByText("Generated cases").closest("div");
expect(generatedCases).toHaveTextContent(
"Unavailable — the partial PCRE2 provider does not expose a complete generation AST",
);
const formatting = screen.getByText("Pattern formatting").closest("div");
expect(formatting).toHaveTextContent(
"Unavailable — no complete PCRE2 grammar-backed formatter is implemented",
);
});
it("offers an accessible close control when used as a modal panel", async () => {
const onClose = vi.fn();
const user = userEvent.setup();
render(
<CapabilityPanel
syntax={syntax}
execution={execution}
onClose={onClose}
/>,
);
await user.click(
screen.getByRole("button", { name: "Close capabilities" }),
);
expect(onClose).toHaveBeenCalledOnce();
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { REGEXPP_VERSION, SYNTAX_PROFILE } from "../version"; import { SYNTAX_PROFILE } from "../version";
import type { RegexExecutionResult } from "../regex/model/match"; import type { RegexExecutionResult } from "../regex/model/match";
import type { RegexSyntaxResult } from "../regex/model/syntax"; import type { RegexSyntaxResult } from "../regex/model/syntax";
import type { RegexEngineCapabilities } from "../regex/model/flavour"; import type { RegexEngineCapabilities } from "../regex/model/flavour";
@@ -35,23 +35,27 @@ function offsetLabel(
export function CapabilityPanel({ export function CapabilityPanel({
syntax, syntax,
execution, execution,
onClose,
}: { }: {
readonly syntax?: RegexSyntaxResult; readonly syntax?: RegexSyntaxResult;
readonly execution?: RegexExecutionResult; readonly execution?: RegexExecutionResult;
readonly onClose?: () => void;
}) { }) {
const capabilities = execution?.engine.capabilities; const capabilities = execution?.engine.capabilities;
const activeFlavour =
execution?.engine.flavour ?? syntax?.root.support.flavour;
const rows = [ const rows = [
[ [
"Flavour", "Flavour",
execution?.engine.flavour ?? execution?.engine.flavour ??
syntax?.root.support.flavour ?? syntax?.root.support.flavour ??
"ECMAScript (awaiting workers)", "Awaiting flavour workers",
], ],
[ [
"Syntax provider", "Syntax provider",
syntax syntax
? `${syntax.provider.id} ${syntax.provider.version}` ? `${syntax.provider.id} ${syntax.provider.version}`
: `regexpp ${REGEXPP_VERSION} (awaiting syntax worker)`, : "Awaiting syntax worker result",
], ],
["Syntax profile", SYNTAX_PROFILE], ["Syntax profile", SYNTAX_PROFILE],
[ [
@@ -65,9 +69,20 @@ export function CapabilityPanel({
execution?.engine.engineName ?? "Awaiting first engine result", execution?.engine.engineName ?? "Awaiting first engine result",
], ],
[ [
"Engine/runtime version", "Engine version",
execution?.engine.engineVersion ?? "Shown after first execution", execution?.engine.engineVersion ?? "Shown after first execution",
], ],
[
"Runtime version",
execution?.engine.runtimeVersion ??
(execution
? "Not separately reported by this adapter"
: "Shown after first execution"),
],
[
"Adapter version",
execution?.engine.adapterVersion ?? "Shown after first execution",
],
["Native offsets", offsetLabel(execution?.engine.offsetUnit)], ["Native offsets", offsetLabel(execution?.engine.offsetUnit)],
["Compilation", capability(capabilities, "compilation")], ["Compilation", capability(capabilities, "compilation")],
["Matching", capability(capabilities, "matching")], ["Matching", capability(capabilities, "matching")],
@@ -90,6 +105,38 @@ export function CapabilityPanel({
), ),
], ],
["Benchmark", capability(capabilities, "benchmark")], ["Benchmark", capability(capabilities, "benchmark")],
[
"Static risk analysis",
activeFlavour === "ecmascript"
? "Available — advisory ECMAScript 2025 heuristics"
: activeFlavour === "pcre2"
? "Unavailable — ECMAScript heuristics are not applied to PCRE2"
: "Awaiting flavour worker result",
],
[
"Generated cases",
activeFlavour === "ecmascript"
? "Available — deterministic AST candidates, retained only after actual-engine verification"
: activeFlavour === "pcre2"
? "Unavailable — the partial PCRE2 provider does not expose a complete generation AST"
: "Awaiting flavour worker result",
],
[
"Pattern formatting",
activeFlavour === "ecmascript"
? "Available — grammar-backed literal/control escaping with mandatory exact-snapshot validation"
: activeFlavour === "pcre2"
? "Unavailable — no complete PCRE2 grammar-backed formatter is implemented"
: "Awaiting flavour worker result",
],
[
"Known syntax gaps",
syntax
? syntax.coverage.unsupportedConstructs.length > 0
? syntax.coverage.unsupportedConstructs.join("; ")
: "None reported by the active provider"
: "Awaiting syntax worker result",
],
] as const; ] as const;
return ( return (
<section <section
@@ -101,7 +148,20 @@ export function CapabilityPanel({
<p className="eyebrow">Current provider and adapter metadata</p> <p className="eyebrow">Current provider and adapter metadata</p>
<h2 id="capability-heading">Capabilities</h2> <h2 id="capability-heading">Capabilities</h2>
</div> </div>
<div className="panel-heading-actions">
<span className="provenance-badge">Community build</span> <span className="provenance-badge">Community build</span>
{onClose ? (
<button
type="button"
className="icon-button"
aria-label="Close capabilities"
data-dialog-initial-focus
onClick={onClose}
>
×
</button>
) : null}
</div>
</header> </header>
<dl className="capability-grid"> <dl className="capability-grid">
{rows.map(([term, value]) => ( {rows.map(([term, value]) => (
@@ -112,10 +172,11 @@ export function CapabilityPanel({
))} ))}
</dl> </dl>
<p className="capability-roadmap"> <p className="capability-roadmap">
Next flavour: official PCRE2 10.47 WebAssembly with actual callout PCRE2 10.47 matching and substitution run in the bundled WebAssembly
traces. Python, Go, Rust, .NET and Java remain unavailable until their worker. Its separate trace worker exposes bounded reported automatic
named runtimes pass the same worker, offset, conformance and licensing callouts; movement classifications remain explicitly derived. Python,
gates. Go, Rust, .NET and Java remain unavailable until their named runtimes
pass the same worker, offset, conformance and licensing gates.
</p> </p>
</section> </section>
); );

View File

@@ -0,0 +1,331 @@
.comparison-code-dialog {
width: min(88rem, calc(100% - 2rem));
}
.comparison-code-panel {
max-height: calc(100vh - 2rem);
overflow: auto;
}
.comparison-tabs {
display: flex;
gap: 0.35rem;
padding: 0.7rem 0.8rem 0;
}
.comparison-tabs button {
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.72);
padding: 0.55rem 0.8rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
font-weight: 750;
}
.comparison-tabs button[aria-selected="true"] {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent-soft);
}
.comparison-config,
.comparison-tab-panel {
display: grid;
gap: 0.8rem;
padding: 0.8rem;
}
.comparison-config {
border-bottom: 1px solid var(--toolbox-border);
background: var(--toolbox-surface-soft);
}
.comparison-config-row,
.comparison-actions {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 0.65rem;
}
.comparison-config label,
.comparison-limit-grid label,
.comparison-text-field {
display: grid;
gap: 0.3rem;
color: var(--toolbox-muted);
font-size: 0.75rem;
font-weight: 700;
}
.comparison-config select,
.comparison-config input,
.comparison-config textarea {
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.65);
padding: 0.5rem 0.6rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
}
.comparison-config textarea {
min-height: 4.8rem;
resize: vertical;
font-family: ui-monospace, monospace;
font-size: 0.78rem;
line-height: 1.45;
}
.comparison-scan-control {
display: flex !important;
align-items: center;
padding-bottom: 0.5rem;
}
.comparison-variant-grid,
.comparison-side-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.comparison-flag-selector,
.comparison-limit-grid {
min-width: 0;
margin: 0;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.7);
padding: 0.55rem;
}
.comparison-flag-selector legend,
.comparison-limit-grid legend {
padding-inline: 0.3rem;
color: var(--toolbox-muted);
font-size: 0.72rem;
font-weight: 750;
}
.comparison-flag-selector {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.comparison-flag-selector label {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px solid var(--toolbox-border);
border-radius: 99rem;
padding: 0.25rem 0.45rem;
background: var(--toolbox-surface);
}
.comparison-flag-selector span {
font-size: 0.67rem;
}
.comparison-limit-grid {
display: grid;
grid-template-columns: repeat(6, minmax(6rem, 1fr));
gap: 0.5rem;
}
.comparison-subject textarea {
min-height: 7rem;
}
.comparison-actions p {
margin: 0;
color: var(--toolbox-muted);
font-size: 0.78rem;
}
.comparison-panel-status.status-error,
.comparison-error {
color: var(--toolbox-danger);
}
.comparison-result {
display: grid;
gap: 0.8rem;
}
.comparison-verdict,
.comparison-reasons,
.comparison-differences,
.comparison-alignments,
.generated-code-result {
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.78);
padding: 0.8rem;
background: var(--toolbox-surface);
}
.comparison-verdict h3,
.comparison-verdict p,
.comparison-verdict small,
.comparison-reasons h4,
.comparison-differences h4,
.comparison-alignments h4,
.generated-code-result h3,
.generated-code-result p {
margin: 0;
}
.comparison-verdict {
display: grid;
gap: 0.3rem;
border-left: 4px solid var(--regex-success);
}
.comparison-verdict.verdict-not-comparable {
border-left-color: var(--regex-warning);
}
.comparison-verdict.verdict-different-for-current-input {
border-left-color: var(--toolbox-danger);
}
.comparison-verdict p,
.comparison-verdict small,
.comparison-reasons,
.comparison-differences > p,
.comparison-alignments > p,
.generated-code-result li {
color: var(--toolbox-muted);
font-size: 0.77rem;
line-height: 1.5;
}
.comparison-reasons ul,
.generated-code-result ul {
margin-bottom: 0;
}
.comparison-side-card {
min-width: 0;
overflow: hidden;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.78);
background: var(--toolbox-surface);
}
.comparison-side-card > header,
.generated-code-result > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
padding: 0.7rem;
border-bottom: 1px solid var(--toolbox-border);
}
.comparison-side-card h4,
.comparison-side-card p {
margin: 0;
}
.comparison-side-card dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0;
}
.comparison-side-card dl > div {
min-width: 0;
padding: 0.55rem 0.7rem;
border-bottom: 1px solid var(--toolbox-border);
}
.comparison-side-card dt {
color: var(--toolbox-muted);
font-size: 0.67rem;
font-weight: 750;
text-transform: uppercase;
}
.comparison-side-card dd {
margin: 0.15rem 0 0;
overflow-wrap: anywhere;
font-size: 0.76rem;
}
.comparison-side-card details,
.comparison-side-card > .comparison-error {
padding: 0.55rem 0.7rem;
font-size: 0.74rem;
}
.comparison-side-card details ul {
margin-bottom: 0;
}
.comparison-differences,
.comparison-alignments {
overflow: auto;
}
.comparison-differences h4,
.comparison-alignments h4 {
margin-bottom: 0.65rem;
}
.comparison-differences td code {
display: block;
max-width: 22rem;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.comparison-limit-note {
margin-bottom: 0 !important;
}
.generated-code-result {
min-width: 0;
padding: 0;
overflow: hidden;
}
.generated-code-result > p,
.generated-code-result > ul {
margin-inline: 0.8rem;
}
.generated-code-result pre {
max-height: 34rem;
overflow: auto;
margin: 0;
border-top: 1px solid var(--toolbox-border);
padding: 0.8rem;
background: #111827;
color: #e5e7eb;
font-size: 0.72rem;
line-height: 1.45;
}
.comparison-empty-state {
margin: 0;
padding: 1.2rem;
color: var(--toolbox-muted);
text-align: center;
}
@media (max-width: 64rem) {
.comparison-limit-grid {
grid-template-columns: repeat(3, minmax(6rem, 1fr));
}
}
@media (max-width: 48rem) {
.comparison-variant-grid,
.comparison-side-grid,
.comparison-side-card dl {
grid-template-columns: 1fr;
}
.comparison-limit-grid {
grid-template-columns: repeat(2, minmax(6rem, 1fr));
}
}

View File

@@ -0,0 +1,82 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ComparisonCodePanel } from "./ComparisonCodePanel";
function renderPanel() {
const onClose = vi.fn();
render(
<ComparisonCodePanel
activeFlavour="ecmascript"
activeFlags={["g", "u"]}
activeOptions={{}}
pattern={"(?<word>\\p{Letter}+)"}
subject="Grüße"
replacement="$<word>!"
scanAll
timeoutMs={2_000}
onClose={onClose}
/>,
);
return { onClose };
}
describe("ComparisonCodePanel", () => {
it("exposes comparison and reviewed PCRE2 C generation through one usable path", () => {
renderPanel();
expect(
screen.getByRole("tab", { name: "Compare engines" }),
).toHaveAttribute("aria-selected", "true");
expect(
screen.getByRole("textbox", { name: "Shared comparison pattern" }),
).toHaveValue("(?<word>\\p{Letter}+)");
expect(
screen.getByRole("button", { name: "Run comparison" }),
).toBeEnabled();
fireEvent.click(screen.getByRole("tab", { name: "PCRE2 C code" }));
fireEvent.click(
screen.getByRole("button", { name: "Generate reviewed C17" }),
);
expect(screen.getByText("PCRE2 10.47 8-bit · all-matches")).toBeVisible();
expect(screen.getByText("Exact UTF-8 byte arrays")).toBeVisible();
expect(
screen.getByText(/PCRE2_UTF and PCRE2_UCP are mandatory/u),
).toBeVisible();
const source = screen.getByText(
/This generated program requires PCRE2 10\.47 exactly/u,
);
expect(source).toHaveTextContent("pcre2_set_match_limit");
expect(source).not.toHaveTextContent("(?<word>\\p{Letter}+)");
});
it("uses the explicit PCRE2 variant without rewriting it", () => {
renderPanel();
fireEvent.change(screen.getByLabelText("Pattern model"), {
target: { value: "variants" },
});
const pcrePattern = screen.getByLabelText("PCRE2 pattern variant");
fireEvent.change(pcrePattern, {
target: { value: "(?<word>\\w++)" },
});
fireEvent.click(screen.getByRole("tab", { name: "PCRE2 C code" }));
fireEvent.click(
screen.getByRole("button", { name: "Generate reviewed C17" }),
);
const source = screen.getByText(
/This generated program requires PCRE2 10\.47 exactly/u,
);
expect(source).toHaveTextContent("0x2b, 0x2b");
expect(source).not.toHaveTextContent("(?<word>\\w++)");
});
it("closes through the dialog action", () => {
const { onClose } = renderPanel();
fireEvent.click(
screen.getByRole("button", { name: "Close compare and generate" }),
);
expect(onClose).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,884 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { writeClipboardText } from "../browser/clipboard";
import {
AVAILABLE_REGEX_FLAVOURS,
defaultRegexOptions,
} from "../regex/flavours/flavour-registry";
import { ComparisonOrchestrator } from "../regex/comparison/ComparisonOrchestrator";
import type {
ComparisonFlavour,
ComparisonPatternModel,
RegexComparisonResult,
} from "../regex/comparison/comparison.types";
import {
generatePcre2C,
type GeneratedPcre2CProgram,
} from "../regex/codegen/pcre2-c";
import {
DEFAULT_REGEX_LIMITS,
utf8ByteLength,
} from "../regex/execution/request-limits";
import type {
RegexEngineOptions,
RegexFlavourId,
} from "../regex/model/flavour";
import "./ComparisonCodePanel.css";
const ECMASCRIPT = AVAILABLE_REGEX_FLAVOURS.require("ecmascript");
const PCRE2 = AVAILABLE_REGEX_FLAVOURS.require("pcre2");
const MAXIMUM_RENDERED_DIFFERENCES = 250;
const MAXIMUM_RENDERED_ALIGNMENTS = 100;
type PanelTab = "compare" | "code";
type PanelStatus =
| { readonly kind: "idle"; readonly message: string }
| { readonly kind: "running"; readonly message: string }
| { readonly kind: "ready"; readonly message: string }
| { readonly kind: "error"; readonly message: string };
export interface ComparisonCodePanelProps {
readonly activeFlavour: RegexFlavourId;
readonly activeFlags: readonly string[];
readonly activeOptions: RegexEngineOptions;
readonly pattern: string;
readonly subject: string;
readonly replacement: string;
readonly scanAll: boolean;
readonly timeoutMs: number;
readonly onClose: () => void;
}
function initialFlags(
activeFlavour: RegexFlavourId,
activeFlags: readonly string[],
flavour: ComparisonFlavour,
): readonly string[] {
const definition = flavour === "ecmascript" ? ECMASCRIPT : PCRE2;
return activeFlavour === flavour
? definition.flags
.map((flag) => flag.value)
.filter((flag) => activeFlags.includes(flag))
: definition.defaultFlags;
}
function initialOptions(
activeFlavour: RegexFlavourId,
activeOptions: RegexEngineOptions,
): RegexEngineOptions {
return activeFlavour === "pcre2" ? activeOptions : defaultRegexOptions(PCRE2);
}
function toggleFlag(
current: readonly string[],
flag: string,
enabled: boolean,
flavour: ComparisonFlavour,
): readonly string[] {
const definition = flavour === "ecmascript" ? ECMASCRIPT : PCRE2;
const selected = new Set(current);
if (enabled) {
selected.add(flag);
for (const group of definition.mutuallyExclusiveFlags ?? []) {
if (!group.includes(flag)) continue;
for (const incompatible of group) {
if (incompatible !== flag) selected.delete(incompatible);
}
}
} else {
selected.delete(flag);
}
return definition.flags
.map((candidate) => candidate.value)
.filter((candidate) => selected.has(candidate));
}
function preview(value: string | undefined, maximum = 240): string {
if (value === undefined) return "—";
return value.length <= maximum
? value
: `${value.slice(0, maximum)}… (${value.length.toLocaleString()} UTF-16 units)`;
}
function downloadSource(program: GeneratedPcre2CProgram): void {
const url = URL.createObjectURL(
new Blob([program.source], { type: "text/x-c;charset=utf-8" }),
);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = program.fileName;
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1_000);
}
function engineExecution(result: RegexComparisonResult["sides"][number]) {
return result.runtime.replacement?.execution ?? result.runtime.execution;
}
function SideResult({
side,
}: {
readonly side: RegexComparisonResult["sides"][number];
}) {
const execution = engineExecution(side);
const diagnostics = [
...(side.syntax.pattern?.diagnostics ?? []),
...(side.syntax.replacement?.diagnostics ?? []),
...(execution?.diagnostics ?? []),
];
return (
<article className="comparison-side-card">
<header>
<div>
<p className="eyebrow">Exact request {side.requestIdentity}</p>
<h4>
{side.input.flavour === "ecmascript" ? "ECMAScript" : "PCRE2"}
</h4>
</div>
<span className={`status status-${side.runtime.status}`}>
{side.runtime.status}
</span>
</header>
<dl>
<div>
<dt>Pattern syntax</dt>
<dd>
{side.syntax.pattern
? side.syntax.pattern.accepted
? "Accepted"
: "Rejected"
: side.syntax.status}
</dd>
</div>
<div>
<dt>Provider</dt>
<dd>
{side.syntax.pattern
? `${side.syntax.pattern.provider.id} ${side.syntax.pattern.provider.version}`
: "Unavailable"}
</dd>
</div>
<div>
<dt>Engine compile</dt>
<dd>
{execution
? execution.accepted
? "Accepted"
: "Rejected"
: side.runtime.status}
</dd>
</div>
<div>
<dt>Engine identity</dt>
<dd>
{execution
? `${execution.engine.engineName} · ${execution.engine.engineVersion}`
: "Unavailable"}
</dd>
</div>
<div>
<dt>Exact flags</dt>
<dd>
user {side.input.flags.join("") || "none"} · effective{" "}
{execution?.flags.effectiveFlags || "unavailable"}
</dd>
</div>
<div>
<dt>Options</dt>
<dd>
<code>{JSON.stringify(side.input.options)}</code>
</dd>
</div>
<div>
<dt>Matches</dt>
<dd>{execution?.matches.length.toLocaleString() ?? "Unavailable"}</dd>
</div>
<div>
<dt>Offsets</dt>
<dd>
native {execution?.engine.offsetUnit ?? "unavailable"} · comparison
UTF-16
</dd>
</div>
{side.runtime.replacement ? (
<div>
<dt>Replacement</dt>
<dd>
{side.runtime.replacement.outputBytes.toLocaleString()} bytes
{side.runtime.replacement.truncated ? " · incomplete" : ""}
</dd>
</div>
) : null}
</dl>
{side.syntax.pattern?.coverage.unsupportedConstructs.length ? (
<details>
<summary>Provider coverage gaps</summary>
<ul>
{side.syntax.pattern.coverage.unsupportedConstructs.map((gap) => (
<li key={gap}>{gap}</li>
))}
</ul>
</details>
) : null}
{diagnostics.length > 0 ? (
<details>
<summary>{diagnostics.length.toLocaleString()} diagnostic(s)</summary>
<ul>
{diagnostics.slice(0, 100).map((diagnostic) => (
<li key={`${diagnostic.source}-${diagnostic.id}`}>
<strong>{diagnostic.severity}:</strong> {diagnostic.message}
</li>
))}
</ul>
</details>
) : null}
{side.syntax.error || side.runtime.error ? (
<p className="comparison-error">
{side.syntax.error ?? side.runtime.error}
</p>
) : null}
</article>
);
}
function ComparisonResultView({
result,
}: {
readonly result: RegexComparisonResult;
}) {
const visibleDifferences = result.differences.slice(
0,
MAXIMUM_RENDERED_DIFFERENCES,
);
const visibleAlignments = result.matchAlignments.slice(
0,
MAXIMUM_RENDERED_ALIGNMENTS,
);
return (
<div className="comparison-result" aria-live="polite">
<section className={`comparison-verdict verdict-${result.status}`}>
<p className="eyebrow">Bounded comparison verdict</p>
<h3>{result.status.replaceAll("-", " ")}</h3>
<p>{result.notices[0]?.message}</p>
<small>
{result.elapsedMs.toFixed(1)} ms wall time ·{" "}
{result.totalDifferences.toLocaleString()} recorded difference(s)
</small>
</section>
{result.notComparable.length > 0 ? (
<section className="comparison-reasons">
<h4>Why this run is not comparable</h4>
<ul>
{result.notComparable.map((reason, index) => (
<li key={`${reason.code}-${reason.flavour ?? "both"}-${index}`}>
<strong>{reason.code.replaceAll("-", " ")}:</strong>{" "}
{reason.message}
</li>
))}
</ul>
</section>
) : null}
<div className="comparison-side-grid">
<SideResult side={result.sides[0]} />
<SideResult side={result.sides[1]} />
</div>
<section className="comparison-differences">
<h4>Syntax, execution and replacement differences</h4>
{visibleDifferences.length === 0 ? (
<p>
No retained semantic differences for this subject. Engine metadata
can still differ.
</p>
) : (
<table>
<thead>
<tr>
<th>Kind</th>
<th>Finding</th>
<th>ECMAScript</th>
<th>PCRE2</th>
</tr>
</thead>
<tbody>
{visibleDifferences.map((difference, index) => (
<tr key={`${difference.kind}-${index}`}>
<td>{difference.kind.replaceAll("-", " ")}</td>
<td>{difference.summary}</td>
<td>
<code>{preview(difference.left)}</code>
</td>
<td>
<code>{preview(difference.right)}</code>
</td>
</tr>
))}
</tbody>
</table>
)}
{visibleDifferences.length < result.totalDifferences ? (
<p className="comparison-limit-note">
Rendering the first {visibleDifferences.length.toLocaleString()} of{" "}
{result.totalDifferences.toLocaleString()} differences.
</p>
) : null}
</section>
{visibleAlignments.length > 0 ? (
<section className="comparison-alignments">
<h4>Match alignment by normalized editor range</h4>
<table>
<thead>
<tr>
<th>Alignment</th>
<th>ECMAScript UTF-16</th>
<th>PCRE2 UTF-16</th>
<th>Equal</th>
</tr>
</thead>
<tbody>
{visibleAlignments.map((alignment, index) => (
<tr key={`${alignment.alignment}-${index}`}>
<td>{alignment.alignment.replaceAll("-", " ")}</td>
<td>
{alignment.left
? `${alignment.left.range.startUtf16}${alignment.left.range.endUtf16}`
: "—"}
</td>
<td>
{alignment.right
? `${alignment.right.range.startUtf16}${alignment.right.range.endUtf16}`
: "—"}
</td>
<td>{alignment.equal ? "yes" : "no"}</td>
</tr>
))}
</tbody>
</table>
{visibleAlignments.length < result.totalMatchAlignments ? (
<p className="comparison-limit-note">
Rendering the first {visibleAlignments.length.toLocaleString()} of{" "}
{result.totalMatchAlignments.toLocaleString()} alignments.
</p>
) : null}
</section>
) : null}
</div>
);
}
export function ComparisonCodePanel({
activeFlavour,
activeFlags,
activeOptions,
pattern: initialPattern,
subject: initialSubject,
replacement: initialReplacement,
scanAll: initialScanAll,
timeoutMs: initialTimeoutMs,
onClose,
}: ComparisonCodePanelProps) {
const [tab, setTab] = useState<PanelTab>("compare");
const [patternModel, setPatternModel] =
useState<ComparisonPatternModel>("shared");
const [sharedPattern, setSharedPattern] = useState(initialPattern);
const [ecmaPattern, setEcmaPattern] = useState(initialPattern);
const [pcrePattern, setPcrePattern] = useState(initialPattern);
const [subject, setSubject] = useState(initialSubject);
const [operation, setOperation] = useState<"match" | "replace">("match");
const [ecmaReplacement, setEcmaReplacement] = useState(initialReplacement);
const [pcreReplacement, setPcreReplacement] = useState(initialReplacement);
const [ecmaFlags, setEcmaFlags] = useState(() =>
initialFlags(activeFlavour, activeFlags, "ecmascript"),
);
const [pcreFlags, setPcreFlags] = useState(() =>
initialFlags(activeFlavour, activeFlags, "pcre2"),
);
const [pcreOptions, setPcreOptions] = useState(() =>
initialOptions(activeFlavour, activeOptions),
);
const [scanAll, setScanAll] = useState(initialScanAll);
const [timeoutMs, setTimeoutMs] = useState(initialTimeoutMs);
const [maximumMatches, setMaximumMatches] = useState(1_000);
const [maximumCaptureRows, setMaximumCaptureRows] = useState(10_000);
const [maximumOutputBytes, setMaximumOutputBytes] = useState(1024 * 1024);
const [status, setStatus] = useState<PanelStatus>({
kind: "idle",
message: "Configure two exact engine requests.",
});
const [result, setResult] = useState<RegexComparisonResult>();
const [generated, setGenerated] = useState<GeneratedPcre2CProgram>();
const [generatedStatus, setGeneratedStatus] = useState("");
const orchestrator = useRef<ComparisonOrchestrator | undefined>(undefined);
const comparisonRevision = useRef(0);
useEffect(
() => () => {
comparisonRevision.current += 1;
orchestrator.current?.dispose();
orchestrator.current = undefined;
},
[],
);
const pcreOptionValues = useMemo(
() => ({
matchLimit: Number(pcreOptions.matchLimit),
depthLimit: Number(pcreOptions.depthLimit),
heapLimitKib: Number(pcreOptions.heapLimitKib),
}),
[pcreOptions],
);
const compare = async () => {
const revision = ++comparisonRevision.current;
setResult(undefined);
setStatus({
kind: "running",
message: "Running independently killable ECMAScript and PCRE2 workers…",
});
try {
orchestrator.current ??= new ComparisonOrchestrator();
const comparison = await orchestrator.current.compare({
schemaVersion: 1,
patternModel,
operation,
subject,
scanAll,
maximumMatches,
maximumCaptureRows,
maximumOutputBytes,
timeoutMs,
sides: [
{
flavour: "ecmascript",
flavourVersion: ECMASCRIPT.defaultVersion,
pattern: patternModel === "shared" ? sharedPattern : ecmaPattern,
flags: ecmaFlags,
options: {},
...(operation === "replace"
? { replacement: ecmaReplacement }
: {}),
},
{
flavour: "pcre2",
flavourVersion: PCRE2.defaultVersion,
pattern: patternModel === "shared" ? sharedPattern : pcrePattern,
flags: pcreFlags,
options: pcreOptionValues,
...(operation === "replace"
? { replacement: pcreReplacement }
: {}),
},
],
});
if (revision !== comparisonRevision.current) return;
setResult(comparison);
setStatus({
kind: "ready",
message:
comparison.status === "not-comparable"
? "Both side outcomes are retained; equivalence is not claimed."
: "Both exact requests completed.",
});
} catch (error) {
if (revision !== comparisonRevision.current) return;
setStatus({
kind: "error",
message: error instanceof Error ? error.message : String(error),
});
}
};
const cancel = () => {
comparisonRevision.current += 1;
orchestrator.current?.cancel();
setStatus({
kind: "idle",
message: "Comparison cancelled; both active workers were terminated.",
});
};
const generate = () => {
setGenerated(undefined);
setGeneratedStatus("");
try {
const program = generatePcre2C({
flavour: "pcre2",
flavourVersion: PCRE2.defaultVersion,
operation,
pattern: patternModel === "shared" ? sharedPattern : pcrePattern,
flags: pcreFlags,
options: pcreOptionValues,
subject,
scanAll,
...(operation === "replace" ? { replacement: pcreReplacement } : {}),
maximumMatches,
maximumCaptureRows,
maximumOutputBytes,
});
setGenerated(program);
setGeneratedStatus(
"Generated locally from the exact PCRE2 snapshot shown above.",
);
} catch (error) {
setGeneratedStatus(
error instanceof Error ? error.message : String(error),
);
}
};
const setPcreOption = (name: string, value: number) => {
setPcreOptions((current) => ({ ...current, [name]: value }));
};
return (
<section
className="panel comparison-code-panel"
aria-labelledby="comparison-code-heading"
>
<header className="panel-heading">
<div>
<p className="eyebrow">Evidence, not automatic translation</p>
<h2 id="comparison-code-heading">Compare &amp; generate</h2>
</div>
<button
type="button"
className="icon-button"
aria-label="Close compare and generate"
data-dialog-initial-focus
onClick={onClose}
>
×
</button>
</header>
<div className="comparison-tabs" role="tablist" aria-label="Tool">
<button
type="button"
role="tab"
aria-selected={tab === "compare"}
onClick={() => setTab("compare")}
>
Compare engines
</button>
<button
type="button"
role="tab"
aria-selected={tab === "code"}
onClick={() => setTab("code")}
>
PCRE2 C code
</button>
</div>
<div className="comparison-config">
<div className="comparison-config-row">
<label>
<span>Pattern model</span>
<select
value={patternModel}
onChange={(event) =>
setPatternModel(event.target.value as ComparisonPatternModel)
}
>
<option value="shared">Shared pattern · unchanged</option>
<option value="variants">Explicit per-flavour variants</option>
</select>
</label>
<label>
<span>Operation</span>
<select
value={operation}
onChange={(event) =>
setOperation(event.target.value as "match" | "replace")
}
>
<option value="match">Match</option>
<option value="replace">Replace</option>
</select>
</label>
<label className="check-control comparison-scan-control">
<input
type="checkbox"
checked={scanAll}
onChange={(event) => setScanAll(event.target.checked)}
/>
Scan all explicitly
</label>
</div>
{patternModel === "shared" ? (
<label className="comparison-text-field">
<span>Shared pattern sent unchanged to both engines</span>
<textarea
aria-label="Shared comparison pattern"
value={sharedPattern}
maxLength={DEFAULT_REGEX_LIMITS.patternHardLengthUtf16}
onChange={(event) => setSharedPattern(event.target.value)}
/>
</label>
) : (
<div className="comparison-variant-grid">
<label className="comparison-text-field">
<span>ECMAScript pattern variant</span>
<textarea
value={ecmaPattern}
maxLength={DEFAULT_REGEX_LIMITS.patternHardLengthUtf16}
onChange={(event) => setEcmaPattern(event.target.value)}
/>
</label>
<label className="comparison-text-field">
<span>PCRE2 pattern variant</span>
<textarea
value={pcrePattern}
maxLength={DEFAULT_REGEX_LIMITS.patternHardLengthUtf16}
onChange={(event) => setPcrePattern(event.target.value)}
/>
</label>
</div>
)}
<div className="comparison-variant-grid">
{(
[
["ecmascript", ECMASCRIPT, ecmaFlags, setEcmaFlags],
["pcre2", PCRE2, pcreFlags, setPcreFlags],
] as const
).map(([flavour, definition, selected, setSelected]) => (
<fieldset className="comparison-flag-selector" key={flavour}>
<legend>{definition.label} flags · exact</legend>
{definition.flags.map((flag) => (
<label key={flag.value} title={flag.label}>
<input
type="checkbox"
checked={selected.includes(flag.value)}
onChange={(event) =>
setSelected(
toggleFlag(
selected,
flag.value,
event.target.checked,
flavour,
),
)
}
/>
<code>{flag.value}</code>
<span>{flag.label}</span>
</label>
))}
</fieldset>
))}
</div>
<fieldset className="comparison-limit-grid">
<legend>PCRE2 native and host bounds</legend>
<label>
<span>Match steps</span>
<input
type="number"
value={pcreOptionValues.matchLimit}
min={1}
max={100_000_000}
onChange={(event) =>
setPcreOption("matchLimit", Number(event.target.value))
}
/>
</label>
<label>
<span>Depth</span>
<input
type="number"
value={pcreOptionValues.depthLimit}
min={1}
max={100_000}
onChange={(event) =>
setPcreOption("depthLimit", Number(event.target.value))
}
/>
</label>
<label>
<span>Heap KiB</span>
<input
type="number"
value={pcreOptionValues.heapLimitKib}
min={1}
max={131_072}
onChange={(event) =>
setPcreOption("heapLimitKib", Number(event.target.value))
}
/>
</label>
<label>
<span>Matches</span>
<input
type="number"
value={maximumMatches}
min={1}
max={DEFAULT_REGEX_LIMITS.maximumMatches}
onChange={(event) =>
setMaximumMatches(Number(event.target.value))
}
/>
</label>
<label>
<span>Capture rows</span>
<input
type="number"
value={maximumCaptureRows}
min={1}
max={DEFAULT_REGEX_LIMITS.maximumCaptureRows}
onChange={(event) =>
setMaximumCaptureRows(Number(event.target.value))
}
/>
</label>
<label>
<span>Worker ms</span>
<input
type="number"
value={timeoutMs}
min={1}
max={DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs}
onChange={(event) => setTimeoutMs(Number(event.target.value))}
/>
</label>
</fieldset>
{operation === "replace" ? (
<div className="comparison-variant-grid">
<label className="comparison-text-field">
<span>ECMAScript replacement · exact syntax</span>
<textarea
value={ecmaReplacement}
maxLength={DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16}
onChange={(event) => setEcmaReplacement(event.target.value)}
/>
</label>
<label className="comparison-text-field">
<span>PCRE2 replacement · exact syntax</span>
<textarea
value={pcreReplacement}
maxLength={DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16}
onChange={(event) => setPcreReplacement(event.target.value)}
/>
</label>
<label>
<span>Output byte cap</span>
<input
type="number"
value={maximumOutputBytes}
min={1}
max={DEFAULT_REGEX_LIMITS.maximumReplacementOutputBytes}
onChange={(event) =>
setMaximumOutputBytes(Number(event.target.value))
}
/>
</label>
</div>
) : null}
<label className="comparison-text-field comparison-subject">
<span>
Shared subject · {utf8ByteLength(subject).toLocaleString()} UTF-8
bytes
</span>
<textarea
aria-label="Comparison subject"
value={subject}
onChange={(event) => setSubject(event.target.value)}
/>
</label>
</div>
{tab === "compare" ? (
<div role="tabpanel" className="comparison-tab-panel">
<div className="comparison-actions">
<button
type="button"
className="primary-button"
disabled={status.kind === "running"}
onClick={() => void compare()}
>
{status.kind === "running" ? "Comparing…" : "Run comparison"}
</button>
{status.kind === "running" ? (
<button
type="button"
className="secondary-button"
onClick={cancel}
>
Cancel both workers
</button>
) : null}
<p className={`comparison-panel-status status-${status.kind}`}>
{status.message}
</p>
</div>
{result ? <ComparisonResultView result={result} /> : null}
</div>
) : (
<div role="tabpanel" className="comparison-tab-panel codegen-panel">
<div className="comparison-actions">
<button type="button" className="primary-button" onClick={generate}>
Generate reviewed C17
</button>
{generated ? (
<>
<button
type="button"
className="secondary-button"
onClick={() => {
void writeClipboardText(generated.source)
.then(() =>
setGeneratedStatus("Complete generated source copied."),
)
.catch((error: unknown) =>
setGeneratedStatus(
error instanceof Error
? error.message
: String(error),
),
);
}}
>
Copy complete source
</button>
<button
type="button"
className="secondary-button"
onClick={() => downloadSource(generated)}
>
Download .c
</button>
</>
) : null}
<p role="status">{generatedStatus}</p>
</div>
{generated ? (
<section className="generated-code-result">
<header>
<div>
<p className="eyebrow">
{generated.engineIdentity} · {generated.represents}
</p>
<h3>{generated.fileName}</h3>
</div>
<span className="provenance-badge">
Exact UTF-8 byte arrays
</span>
</header>
<p>
Compile: <code>{generated.compileCommand}</code>
</p>
<ul>
{generated.caveats.map((caveat) => (
<li key={caveat}>{caveat}</li>
))}
</ul>
<pre tabIndex={0}>
<code>{generated.source}</code>
</pre>
</section>
) : (
<p className="comparison-empty-state">
Generation consumes the exact PCRE2 pattern, flags, native limits,
subject and optional replacement above. It never powers the
browser runtime.
</p>
)}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,253 @@
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RegexExecutionRequest } from "../regex/model/match";
const engineHarness = vi.hoisted(() => ({
cancelCalls: 0,
disposeCalls: 0,
executeRequests: [] as RegexExecutionRequest[],
}));
vi.mock("../regex/execution/EngineSupervisor", () => ({
EngineSupervisor: class {
execute(request: RegexExecutionRequest) {
engineHarness.executeRequests.push(request);
return Promise.resolve({
accepted: true,
engine: {
flavour: "ecmascript",
adapterVersion: "test",
engineName: "Test engine",
engineVersion: "1",
runtimeVersion: "1",
offsetUnit: "utf16",
capabilities: {
compilation: true,
matching: true,
replacement: true,
namedCaptures: true,
captureHistory: false,
actualTrace: false,
benchmark: false,
},
},
flags: {
userFlags: "",
effectiveFlags: "g",
internallyAddedIndicesFlag: true,
internallyAddedGlobalFlag: true,
},
matches: [
{
matchNumber: 1,
value: "42",
valueStatus: "complete",
range: { startUtf16: 0, endUtf16: 2 },
nativeRange: { start: 0, end: 2, unit: "utf16" },
captures: [],
},
],
diagnostics: [],
elapsedMs: 2,
truncated: false,
});
}
replace(): Promise<never> {
return Promise.reject(new Error("Unexpected replacement call"));
}
cancel(): void {
engineHarness.cancelCalls += 1;
}
dispose(): void {
engineHarness.disposeCalls += 1;
}
},
}));
import { CorpusPanel } from "./CorpusPanel";
afterEach(() => {
cleanup();
engineHarness.cancelCalls = 0;
engineHarness.disposeCalls = 0;
engineHarness.executeRequests.length = 0;
});
function renderPanel(
overrides: Partial<Parameters<typeof CorpusPanel>[0]> = {},
) {
return render(
<CorpusPanel
active
flavour="ecmascript"
flavourVersion="2025"
options={{}}
pattern="\\d+"
flags={[]}
captureMetadata={[]}
patternAccepted
replacement="[$&]"
replacementAccepted
onReplacementChange={() => {}}
timeoutMs={2_000}
{...overrides}
/>,
);
}
describe("CorpusPanel", () => {
it("adds pasted text and produces an exhaustive per-document summary", async () => {
renderPanel();
await userEvent.type(
screen.getByRole("textbox", { name: "Pasted corpus text" }),
"42",
);
await userEvent.click(
screen.getByRole("button", { name: "Add pasted text" }),
);
expect(
screen.getByRole("list", { name: "Corpus documents" }),
).toHaveTextContent("pasted-text.txt");
await userEvent.click(screen.getByRole("button", { name: "Scan corpus" }));
expect(
await screen.findByRole("table", { name: "Corpus document results" }),
).toHaveTextContent("complete");
expect(engineHarness.executeRequests).toHaveLength(1);
expect(engineHarness.executeRequests[0]).toMatchObject({
subject: "42",
scanAll: true,
});
expect(
screen.getByRole("button", { name: "Download summary JSON" }),
).toBeEnabled();
});
it("blocks invalid runs and clears results when configuration changes", async () => {
const rendered = renderPanel({ patternAccepted: false });
fireEvent.click(screen.getByRole("button", { name: "Add pasted text" }));
expect(screen.getByRole("button", { name: "Scan corpus" })).toBeDisabled();
expect(screen.getByRole("alert")).toHaveTextContent(
"syntax provider accepts",
);
rendered.rerender(
<CorpusPanel
active
flavour="ecmascript"
flavourVersion="2025"
options={{}}
pattern="changed"
flags={[]}
captureMetadata={[]}
patternAccepted
replacement="[$&]"
replacementAccepted
onReplacementChange={() => {}}
timeoutMs={2_000}
/>,
);
expect(screen.getByRole("button", { name: "Scan corpus" })).toBeEnabled();
expect(screen.getByRole("status")).toHaveTextContent(
"previous corpus results were cleared",
);
});
it("cancels an active batch when the workspace is left", async () => {
let resolveExecution: ((value: never) => void) | undefined;
const pending = new Promise<never>((resolve) => {
resolveExecution = resolve;
});
const execute = vi
.spyOn(
(await import("../regex/execution/EngineSupervisor")).EngineSupervisor
.prototype,
"execute",
)
.mockReturnValue(pending);
const rendered = renderPanel();
fireEvent.click(screen.getByRole("button", { name: "Add pasted text" }));
fireEvent.click(screen.getByRole("button", { name: "Scan corpus" }));
expect(
screen.getByRole("button", { name: "Cancel corpus run" }),
).toBeEnabled();
rendered.rerender(
<CorpusPanel
active={false}
flavour="ecmascript"
flavourVersion="2025"
options={{}}
pattern="\\d+"
flags={[]}
captureMetadata={[]}
patternAccepted
replacement="[$&]"
replacementAccepted
onReplacementChange={() => {}}
timeoutMs={2_000}
/>,
);
expect(engineHarness.cancelCalls).toBeGreaterThan(0);
execute.mockRestore();
await act(async () => {
resolveExecution?.(undefined as never);
});
});
it("does not publish stale batch results after configuration changes", async () => {
let resolveExecution: ((value: never) => void) | undefined;
const pending = new Promise<never>((resolve) => {
resolveExecution = resolve;
});
const execute = vi
.spyOn(
(await import("../regex/execution/EngineSupervisor")).EngineSupervisor
.prototype,
"execute",
)
.mockReturnValue(pending);
const rendered = renderPanel();
fireEvent.click(screen.getByRole("button", { name: "Add pasted text" }));
fireEvent.click(screen.getByRole("button", { name: "Scan corpus" }));
rendered.rerender(
<CorpusPanel
active
flavour="ecmascript"
flavourVersion="2025"
options={{}}
pattern="changed"
flags={[]}
captureMetadata={[]}
patternAccepted
replacement="[$&]"
replacementAccepted
onReplacementChange={() => {}}
timeoutMs={2_000}
/>,
);
await act(async () => {
resolveExecution?.(undefined as never);
});
expect(
screen.queryByRole("table", { name: "Corpus document results" }),
).not.toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent(
"previous corpus results were cleared",
);
execute.mockRestore();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,314 @@
.generation-dialog {
width: min(90rem, calc(100% - 2rem));
}
.generation-panel {
display: grid;
gap: 1rem;
min-width: 0;
max-height: calc(100vh - 2rem);
overflow: auto;
}
.generation-disclaimer,
.generation-unavailable,
.generation-status,
.generation-summary,
.generation-coverage,
.generation-cases,
.generation-discarded {
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.82);
background: color-mix(
in srgb,
var(--toolbox-surface) 94%,
var(--toolbox-accent) 6%
);
}
.generation-disclaimer {
margin: 0;
padding: 0.8rem 0.95rem;
color: var(--toolbox-muted);
}
.generation-unavailable {
display: grid;
gap: 0.35rem;
padding: 1rem;
}
.generation-unavailable span {
color: var(--toolbox-muted);
}
.generation-settings {
display: grid;
gap: 0.8rem;
padding: 1rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.82);
background: var(--toolbox-surface);
}
.generation-settings-primary,
.generation-settings-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.7rem;
}
.generation-settings label {
display: grid;
gap: 0.3rem;
min-width: 0;
color: var(--toolbox-muted);
font-size: 0.82rem;
font-weight: 700;
}
.generation-settings input {
min-width: 0;
width: 100%;
}
.generation-settings details {
border-top: 1px solid var(--toolbox-border);
padding-top: 0.65rem;
}
.generation-settings details summary,
.generation-discarded summary,
.generation-coverage details summary {
cursor: pointer;
font-weight: 750;
}
.generation-settings details[open] summary {
margin-bottom: 0.65rem;
}
.generation-run-actions,
.generation-case-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
}
.generation-run-actions small {
color: var(--toolbox-muted);
}
.generation-status {
display: grid;
gap: 0.4rem;
padding: 0.75rem 0.9rem;
}
.generation-status.is-error {
border-color: color-mix(
in srgb,
var(--toolbox-danger) 55%,
var(--toolbox-border)
);
}
.generation-status progress {
width: 100%;
}
.generation-status span {
color: var(--toolbox-muted);
font-size: 0.82rem;
}
.generation-results {
display: grid;
gap: 1rem;
}
.generation-summary,
.generation-coverage,
.generation-cases,
.generation-discarded {
padding: 1rem;
}
.generation-summary dl {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.55rem;
margin: 0;
}
.generation-summary dl > div {
min-width: 0;
padding: 0.65rem;
border-radius: calc(var(--toolbox-radius) * 0.68);
background: var(--toolbox-surface-soft);
}
.generation-summary dt {
color: var(--toolbox-muted);
font-size: 0.75rem;
font-weight: 750;
text-transform: uppercase;
}
.generation-summary dd {
overflow-wrap: anywhere;
margin: 0.2rem 0 0;
}
.generation-warnings {
margin: 0.75rem 0 0;
color: var(--toolbox-muted);
}
.generation-coverage > header,
.generation-cases > header {
display: flex;
justify-content: space-between;
align-items: start;
gap: 1rem;
margin-bottom: 0.75rem;
}
.generation-coverage h3,
.generation-cases h3 {
margin: 0;
}
.generation-coverage > ul,
.unsupported-generation-list,
.generated-case-list,
.generation-discarded ul {
display: grid;
gap: 0.55rem;
margin: 0;
padding: 0;
list-style: none;
}
.generation-coverage > ul > li {
display: grid;
grid-template-columns: 7.5rem minmax(9rem, 0.45fr) minmax(0, 1fr);
align-items: start;
gap: 0.55rem;
padding: 0.6rem;
border-radius: calc(var(--toolbox-radius) * 0.68);
background: var(--toolbox-surface-soft);
}
.generation-coverage .status {
justify-self: start;
}
.generation-coverage details {
margin-top: 0.8rem;
}
.unsupported-generation-list {
margin-top: 0.65rem;
}
.unsupported-generation-list li,
.generation-discarded li {
display: grid;
grid-template-columns: minmax(10rem, 0.35fr) minmax(8rem, 0.25fr) minmax(
0,
1fr
);
gap: 0.6rem;
align-items: start;
padding: 0.55rem;
border-radius: calc(var(--toolbox-radius) * 0.68);
background: var(--toolbox-surface-soft);
}
.unsupported-generation-list code,
.generation-discarded code {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.generated-case-list li {
display: grid;
gap: 0.5rem;
min-width: 0;
padding: 0.7rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.68);
background: var(--toolbox-surface-soft);
}
.generated-case-list label {
display: flex;
align-items: start;
gap: 0.55rem;
}
.generated-case-list label span {
display: grid;
gap: 0.15rem;
}
.generated-case-list small {
color: var(--toolbox-muted);
}
.generated-case-list > li > code {
display: block;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.generation-feature-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.generation-feature-chips span {
padding: 0.2rem 0.45rem;
border: 1px solid var(--toolbox-border);
border-radius: 999px;
color: var(--toolbox-muted);
font-size: 0.72rem;
}
.generation-discarded > p {
color: var(--toolbox-muted);
}
.generation-discarded[open] summary {
margin-bottom: 0.5rem;
}
@media (max-width: 900px) {
.generation-settings-primary,
.generation-settings-grid,
.generation-summary dl {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.generation-coverage > ul > li,
.unsupported-generation-list li,
.generation-discarded li {
grid-template-columns: 1fr;
}
}
@media (max-width: 620px) {
.generation-settings-primary,
.generation-settings-grid,
.generation-summary dl {
grid-template-columns: 1fr;
}
.generation-coverage > header,
.generation-cases > header {
display: grid;
}
}

View File

@@ -0,0 +1,326 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CaseGenerationClient } from "../regex/generation/CaseGenerationOrchestrator";
import type {
CaseGenerationResult,
VerifiedGeneratedCase,
} from "../regex/generation/generation.types";
import { WorkerRequestError } from "../regex/execution/WorkerSupervisor";
import { EcmaScriptSyntaxProvider } from "../regex/syntax/providers/ecmascript/EcmaScriptSyntaxProvider";
import { GenerationPanel } from "./GenerationPanel";
const provider = new EcmaScriptSyntaxProvider();
function generatedCase(
id: string,
expectation: "should-match" | "should-not-match",
): VerifiedGeneratedCase {
const subject = expectation === "should-match" ? "cat" : "dog";
return {
id,
name: `Generated ${id}`,
subject,
subjectBytes: subject.length,
expectation,
features:
expectation === "should-match" ? ["shortest"] : ["likely-near-miss"],
notes: [],
elapsedMs: 0.5,
matched: expectation === "should-match",
matchCount: expectation === "should-match" ? 1 : 0,
provenance: {
kind: "generated",
generatorId: "regex-tools-ast-cases",
generatorVersion: "1",
seed: "case-seed-1",
candidateId: id,
intendedOutcome: expectation === "should-match" ? "match" : "no-match",
},
};
}
function fixtureResult(): CaseGenerationResult {
return {
status: "partial",
seed: "case-seed-1",
seedHash: "1234abcd",
flavour: "ecmascript",
cases: [
generatedCase("positive", "should-match"),
generatedCase("negative", "should-not-match"),
],
discarded: [
{
candidateId: "discarded",
intendedOutcome: "no-match",
reason: "unexpected-match",
detail: "The actual engine matched it.",
subjectBytes: 3,
subjectPreview: "cat",
},
],
coverage: [
{
feature: "shortest",
status: "covered",
detail: "Shortest path requested.",
relevantNodes: 1,
},
{
feature: "alternative-coverage",
status: "not-applicable",
detail: "No alternatives.",
relevantNodes: 0,
},
],
unsupportedConstructs: [
{
id: "unsupported-lookahead",
kind: "lookahead",
range: { startUtf16: 0, endUtf16: 3 },
rawPreview: "(?=cat)",
reason: "Lookaround is verified but not synthesized.",
},
],
engine: {
flavour: "ecmascript",
adapterVersion: "fixture",
engineName: "Native ECMAScript RegExp",
engineVersion: "Fixture Browser 1",
runtimeVersion: "Fixture Browser 1",
offsetUnit: "utf16",
capabilities: {
compilation: true,
matching: true,
replacement: true,
namedCaptures: true,
captureHistory: false,
actualTrace: false,
benchmark: true,
},
},
candidateAttempts: 5,
engineExecutions: 3,
generatedCandidateBytes: 9,
verifiedSubjectBytes: 6,
wallTimeMs: 4.5,
warnings: [],
};
}
function client(
overrides: Partial<CaseGenerationClient> = {},
): CaseGenerationClient {
return {
run: vi.fn().mockImplementation(async (_input, onProgress) => {
onProgress?.({
phase: "verifying",
completed: 1,
total: 3,
retained: 1,
message: "Verifying fixture candidate.",
});
return fixtureResult();
}),
cancel: vi.fn(),
dispose: vi.fn(),
...overrides,
};
}
async function renderPanel(
overrides: Partial<Parameters<typeof GenerationPanel>[0]> = {},
) {
const pattern = overrides.pattern ?? "cat";
const syntax =
overrides.syntax ??
(await provider.parsePattern({
flavour: "ecmascript",
flavourVersion: "2025",
pattern,
flags: [],
options: {},
}));
const currentClient = client();
const rendered = render(
<GenerationPanel
active
flavour="ecmascript"
flavourVersion="2025"
pattern={pattern}
flags={[]}
options={{}}
scanAll={false}
syntax={syntax}
createOrchestrator={() => currentClient}
{...overrides}
/>,
);
return { ...rendered, currentClient, syntax };
}
afterEach(() => {
cleanup();
});
describe("GenerationPanel", () => {
it("runs deterministic generation, renders honest coverage and adds verified cases to tests", async () => {
const onAddToTests = vi.fn();
const onSelectPatternRange = vi.fn();
const { currentClient } = await renderPanel({
onAddToTests,
onSelectPatternRange,
});
await userEvent.click(
screen.getByRole("button", { name: "Generate & verify" }),
);
expect(
await screen.findByText(/Native ECMAScript RegExp · Fixture Browser 1/u),
).toBeVisible();
expect(screen.getByText("case-seed-1")).toBeVisible();
expect(
screen.getByRole("region", { name: "Generation summary" }),
).toHaveTextContent("1234abcd");
expect(screen.getByText("Shortest path requested.")).toBeVisible();
expect(screen.getAllByText("actual engine verified")).toHaveLength(2);
await userEvent.click(
screen.getByText(/1 unsupported or partially synthesized construct/u),
);
expect(currentClient.run).toHaveBeenCalledWith(
expect.objectContaining({
seed: "case-seed-1",
flavour: "ecmascript",
pattern: "cat",
}),
expect.any(Function),
);
await userEvent.click(
screen.getByRole("button", {
name: /lookahead · 03/u,
}),
);
expect(onSelectPatternRange).toHaveBeenCalledWith({
startUtf16: 0,
endUtf16: 3,
});
await userEvent.click(
screen.getByText(/Advanced: 1 rejected candidate diagnostic/u),
);
expect(screen.getByText(/unexpected match/u)).toBeVisible();
await userEvent.click(
screen.getByRole("button", { name: "Add selected to tests" }),
);
expect(onAddToTests).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
expectation: "should-match",
provenance: expect.objectContaining({ seed: "case-seed-1" }),
}),
expect.objectContaining({ expectation: "should-not-match" }),
]),
);
expect(
screen.getByText(/Added 2 generated cases to the unit-test suite/u),
).toBeVisible();
});
it("shows strict setting errors without starting worker execution", async () => {
const { currentClient } = await renderPanel();
const maximumCases = screen.getByRole("spinbutton", {
name: "Retain at most",
});
await userEvent.clear(maximumCases);
await userEvent.type(maximumCases, "500");
await userEvent.click(screen.getByText("Advanced generation bounds"));
const attempts = screen.getByRole("spinbutton", {
name: "Candidate attempts",
});
await userEvent.clear(attempts);
await userEvent.type(attempts, "100");
await userEvent.click(
screen.getByRole("button", { name: "Generate & verify" }),
);
expect(
await screen.findByText(/Maximum candidate attempts must be/u),
).toBeVisible();
expect(currentClient.run).not.toHaveBeenCalled();
});
it("does not expose the ECMAScript generator for PCRE2", async () => {
const { currentClient } = await renderPanel({ flavour: "pcre2" });
expect(
screen.getByText(
/current PCRE2 syntax provider is intentionally partial/u,
),
).toBeVisible();
expect(
screen.queryByRole("button", { name: "Generate & verify" }),
).not.toBeInTheDocument();
expect(currentClient.run).not.toHaveBeenCalled();
});
it("clears verified cases immediately when the pattern changes", async () => {
const rendered = await renderPanel();
await userEvent.click(
screen.getByRole("button", { name: "Generate & verify" }),
);
expect(await screen.findByText("Generated positive")).toBeVisible();
rendered.rerender(
<GenerationPanel
active
flavour="ecmascript"
flavourVersion="2025"
pattern="changed"
flags={[]}
options={{}}
scanAll={false}
syntax={rendered.syntax}
createOrchestrator={() => rendered.currentClient}
/>,
);
expect(screen.queryByText("Generated positive")).not.toBeInTheDocument();
expect(
await screen.findByText(/previous generated cases were cleared/u),
).toBeVisible();
});
it("terminates synthesis and engine workers on cancellation", async () => {
let rejectRun: ((error: Error) => void) | undefined;
const pending = new Promise<CaseGenerationResult>((_resolve, reject) => {
rejectRun = reject;
});
const currentClient = client({
run: vi.fn().mockReturnValue(pending),
cancel: vi.fn().mockImplementation(() => {
rejectRun?.(
new WorkerRequestError("cancelled", "fixture cancellation"),
);
}),
});
await renderPanel({ createOrchestrator: () => currentClient });
await userEvent.click(
screen.getByRole("button", { name: "Generate & verify" }),
);
await userEvent.click(
await screen.findByRole("button", { name: "Cancel generation" }),
);
await waitFor(() => expect(currentClient.cancel).toHaveBeenCalled());
expect(
await screen.findByText(
/Generation cancelled; its workers were terminated/u,
),
).toBeVisible();
expect(currentClient.dispose).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,829 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CaseGenerationOrchestrator,
type CaseGenerationClient,
} from "../regex/generation/CaseGenerationOrchestrator";
import {
DEFAULT_GENERATION_SETTINGS,
GENERATION_LIMITS,
} from "../regex/generation/generation-limits";
import type {
CaseGenerationProgress,
CaseGenerationResult,
CandidateGenerationSettings,
VerifiedGeneratedCase,
} from "../regex/generation/generation.types";
import {
GENERATED_CASE_GENERATOR_ID,
GENERATED_CASE_GENERATOR_VERSION,
} from "../regex/generation/generation.types";
import { DEFAULT_REGEX_LIMITS } from "../regex/execution/request-limits";
import { WorkerRequestError } from "../regex/execution/WorkerSupervisor";
import type {
RegexEngineOptions,
RegexFlavourId,
} from "../regex/model/flavour";
import type { RegexSyntaxResult, SourceRange } from "../regex/model/syntax";
import "./GenerationPanel.css";
const MAXIMUM_SUBJECT_PREVIEW_UTF16 = 240;
function defaultOrchestrator(): CaseGenerationClient {
return new CaseGenerationOrchestrator();
}
function integer(
source: string,
label: string,
minimum: number,
maximum: number,
): number {
const value = Number(source);
if (
source.trim() === "" ||
!Number.isSafeInteger(value) ||
value < minimum ||
value > maximum
) {
throw new RangeError(
`${label} must be a whole number from ${minimum.toLocaleString()} to ${maximum.toLocaleString()}.`,
);
}
return value;
}
function subjectPreview(subject: string): string {
if (subject.length <= MAXIMUM_SUBJECT_PREVIEW_UTF16) {
return JSON.stringify(subject);
}
return `${JSON.stringify(subject.slice(0, MAXIMUM_SUBJECT_PREVIEW_UTF16))}… (${subject.length.toLocaleString()} UTF-16 units)`;
}
function downloadCases(
result: CaseGenerationResult,
configuration: {
readonly flavour: RegexFlavourId;
readonly flavourVersion?: string;
readonly pattern: string;
readonly flags: readonly string[];
readonly options: RegexEngineOptions;
readonly scanAll: boolean;
},
): void {
const payload = {
schemaVersion: 1,
kind: "regex-tools-generated-cases",
generator: {
id: GENERATED_CASE_GENERATOR_ID,
version: GENERATED_CASE_GENERATOR_VERSION,
seed: result.seed,
seedHash: result.seedHash,
},
configuration,
engine: result.engine,
cases: result.cases.map((candidate) => ({
id: candidate.id,
name: candidate.name,
subject: candidate.subject,
expectation: { kind: candidate.expectation },
features: candidate.features,
provenance: candidate.provenance,
})),
};
const url = URL.createObjectURL(
new Blob([`${JSON.stringify(payload, null, 2)}\n`], {
type: "application/json",
}),
);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `regex-generated-${result.seedHash}.json`;
anchor.click();
globalThis.setTimeout(() => URL.revokeObjectURL(url), 1_000);
}
export interface GenerationPanelProps {
readonly active: boolean;
readonly flavour: RegexFlavourId;
readonly flavourVersion?: string;
readonly pattern: string;
readonly flags: readonly string[];
readonly options: RegexEngineOptions;
readonly scanAll: boolean;
readonly syntax?: RegexSyntaxResult;
readonly onClose?: () => void;
readonly onAddToTests?: (cases: readonly VerifiedGeneratedCase[]) => void;
readonly onSelectPatternRange?: (range: SourceRange) => void;
readonly createOrchestrator?: () => CaseGenerationClient;
}
export function GenerationPanel({
active,
flavour,
flavourVersion,
pattern,
flags,
options,
scanAll,
syntax,
onClose,
onAddToTests,
onSelectPatternRange,
createOrchestrator = defaultOrchestrator,
}: GenerationPanelProps) {
const [seed, setSeed] = useState("case-seed-1");
const [maximumCases, setMaximumCases] = useState(
String(DEFAULT_GENERATION_SETTINGS.maximumCases),
);
const [maximumCandidateAttempts, setMaximumCandidateAttempts] = useState(
String(DEFAULT_GENERATION_SETTINGS.maximumCandidateAttempts),
);
const [randomVariantCount, setRandomVariantCount] = useState(
String(DEFAULT_GENERATION_SETTINGS.randomVariantCount),
);
const [maximumQuantifierRepetitions, setMaximumQuantifierRepetitions] =
useState(String(DEFAULT_GENERATION_SETTINGS.maximumQuantifierRepetitions));
const [maximumSubjectBytes, setMaximumSubjectBytes] = useState(
String(DEFAULT_GENERATION_SETTINGS.maximumSubjectBytes),
);
const [maximumTotalSubjectBytes, setMaximumTotalSubjectBytes] = useState(
String(DEFAULT_GENERATION_SETTINGS.maximumTotalSubjectBytes),
);
const [perCaseTimeoutMs, setPerCaseTimeoutMs] = useState(
String(DEFAULT_GENERATION_SETTINGS.perCaseTimeoutMs),
);
const [maximumWallTimeMs, setMaximumWallTimeMs] = useState(
String(DEFAULT_GENERATION_SETTINGS.maximumWallTimeMs),
);
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState<CaseGenerationProgress>();
const [message, setMessage] = useState(
"No cases have been generated for this configuration.",
);
const [error, setError] = useState<string>();
const [resultRecord, setResultRecord] = useState<{
readonly configurationKey: symbol;
readonly result: CaseGenerationResult;
}>();
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(
new Set(),
);
const orchestrator = useRef<CaseGenerationClient | undefined>(undefined);
const runRevision = useRef(0);
const syntaxCurrent =
syntax?.accepted === true &&
syntax.root.raw === pattern &&
syntax.root.support.flavour === flavour;
const generationAvailable = flavour === "ecmascript" && syntaxCurrent;
const configurationKey = useMemo(
() =>
Symbol(
`generation-${flavour}-${flavourVersion ?? ""}-${flags.join("")}-${JSON.stringify(options)}-${pattern.length}-${String(scanAll)}`,
),
[flags, flavour, flavourVersion, options, pattern, scanAll],
);
const previousConfiguration = useRef(configurationKey);
const result =
resultRecord?.configurationKey === configurationKey
? resultRecord.result
: undefined;
const disposeActive = useCallback(() => {
orchestrator.current?.cancel();
orchestrator.current?.dispose();
orchestrator.current = undefined;
}, []);
useEffect(() => {
if (active) return;
runRevision.current += 1;
disposeActive();
queueMicrotask(() => setRunning(false));
}, [active, disposeActive]);
useEffect(() => {
if (previousConfiguration.current === configurationKey) return;
previousConfiguration.current = configurationKey;
const revision = ++runRevision.current;
disposeActive();
queueMicrotask(() => {
if (runRevision.current !== revision) return;
setRunning(false);
setProgress(undefined);
setResultRecord(undefined);
setSelectedIds(new Set());
setError(undefined);
setMessage(
"Pattern or engine configuration changed; previous generated cases were cleared.",
);
});
}, [configurationKey, disposeActive]);
useEffect(
() => () => {
runRevision.current += 1;
disposeActive();
},
[disposeActive],
);
const settings = (): CandidateGenerationSettings => {
const retained = integer(
maximumCases,
"Maximum retained cases",
1,
DEFAULT_REGEX_LIMITS.maximumGeneratedCases,
);
return {
maximumCases: retained,
maximumCandidateAttempts: integer(
maximumCandidateAttempts,
"Maximum candidate attempts",
retained,
GENERATION_LIMITS.maximumCandidateAttempts,
),
maximumTotalSubjectBytes: integer(
maximumTotalSubjectBytes,
"Maximum aggregate subject bytes",
1,
GENERATION_LIMITS.maximumTotalSubjectBytes,
),
maximumSubjectBytes: integer(
maximumSubjectBytes,
"Maximum bytes per subject",
1,
GENERATION_LIMITS.maximumSubjectBytes,
),
maximumAstNodes: DEFAULT_GENERATION_SETTINGS.maximumAstNodes,
maximumAstDepth: DEFAULT_GENERATION_SETTINGS.maximumAstDepth,
maximumQuantifierRepetitions: integer(
maximumQuantifierRepetitions,
"Maximum synthesized repetitions",
1,
GENERATION_LIMITS.maximumQuantifierRepetitions,
),
randomVariantCount: integer(
randomVariantCount,
"Random variants",
0,
DEFAULT_REGEX_LIMITS.maximumGeneratedCases,
),
perCaseTimeoutMs: integer(
perCaseTimeoutMs,
"Per-case timeout",
GENERATION_LIMITS.minimumPerCaseTimeoutMs,
GENERATION_LIMITS.maximumPerCaseTimeoutMs,
),
maximumWallTimeMs: integer(
maximumWallTimeMs,
"Aggregate wall time",
1,
GENERATION_LIMITS.maximumWallTimeMs,
),
};
};
const generate = async () => {
if (!generationAvailable || !syntax) return;
const revision = ++runRevision.current;
disposeActive();
const client = createOrchestrator();
orchestrator.current = client;
setRunning(true);
setProgress(undefined);
setError(undefined);
setResultRecord(undefined);
setSelectedIds(new Set());
setMessage(
"Starting deterministic synthesis and actual-engine verification…",
);
try {
const next = await client.run(
{
flavour,
...(flavourVersion === undefined ? {} : { flavourVersion }),
pattern,
flags,
options,
scanAll,
root: syntax.root,
captureMetadata: syntax.captures,
syntaxAccepted: syntax.accepted,
seed,
settings: settings(),
},
(value) => {
if (runRevision.current !== revision) return;
setProgress(value);
setMessage(value.message);
},
);
if (runRevision.current !== revision) return;
setResultRecord({ configurationKey, result: next });
setSelectedIds(new Set(next.cases.map((candidate) => candidate.id)));
setMessage(
next.stoppedReason ??
`Generation ${next.status}: ${next.cases.length.toLocaleString()} actual-engine-verified cases retained.`,
);
} catch (cause) {
if (runRevision.current !== revision) return;
if (cause instanceof WorkerRequestError && cause.kind === "cancelled") {
setMessage("Generation cancelled; its workers were terminated.");
} else {
const detail =
cause instanceof Error
? cause.message
: "Generated cases could not be produced.";
setError(detail);
setMessage("Generation settings or worker execution require review.");
}
} finally {
client.dispose();
if (orchestrator.current === client) orchestrator.current = undefined;
if (runRevision.current === revision) {
setRunning(false);
setProgress(undefined);
}
}
};
const cancel = () => {
orchestrator.current?.cancel();
setMessage(
"Cancelling synthesis and verification; active workers are being terminated…",
);
};
const selectedCases =
result?.cases.filter((candidate) => selectedIds.has(candidate.id)) ?? [];
const progressMaximum = Math.max(1, progress?.total ?? 1);
const progressValue = Math.min(progressMaximum, progress?.completed ?? 0);
return (
<section
className="panel generation-panel"
aria-labelledby="generation-heading"
>
<header className="panel-heading">
<div>
<p className="eyebrow">
Normalized AST candidates · actual-engine truth
</p>
<h2 id="generation-heading">Generated test cases</h2>
</div>
<div className="panel-heading-actions">
<span className="provenance-badge">
{generationAvailable
? "ECMAScript generator v1"
: "Coverage unavailable"}
</span>
{onClose ? (
<button
type="button"
className="icon-button"
aria-label="Close generated test cases"
data-dialog-initial-focus
onClick={onClose}
>
×
</button>
) : null}
</div>
</header>
<p className="generation-disclaimer">
Generation samples supported structure; it is not proof of complete
coverage. A positive or negative label appears only after the selected
actual engine confirms that exact subject. Rejected candidates remain
diagnostics, never tests.
</p>
{!generationAvailable ? (
<div className="generation-unavailable" role="status">
<strong>
Generated cases are unavailable for this configuration.
</strong>
<span>
{flavour === "pcre2"
? "The current PCRE2 syntax provider is intentionally partial and does not expose a complete normalized AST. It is not reinterpreted as ECMAScript."
: syntaxCurrent
? "This flavour does not have an implemented generator."
: "Wait for an accepted, current syntax result before generating."}
</span>
</div>
) : (
<form
className="generation-settings"
onSubmit={(event) => {
event.preventDefault();
void generate();
}}
>
<div className="generation-settings-primary">
<label>
<span>Deterministic seed</span>
<input
value={seed}
maxLength={GENERATION_LIMITS.maximumSeedUtf16}
disabled={running}
required
onChange={(event) => setSeed(event.target.value)}
/>
</label>
<label>
<span>Retain at most</span>
<input
type="number"
min={1}
max={DEFAULT_REGEX_LIMITS.maximumGeneratedCases}
value={maximumCases}
disabled={running}
onChange={(event) => setMaximumCases(event.target.value)}
/>
</label>
<label>
<span>Seeded variants</span>
<input
type="number"
min={0}
max={DEFAULT_REGEX_LIMITS.maximumGeneratedCases}
value={randomVariantCount}
disabled={running}
onChange={(event) => setRandomVariantCount(event.target.value)}
/>
</label>
</div>
<details>
<summary>Advanced generation bounds</summary>
<div className="generation-settings-grid">
<label>
<span>Candidate attempts</span>
<input
type="number"
min={1}
max={GENERATION_LIMITS.maximumCandidateAttempts}
value={maximumCandidateAttempts}
disabled={running}
onChange={(event) =>
setMaximumCandidateAttempts(event.target.value)
}
/>
</label>
<label>
<span>Per-case timeout (ms)</span>
<input
type="number"
min={GENERATION_LIMITS.minimumPerCaseTimeoutMs}
max={GENERATION_LIMITS.maximumPerCaseTimeoutMs}
value={perCaseTimeoutMs}
disabled={running}
onChange={(event) => setPerCaseTimeoutMs(event.target.value)}
/>
</label>
<label>
<span>Aggregate wall time (ms)</span>
<input
type="number"
min={1}
max={GENERATION_LIMITS.maximumWallTimeMs}
value={maximumWallTimeMs}
disabled={running}
onChange={(event) => setMaximumWallTimeMs(event.target.value)}
/>
</label>
<label>
<span>Bytes per subject</span>
<input
type="number"
min={1}
max={GENERATION_LIMITS.maximumSubjectBytes}
value={maximumSubjectBytes}
disabled={running}
onChange={(event) =>
setMaximumSubjectBytes(event.target.value)
}
/>
</label>
<label>
<span>Aggregate subject bytes</span>
<input
type="number"
min={1}
max={GENERATION_LIMITS.maximumTotalSubjectBytes}
value={maximumTotalSubjectBytes}
disabled={running}
onChange={(event) =>
setMaximumTotalSubjectBytes(event.target.value)
}
/>
</label>
<label>
<span>Synthesized repetitions</span>
<input
type="number"
min={1}
max={GENERATION_LIMITS.maximumQuantifierRepetitions}
value={maximumQuantifierRepetitions}
disabled={running}
onChange={(event) =>
setMaximumQuantifierRepetitions(event.target.value)
}
/>
</label>
</div>
</details>
<div className="generation-run-actions">
{running ? (
<button type="button" className="primary-button" onClick={cancel}>
Cancel generation
</button>
) : (
<button type="submit" className="primary-button">
Generate &amp; verify
</button>
)}
<small>
Hard caps:{" "}
{DEFAULT_REGEX_LIMITS.maximumGeneratedCases.toLocaleString()}{" "}
retained cases ·{" "}
{GENERATION_LIMITS.maximumTotalSubjectBytes / 1024 / 1024} MiB
candidate text · {GENERATION_LIMITS.maximumWallTimeMs / 1_000} s
wall time
</small>
</div>
</form>
)}
<div
className={`generation-status ${error ? "is-error" : ""}`}
role={error ? "alert" : "status"}
aria-live="polite"
>
<strong>{message}</strong>
{progress ? (
<>
<progress
aria-label="Generated-case progress"
max={progressMaximum}
value={progressValue}
/>
<span>
{progress.phase} · {progress.completed.toLocaleString()} /{" "}
{progress.total.toLocaleString()} ·{" "}
{progress.retained.toLocaleString()} retained
</span>
</>
) : null}
{error ? <span>{error}</span> : null}
</div>
{result ? (
<div className="generation-results">
<section
className="generation-summary"
aria-label="Generation summary"
>
<dl>
<div>
<dt>Status</dt>
<dd>{result.status.replaceAll("-", " ")}</dd>
</div>
<div>
<dt>Seed</dt>
<dd>
<code>{result.seed}</code> · {result.seedHash}
</dd>
</div>
<div>
<dt>Actual engine</dt>
<dd>
{result.engine
? `${result.engine.engineName} · ${result.engine.engineVersion}`
: "No candidate reached engine verification"}
</dd>
</div>
<div>
<dt>Verified cases</dt>
<dd>{result.cases.length.toLocaleString()}</dd>
</div>
<div>
<dt>Engine executions</dt>
<dd>{result.engineExecutions.toLocaleString()}</dd>
</div>
<div>
<dt>Candidate bytes</dt>
<dd>{result.generatedCandidateBytes.toLocaleString()}</dd>
</div>
<div>
<dt>Wall time</dt>
<dd>{result.wallTimeMs.toFixed(1)} ms</dd>
</div>
</dl>
{result.warnings.length > 0 ? (
<ul className="generation-warnings">
{result.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
) : null}
</section>
<section
className="generation-coverage"
aria-labelledby="generation-coverage-heading"
>
<header>
<div>
<p className="eyebrow">Claimed only for generator v1</p>
<h3 id="generation-coverage-heading">Coverage report</h3>
</div>
</header>
<ul>
{result.coverage.map((entry) => (
<li key={entry.feature}>
<span className={`status status-${entry.status}`}>
{entry.status}
</span>
<strong>{entry.feature.replaceAll("-", " ")}</strong>
<span>{entry.detail}</span>
</li>
))}
</ul>
{result.unsupportedConstructs.length > 0 ? (
<details>
<summary>
{result.unsupportedConstructs.length.toLocaleString()}{" "}
unsupported or partially synthesized construct(s)
</summary>
<ul className="unsupported-generation-list">
{result.unsupportedConstructs.map((construct) => (
<li key={construct.id}>
<button
type="button"
className="text-button"
disabled={!onSelectPatternRange}
onClick={() => onSelectPatternRange?.(construct.range)}
>
{construct.kind} · {construct.range.startUtf16}
{construct.range.endUtf16}
</button>
<code>{construct.rawPreview || "empty"}</code>
<span>{construct.reason}</span>
</li>
))}
</ul>
</details>
) : null}
</section>
<section
className="generation-cases"
aria-labelledby="generated-cases-heading"
>
<header>
<div>
<p className="eyebrow">Exact verified subjects</p>
<h3 id="generated-cases-heading">
Retained cases{" "}
<small>({result.cases.length.toLocaleString()})</small>
</h3>
</div>
<div className="generation-case-actions">
<button
type="button"
className="secondary-button"
disabled={result.cases.length === 0}
onClick={() =>
setSelectedIds(
selectedIds.size === result.cases.length
? new Set()
: new Set(
result.cases.map((candidate) => candidate.id),
),
)
}
>
{selectedIds.size === result.cases.length
? "Select none"
: "Select all"}
</button>
<button
type="button"
className="secondary-button"
disabled={result.cases.length === 0}
onClick={() =>
downloadCases(result, {
flavour,
...(flavourVersion === undefined
? {}
: { flavourVersion }),
pattern,
flags,
options,
scanAll,
})
}
>
Export verified JSON
</button>
{onAddToTests ? (
<button
type="button"
className="primary-button"
disabled={selectedCases.length === 0}
onClick={() => {
try {
onAddToTests(selectedCases);
setError(undefined);
setMessage(
`Added ${selectedCases.length.toLocaleString()} generated cases to the unit-test suite with seed provenance.`,
);
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: "Generated cases could not be added.",
);
}
}}
>
Add selected to tests
</button>
) : null}
</div>
</header>
{result.cases.length === 0 ? (
<p className="empty-state">
No candidate satisfied its intended property within the
configured bounds.
</p>
) : (
<ul className="generated-case-list">
{result.cases.map((candidate) => (
<li key={candidate.id}>
<label>
<input
type="checkbox"
aria-label={`Select ${candidate.name}`}
checked={selectedIds.has(candidate.id)}
onChange={(event) => {
const checked = event.target.checked;
setSelectedIds((current) => {
const next = new Set(current);
if (checked) next.add(candidate.id);
else next.delete(candidate.id);
return next;
});
}}
/>
<span>
<strong>{candidate.name}</strong>
<small>
{candidate.expectation.replaceAll("-", " ")} ·{" "}
{candidate.subjectBytes.toLocaleString()} bytes ·{" "}
{candidate.elapsedMs.toFixed(2)} ms
</small>
</span>
</label>
<code>{subjectPreview(candidate.subject)}</code>
<div className="generation-feature-chips">
{candidate.features.map((feature) => (
<span key={feature}>
{feature.replaceAll("-", " ")}
</span>
))}
<span>actual engine verified</span>
</div>
</li>
))}
</ul>
)}
</section>
{result.discarded.length > 0 ? (
<details className="generation-discarded">
<summary>
Advanced: {result.discarded.length.toLocaleString()} rejected
candidate diagnostic(s)
</summary>
<p>
These candidates are not tests. Subjects are clipped here; the
diagnostic records only why each intended property failed.
</p>
<ul>
{result.discarded.map((candidate) => (
<li key={`${candidate.candidateId}-${candidate.reason}`}>
<strong>{candidate.reason.replaceAll("-", " ")}</strong>
<code>{subjectPreview(candidate.subjectPreview)}</code>
<span>{candidate.detail}</span>
</li>
))}
</ul>
</details>
) : null}
</div>
) : null}
</section>
);
}

View File

@@ -1,5 +1,9 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { DEFAULT_REGEX_LIMITS } from "../regex/execution/request-limits"; import { DEFAULT_REGEX_LIMITS } from "../regex/execution/request-limits";
import {
DEFAULT_GENERATION_SETTINGS,
GENERATION_LIMITS,
} from "../regex/generation/generation-limits";
import { manifest } from "../toolbox/manifest"; import { manifest } from "../toolbox/manifest";
const releaseSourceUrl = `https://git.add-ideas.de/zemion/regex-tools/src/tag/v${manifest.version}`; const releaseSourceUrl = `https://git.add-ideas.de/zemion/regex-tools/src/tag/v${manifest.version}`;
@@ -43,10 +47,11 @@ export function HelpDialog({
<section> <section>
<h3>What runs where</h3> <h3>What runs where</h3>
<p> <p>
Syntax parsing and actual ECMAScript execution run in separate, Syntax parsing and actual ECMAScript or PCRE2 execution run in
killable browser workers. Patterns, subjects and projects are never separate, killable browser workers. PCRE2 is the bundled,
uploaded. There is no backend, account, telemetry, CDN dependency, self-hosted 10.47 WebAssembly engine. Patterns, subjects and
or executable replacement function. projects are never uploaded. There is no backend, account,
telemetry, CDN dependency, or executable replacement function.
</p> </p>
</section> </section>
<section> <section>
@@ -56,8 +61,47 @@ export function HelpDialog({
syntax provider. The extraction tree contains actual browser-engine syntax provider. The extraction tree contains actual browser-engine
matches and captures. Replacement mode adds a typed token tree and matches and captures. Replacement mode adds a typed token tree and
bounded per-match contribution/range mapping over those actual bounded per-match contribution/range mapping over those actual
matches. None is an internal V8, SpiderMonkey or JavaScriptCore matches. These views are not internal engine traces. The separate
execution trace. PCRE trace viewer shows actual bounded automatic callouts reported
by PCRE2; its adjacent-position movement labels are derived and it
is not a complete record of every internal action. Browser
ECMAScript exposes no corresponding V8, SpiderMonkey or
JavaScriptCore trace API.
</p>
</section>
<section>
<h3>Advisory analysis</h3>
<p>
Static risk findings are ECMAScript-2025-specific heuristics, and
generated-input growth observations describe only the selected
bounded samples. Neither proves safety or general complexity. Growth
and cold/warm benchmark timing run in a separate killable worker and
never include the PCRE2 trace path.
</p>
</section>
<section>
<h3>Generated cases</h3>
<p>
ECMAScript generator v1 deterministically samples supported
normalized-AST paths from an explicit seed. Every intended positive
and negative subject runs through the selected actual engine before
it can become a test; candidates with the wrong outcome are
discarded. The coverage report names unsupported constructs.
Sampling is not proof of complete language coverage. PCRE2 remains
unavailable until its structural provider exposes the required AST;
its syntax is never reinterpreted as ECMAScript.
</p>
</section>
<section>
<h3>Pattern formatting</h3>
<p>
The ECMAScript formatter makes only grammar-backed literal/control
escape changes; it does not insert layout whitespace or reinterpret
PCRE2 syntax. Applying a preview requires independent source and
candidate reparsing, actual-engine match/replacement comparison,
every applicable exact unit test, and an explicit confirmation.
Passing bounded checks is evidence for those snapshots, not proof
for every possible subject.
</p> </p>
</section> </section>
<section> <section>
@@ -105,6 +149,78 @@ export function HelpDialog({
<dt>Unit-test suite wall time</dt> <dt>Unit-test suite wall time</dt>
<dd>60 seconds</dd> <dd>60 seconds</dd>
</div> </div>
<div>
<dt>Corpus input</dt>
<dd>
{DEFAULT_REGEX_LIMITS.maximumCorpusDocuments.toLocaleString()}{" "}
documents · {DEFAULT_REGEX_LIMITS.corpusHardBytes / 1024 / 1024}{" "}
MiB
</dd>
</div>
<div>
<dt>Corpus wall time</dt>
<dd>
{DEFAULT_REGEX_LIMITS.maximumCorpusWallTimeMs / 60_000} minutes
</dd>
</div>
<div>
<dt>Analysis wall time</dt>
<dd>
{DEFAULT_REGEX_LIMITS.maximumBenchmarkWallTimeMs / 1_000}{" "}
seconds
</dd>
</div>
<div>
<dt>Benchmark iteration cap</dt>
<dd>
{DEFAULT_REGEX_LIMITS.maximumBenchmarkIterations.toLocaleString()}
</dd>
</div>
<div>
<dt>Growth probe steps</dt>
<dd>24</dd>
</div>
<div>
<dt>Generated cases</dt>
<dd>
{DEFAULT_REGEX_LIMITS.maximumGeneratedCases.toLocaleString()}{" "}
retained ·{" "}
{GENERATION_LIMITS.maximumCandidateAttempts.toLocaleString()}{" "}
attempts
</dd>
</div>
<div>
<dt>Generation defaults</dt>
<dd>
{DEFAULT_GENERATION_SETTINGS.perCaseTimeoutMs} ms per case ·{" "}
{DEFAULT_GENERATION_SETTINGS.maximumWallTimeMs / 1_000} seconds
aggregate
</dd>
</div>
<div>
<dt>Generated subject text</dt>
<dd>
{GENERATION_LIMITS.maximumTotalSubjectBytes / 1024 / 1024} MiB
aggregate hard cap
</dd>
</div>
<div>
<dt>Formatter validation</dt>
<dd>1,000 tests · 60 seconds aggregate</dd>
</div>
<div>
<dt>Independent corpus lines</dt>
<dd>
{DEFAULT_REGEX_LIMITS.maximumCorpusLines.toLocaleString()}
</dd>
</div>
<div>
<dt>Corpus applied output</dt>
<dd>
{DEFAULT_REGEX_LIMITS.maximumCorpusOutputBytes / 1024 / 1024}{" "}
MiB
</dd>
</div>
</dl> </dl>
<p> <p>
A timeout terminates the worker. It means timed out, never no A timeout terminates the worker. It means timed out, never no
@@ -116,7 +232,15 @@ export function HelpDialog({
<p> <p>
Ad-hoc test text is excluded from local saves and exports by Ad-hoc test text is excluded from local saves and exports by
default. Test definitions are deliberate project data. Import is default. Test definitions are deliberate project data. Import is
validated and paused until you review and run it. validated and paused until you review and run it. Corpus documents,
contents, outputs and results are always ephemeral and excluded from
projects and IndexedDB. Analysis settings and results are likewise
ephemeral. Generated-case settings and review results are ephemeral;
only cases explicitly added to the test suite persist, together with
their bounded seed and generator provenance. Test subjects remain
excluded unless separately opted in. Formatter previews and
validation observations are ephemeral; only an explicitly applied
pattern enters workbench/project state.
</p> </p>
</section> </section>
<section> <section>

View File

@@ -0,0 +1,241 @@
.minimize-dialog {
width: min(90rem, calc(100% - 2rem));
}
.minimize-panel {
max-height: calc(100vh - 2rem);
overflow: auto;
}
.minimizer-body {
display: grid;
gap: 0.8rem;
padding: 0.8rem;
}
.minimizer-advisory {
margin: 0;
border-left: 3px solid var(--toolbox-accent);
padding: 0.7rem 0.8rem;
background: var(--toolbox-accent-soft);
color: var(--toolbox-muted);
font-size: 0.78rem;
line-height: 1.5;
}
.minimizer-config,
.minimizer-result {
display: grid;
gap: 0.75rem;
overflow: hidden;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.8);
background: var(--toolbox-surface);
}
.minimizer-config > header,
.minimizer-result > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
border-bottom: 1px solid var(--toolbox-border);
padding: 0.7rem 0.8rem;
background: var(--toolbox-surface-soft);
}
.minimizer-config h3,
.minimizer-result h3,
.minimizer-result header p {
margin: 0;
}
.minimizer-config h3,
.minimizer-result h3 {
font-size: 0.95rem;
}
.minimizer-config > header span {
color: var(--toolbox-muted);
font-size: 0.72rem;
}
.minimizer-control-grid,
.minimizer-variant-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
align-items: end;
gap: 0.65rem;
padding: 0 0.8rem;
}
.minimizer-variant-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.minimizer-control-grid label:not(.check-control),
.minimizer-variant-grid label,
.minimizer-subject,
.minimizer-output {
display: grid;
gap: 0.3rem;
color: var(--toolbox-muted);
font-size: 0.72rem;
font-weight: 700;
}
.minimizer-wide-control {
grid-column: 1 / -1;
}
.minimizer-check {
min-height: 2.25rem;
align-items: center;
}
.minimizer-subject {
padding: 0 0.8rem 0.8rem;
}
.minimizer-subject textarea {
min-height: 8rem;
}
.minimizer-variant-grid textarea {
min-height: 4.5rem;
}
.minimizer-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
}
.minimizer-actions p,
.minimizer-status {
margin: 0;
color: var(--toolbox-muted);
font-size: 0.76rem;
}
.minimizer-progress {
display: grid;
grid-template-columns: minmax(8rem, 1fr) auto;
align-items: center;
gap: 0.65rem;
color: var(--toolbox-muted);
font-size: 0.7rem;
}
.minimizer-progress progress {
width: 100%;
accent-color: var(--toolbox-accent);
}
.minimizer-result > dl {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
margin: 0;
padding: 0 0.8rem;
}
.minimizer-result > dl > div {
padding: 0.55rem;
border-bottom: 1px solid var(--toolbox-border);
}
.minimizer-result dt {
color: var(--toolbox-muted);
font-size: 0.66rem;
font-weight: 750;
text-transform: uppercase;
}
.minimizer-result dd {
margin: 0.15rem 0 0;
font-size: 0.78rem;
}
.minimizer-result-badge {
border: 1px solid currentColor;
border-radius: 99rem;
padding: 0.2rem 0.45rem;
color: var(--toolbox-accent);
font-size: 0.65rem;
font-weight: 800;
text-transform: uppercase;
}
.minimizer-result-badge.is-partial,
.minimizer-result-badge.is-cancelled {
color: var(--regex-warning);
}
.minimizer-result-badge.is-unsupported {
color: var(--toolbox-danger);
}
.minimizer-observation-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
padding: 0 0.8rem;
}
.minimizer-observation {
display: grid;
gap: 0.3rem;
min-width: 0;
border: 1px solid var(--toolbox-border);
border-left: 4px solid var(--toolbox-accent);
border-radius: calc(var(--toolbox-radius) * 0.7);
padding: 0.65rem;
background: var(--toolbox-surface-soft);
}
.minimizer-observation.is-timeout {
border-left-color: var(--regex-warning);
}
.minimizer-observation.is-crash,
.minimizer-observation.is-worker-error {
border-left-color: var(--toolbox-danger);
}
.minimizer-observation span,
.minimizer-observation p,
.minimizer-observation small {
margin: 0;
color: var(--toolbox-muted);
font-size: 0.72rem;
line-height: 1.45;
}
.minimizer-observation code {
overflow-wrap: anywhere;
font-size: 0.68rem;
}
.minimizer-output {
padding: 0 0.8rem;
}
.minimizer-output textarea {
min-height: 6rem;
}
.minimizer-result > .minimizer-actions {
padding: 0 0.8rem 0.8rem;
}
@media (max-width: 720px) {
.minimizer-variant-grid,
.minimizer-observation-grid {
grid-template-columns: 1fr;
}
.minimizer-progress {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,184 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type {
SubjectMinimizationProgress,
SubjectMinimizationRequest,
SubjectMinimizationResult,
} from "../regex/minimization/minimization.types";
import { MinimizePanel, type SubjectMinimizerClient } from "./MinimizePanel";
function result(): SubjectMinimizationResult {
const observation = {
status: "complete" as const,
reproduced: true,
fingerprint: ["expected-no-match:present"],
summary: "The forbidden match is still present.",
sides: [
{
flavour: "ecmascript" as const,
status: "complete" as const,
engineIdentity: "ecmascript · Fixture · 1 · utf16",
},
],
};
return {
schemaVersion: 1,
targetIdentity: "minimization-fixture",
status: "complete",
stopReason: "locally-minimal",
locallyMinimal: true,
originalSubject: "abXcd",
minimizedSubject: "X",
originalScalars: 5,
minimizedScalars: 1,
originalUtf8Bytes: 5,
minimizedUtf8Bytes: 1,
evaluations: 12,
acceptedReductions: 3,
elapsedMs: 4,
budgets: {
maximumEvaluations: 500,
maximumWallTimeMs: 15_000,
candidateTimeoutMs: 2_000,
},
baseline: observation,
final: observation,
retainedHistory: [],
historyTruncated: false,
};
}
function renderPanel(client: SubjectMinimizerClient) {
const onUseSubject = vi.fn();
const onClose = vi.fn();
render(
<MinimizePanel
activeFlavour="ecmascript"
activeFlavourVersion="ECMAScript 2025 syntax / current browser runtime"
activeFlags={["g", "u"]}
activeOptions={{}}
pattern="X"
subject="abXcd"
replacement="!"
scanAll
timeoutMs={2_000}
onUseSubject={onUseSubject}
onClose={onClose}
createMinimizer={() => client}
/>,
);
return { onUseSubject, onClose };
}
function completingClient(
requests: SubjectMinimizationRequest[],
): SubjectMinimizerClient {
return {
minimize: vi.fn(
async (
request: SubjectMinimizationRequest,
onProgress?: (progress: SubjectMinimizationProgress) => void,
) => {
requests.push(request);
onProgress?.({
phase: "local-sweep",
evaluations: 12,
maximumEvaluations: 500,
acceptedReductions: 3,
currentScalars: 1,
currentUtf8Bytes: 1,
elapsedMs: 4,
maximumWallTimeMs: 15_000,
});
return result();
},
),
cancel: vi.fn(),
dispose: vi.fn(),
};
}
describe("MinimizePanel", () => {
it("runs a failing unit-test predicate and can apply the verified result", async () => {
const requests: SubjectMinimizationRequest[] = [];
const { onUseSubject } = renderPanel(completingClient(requests));
expect(
screen.getByText(/does not claim a globally or semantically minimal/u),
).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "Verify & minimize" }));
expect(
await screen.findByRole("heading", {
name: "Locally minimal under the documented transforms",
}),
).toBeVisible();
expect(screen.getByLabelText("Minimized subject")).toHaveValue("X");
expect(requests[0]?.target).toMatchObject({
kind: "engine",
oracle: {
kind: "unit-test-failure",
expectation: { kind: "should-not-match" },
},
});
fireEvent.click(
screen.getByRole("button", { name: "Use minimized subject" }),
);
expect(onUseSubject).toHaveBeenCalledWith("X");
});
it("exposes capture-range, timeout and two-engine mismatch targets", async () => {
const requests: SubjectMinimizationRequest[] = [];
renderPanel(completingClient(requests));
const kind = screen.getByLabelText("Minimization failure kind");
expect(kind).toContainHTML("capture-range-mismatch");
expect(kind).toContainHTML("engine-timeout");
expect(kind).toContainHTML("comparison-mismatch");
fireEvent.change(kind, { target: { value: "engine-timeout" } });
fireEvent.click(screen.getByRole("button", { name: "Verify & minimize" }));
await waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0]?.target).toMatchObject({
kind: "engine",
oracle: { kind: "engine-timeout" },
});
fireEvent.change(kind, {
target: { value: "comparison-mismatch" },
});
fireEvent.click(screen.getByRole("button", { name: "Verify & minimize" }));
await waitFor(() => expect(requests).toHaveLength(2));
expect(requests[1]?.target).toMatchObject({
kind: "comparison-mismatch",
operation: "match",
sides: [
{ flavour: "ecmascript", pattern: "X" },
{ flavour: "pcre2", pattern: "X" },
],
});
});
it("cancels the active worker set and closes independently", async () => {
const client: SubjectMinimizerClient = {
minimize: vi.fn(
() => new Promise<SubjectMinimizationResult>(() => undefined),
),
cancel: vi.fn(),
dispose: vi.fn(),
};
const { onClose } = renderPanel(client);
fireEvent.click(screen.getByRole("button", { name: "Verify & minimize" }));
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
expect(client.cancel).toHaveBeenCalledOnce();
expect(
screen.getByText(/active syntax and engine workers were terminated/u),
).toBeVisible();
fireEvent.click(
screen.getByRole("button", { name: "Close subject minimizer" }),
);
expect(onClose).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,848 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { writeClipboardText } from "../browser/clipboard";
import {
AVAILABLE_REGEX_FLAVOURS,
defaultRegexOptions,
} from "../regex/flavours/flavour-registry";
import {
DEFAULT_REGEX_LIMITS,
utf8ByteLength,
} from "../regex/execution/request-limits";
import type {
RegexEngineOptions,
RegexFlavourId,
} from "../regex/model/flavour";
import type { RegexTestExpectation } from "../regex/tests/test-case.types";
import { SubjectMinimizer } from "../regex/minimization/SubjectMinimizer";
import type {
EngineFailureOracle,
SubjectMinimizationProgress,
SubjectMinimizationRequest,
SubjectMinimizationResult,
} from "../regex/minimization/minimization.types";
import "./MinimizePanel.css";
type MinimizationMode =
| "unit-test-failure"
| "capture-range-mismatch"
| "engine-timeout"
| "comparison-mismatch";
type UnitExpectationKind = RegexTestExpectation["kind"];
export interface SubjectMinimizerClient {
minimize(
request: SubjectMinimizationRequest,
onProgress?: (progress: SubjectMinimizationProgress) => void,
): Promise<SubjectMinimizationResult>;
cancel(): void;
dispose(): void;
}
export interface MinimizePanelProps {
readonly activeFlavour: RegexFlavourId;
readonly activeFlavourVersion: string;
readonly activeFlags: readonly string[];
readonly activeOptions: RegexEngineOptions;
readonly pattern: string;
readonly subject: string;
readonly replacement: string;
readonly scanAll: boolean;
readonly timeoutMs: number;
readonly onUseSubject: (subject: string) => void;
readonly onClose: () => void;
readonly createMinimizer?: () => SubjectMinimizerClient;
}
const ECMASCRIPT = AVAILABLE_REGEX_FLAVOURS.require("ecmascript");
const PCRE2 = AVAILABLE_REGEX_FLAVOURS.require("pcre2");
function flagsFor(
activeFlavour: RegexFlavourId,
activeFlags: readonly string[],
flavour: "ecmascript" | "pcre2",
): readonly string[] {
const definition = flavour === "ecmascript" ? ECMASCRIPT : PCRE2;
return activeFlavour === flavour
? definition.flags
.map((flag) => flag.value)
.filter((flag) => activeFlags.includes(flag))
: definition.defaultFlags;
}
function optionsFor(
activeFlavour: RegexFlavourId,
activeOptions: RegexEngineOptions,
flavour: "ecmascript" | "pcre2",
): RegexEngineOptions {
if (activeFlavour === flavour) return activeOptions;
return defaultRegexOptions(flavour === "ecmascript" ? ECMASCRIPT : PCRE2);
}
function parseCaptureSelector(value: string): {
readonly groupNumber?: number;
readonly groupName?: string;
} {
const trimmed = value.trim();
const number = Number(trimmed);
return trimmed !== "" && Number.isSafeInteger(number) && number > 0
? { groupNumber: number }
: { groupName: trimmed };
}
function resultLabel(result: SubjectMinimizationResult): string {
if (result.locallyMinimal) {
return "Locally minimal under the documented transforms";
}
return result.stopReason.replaceAll("-", " ");
}
function Observation({
label,
observation,
}: {
readonly label: string;
readonly observation: SubjectMinimizationResult["baseline"];
}) {
return (
<article className={`minimizer-observation is-${observation.status}`}>
<strong>{label}</strong>
<span>
{observation.status} ·{" "}
{observation.reproduced ? "predicate reproduced" : "not reproduced"}
</span>
<p>{observation.summary}</p>
{observation.fingerprint.length > 0 ? (
<code>{observation.fingerprint.join(" · ")}</code>
) : null}
{observation.sides?.length ? (
<small>
{observation.sides
.map(
(side) =>
`${side.flavour}: ${side.status}${
side.engineIdentity ? ` · ${side.engineIdentity}` : ""
}`,
)
.join(" | ")}
</small>
) : null}
</article>
);
}
export function MinimizePanel({
activeFlavour,
activeFlavourVersion,
activeFlags,
activeOptions,
pattern: initialPattern,
subject: initialSubject,
replacement: initialReplacement,
scanAll: initialScanAll,
timeoutMs: initialTimeoutMs,
onUseSubject,
onClose,
createMinimizer = () => new SubjectMinimizer(),
}: MinimizePanelProps) {
const [mode, setMode] = useState<MinimizationMode>("unit-test-failure");
const [subject, setSubject] = useState(initialSubject);
const [scanAll, setScanAll] = useState(initialScanAll);
const [unitKind, setUnitKind] =
useState<UnitExpectationKind>("should-not-match");
const [expectedCount, setExpectedCount] = useState(1);
const [matchIndex, setMatchIndex] = useState(0);
const [expectedValue, setExpectedValue] = useState("");
const [captureSelector, setCaptureSelector] = useState("1");
const [captureStatus, setCaptureStatus] = useState<
"participated" | "did-not-participate" | "matched-empty"
>("participated");
const [checkCaptureValue, setCheckCaptureValue] = useState(false);
const [expectedStart, setExpectedStart] = useState(0);
const [expectedEnd, setExpectedEnd] = useState(0);
const [durationLimitMs, setDurationLimitMs] = useState(10);
const [comparisonOperation, setComparisonOperation] = useState<
"match" | "replace"
>("match");
const [ecmaPattern, setEcmaPattern] = useState(initialPattern);
const [pcrePattern, setPcrePattern] = useState(initialPattern);
const [ecmaReplacement, setEcmaReplacement] = useState(initialReplacement);
const [pcreReplacement, setPcreReplacement] = useState(initialReplacement);
const [maximumEvaluations, setMaximumEvaluations] = useState(500);
const [maximumWallTimeMs, setMaximumWallTimeMs] = useState(15_000);
const [candidateTimeoutMs, setCandidateTimeoutMs] = useState(
Math.min(
DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs,
Math.max(1, initialTimeoutMs),
),
);
const [running, setRunning] = useState(false);
const [status, setStatus] = useState(
"Select an exact failure predicate, then verify the baseline.",
);
const [progress, setProgress] = useState<SubjectMinimizationProgress>();
const [result, setResult] = useState<SubjectMinimizationResult>();
const [copyStatus, setCopyStatus] = useState("");
const minimizer = useRef<SubjectMinimizerClient | null>(null);
const revision = useRef(0);
useEffect(
() => () => {
revision.current += 1;
minimizer.current?.dispose();
minimizer.current = null;
},
[],
);
const ecmaFlags = useMemo(
() => flagsFor(activeFlavour, activeFlags, "ecmascript"),
[activeFlags, activeFlavour],
);
const pcreFlags = useMemo(
() => flagsFor(activeFlavour, activeFlags, "pcre2"),
[activeFlags, activeFlavour],
);
const ecmaOptions = useMemo(
() => optionsFor(activeFlavour, activeOptions, "ecmascript"),
[activeFlavour, activeOptions],
);
const pcreOptions = useMemo(
() => optionsFor(activeFlavour, activeOptions, "pcre2"),
[activeFlavour, activeOptions],
);
const unitExpectation = (): RegexTestExpectation => {
switch (unitKind) {
case "should-match":
case "should-not-match":
case "must-time-out":
return { kind: unitKind };
case "match-count":
return { kind: unitKind, count: expectedCount };
case "full-match":
return {
kind: unitKind,
matchIndex,
value: expectedValue,
};
case "capture":
return {
kind: unitKind,
matchIndex,
...parseCaptureSelector(captureSelector),
status: captureStatus,
...(checkCaptureValue ? { value: expectedValue } : {}),
};
case "replacement":
return { kind: unitKind, expected: expectedValue };
case "must-complete-within":
return { kind: unitKind, milliseconds: durationLimitMs };
}
};
const activeSide = {
flavour:
activeFlavour === "pcre2" ? ("pcre2" as const) : ("ecmascript" as const),
flavourVersion: activeFlavourVersion,
pattern: initialPattern,
flags: activeFlags,
options: activeOptions,
...(unitKind === "replacement" ? { replacement: initialReplacement } : {}),
};
const request = (): SubjectMinimizationRequest => {
let target: SubjectMinimizationRequest["target"];
if (mode === "comparison-mismatch") {
target = {
kind: "comparison-mismatch",
operation: comparisonOperation,
scanAll,
sides: [
{
flavour: "ecmascript",
flavourVersion: ECMASCRIPT.defaultVersion,
pattern: ecmaPattern,
flags: ecmaFlags,
options: ecmaOptions,
...(comparisonOperation === "replace"
? { replacement: ecmaReplacement }
: {}),
},
{
flavour: "pcre2",
flavourVersion: PCRE2.defaultVersion,
pattern: pcrePattern,
flags: pcreFlags,
options: pcreOptions,
...(comparisonOperation === "replace"
? { replacement: pcreReplacement }
: {}),
},
],
};
} else {
let oracle: EngineFailureOracle;
if (mode === "engine-timeout") {
oracle = { kind: "engine-timeout" };
} else if (mode === "capture-range-mismatch") {
oracle = {
kind: "capture-range-mismatch",
matchIndex,
...parseCaptureSelector(captureSelector),
expectedRange: {
startUtf16: expectedStart,
endUtf16: expectedEnd,
},
};
} else {
oracle = {
kind: "unit-test-failure",
expectation: unitExpectation(),
};
}
target = {
kind: "engine",
side: activeSide,
scanAll,
oracle,
};
}
return {
schemaVersion: 1,
subject,
target,
budgets: {
maximumEvaluations,
maximumWallTimeMs,
candidateTimeoutMs,
},
maximumMatches: 1_000,
maximumCaptureRows: 10_000,
maximumOutputBytes: 1024 * 1024,
};
};
const run = async () => {
const currentRevision = ++revision.current;
setRunning(true);
setResult(undefined);
setCopyStatus("");
setStatus("Verifying the baseline with the selected real engine worker…");
try {
minimizer.current ??= createMinimizer();
const minimized = await minimizer.current.minimize(request(), (value) => {
if (currentRevision !== revision.current) return;
setProgress(value);
setStatus(
`${value.phase.replaceAll("-", " ")} · ${value.evaluations}/${value.maximumEvaluations} evaluations · ${value.acceptedReductions} accepted`,
);
});
if (currentRevision !== revision.current) return;
setResult(minimized);
setStatus(resultLabel(minimized));
} catch (error) {
if (currentRevision !== revision.current) return;
setStatus(error instanceof Error ? error.message : String(error));
} finally {
if (currentRevision === revision.current) setRunning(false);
}
};
const cancel = () => {
revision.current += 1;
minimizer.current?.cancel();
setRunning(false);
setStatus("Cancelled; active syntax and engine workers were terminated.");
};
return (
<section
className="panel minimize-panel"
aria-labelledby="minimize-heading"
>
<header className="panel-heading">
<div>
<p className="eyebrow">Bounded failing-subject reduction</p>
<h2 id="minimize-heading">Minimize a reproducer</h2>
</div>
<button
type="button"
className="icon-button"
aria-label="Close subject minimizer"
data-dialog-initial-focus
onClick={onClose}
>
×
</button>
</header>
<div className="minimizer-body">
<p className="minimizer-advisory">
This reducer proves only local minimality under deterministic
Unicode-scalar chunk deletion, single-scalar deletion and canonical
replacement. It does not claim a globally or semantically minimal
example.
</p>
<section className="minimizer-config" aria-labelledby="minimize-target">
<header>
<h3 id="minimize-target">Exact predicate</h3>
<span>
{activeFlavour} · {utf8ByteLength(subject).toLocaleString()} bytes
</span>
</header>
<div className="minimizer-control-grid">
<label>
<span>Failure kind</span>
<select
aria-label="Minimization failure kind"
value={mode}
disabled={running}
onChange={(event) =>
setMode(event.target.value as MinimizationMode)
}
>
<option value="unit-test-failure">
Failing unit-test assertion
</option>
<option value="capture-range-mismatch">
Wrong capture range
</option>
<option value="engine-timeout">Exact engine timeout</option>
<option value="comparison-mismatch">
ECMAScript PCRE2 mismatch
</option>
</select>
</label>
<label className="check-control minimizer-check">
<input
type="checkbox"
checked={scanAll}
disabled={running}
onChange={(event) => setScanAll(event.target.checked)}
/>
Scan all
</label>
</div>
{mode === "unit-test-failure" ? (
<div className="minimizer-control-grid">
<label>
<span>Saved assertion</span>
<select
aria-label="Unit-test expectation"
value={unitKind}
disabled={running}
onChange={(event) =>
setUnitKind(event.target.value as UnitExpectationKind)
}
>
<option value="should-match">Should match</option>
<option value="should-not-match">Should not match</option>
<option value="match-count">Exact match count</option>
<option value="full-match">Full-match value</option>
<option value="capture">Capture status/value</option>
<option value="replacement">Replacement output</option>
<option value="must-complete-within">
Must complete within
</option>
<option value="must-time-out">Must time out</option>
</select>
</label>
{unitKind === "match-count" ? (
<label>
<span>Expected count</span>
<input
type="number"
value={expectedCount}
min={0}
max={DEFAULT_REGEX_LIMITS.maximumMatches}
onChange={(event) =>
setExpectedCount(Number(event.target.value))
}
/>
</label>
) : null}
{unitKind === "full-match" || unitKind === "capture" ? (
<label>
<span>Zero-based match index</span>
<input
type="number"
value={matchIndex}
min={0}
onChange={(event) =>
setMatchIndex(Number(event.target.value))
}
/>
</label>
) : null}
{unitKind === "capture" ? (
<>
<label>
<span>Capture number or name</span>
<input
type="text"
value={captureSelector}
onChange={(event) =>
setCaptureSelector(event.target.value)
}
/>
</label>
<label>
<span>Expected participation</span>
<select
value={captureStatus}
onChange={(event) =>
setCaptureStatus(
event.target.value as typeof captureStatus,
)
}
>
<option value="participated">Participated</option>
<option value="did-not-participate">
Did not participate
</option>
<option value="matched-empty">Matched empty</option>
</select>
</label>
<label className="check-control minimizer-check">
<input
type="checkbox"
checked={checkCaptureValue}
onChange={(event) =>
setCheckCaptureValue(event.target.checked)
}
/>
Assert value too
</label>
</>
) : null}
{unitKind === "full-match" ||
unitKind === "replacement" ||
(unitKind === "capture" && checkCaptureValue) ? (
<label className="minimizer-wide-control">
<span>Expected value</span>
<input
type="text"
value={expectedValue}
onChange={(event) => setExpectedValue(event.target.value)}
/>
</label>
) : null}
{unitKind === "must-complete-within" ? (
<label>
<span>Assertion milliseconds</span>
<input
type="number"
value={durationLimitMs}
min={1}
onChange={(event) =>
setDurationLimitMs(Number(event.target.value))
}
/>
</label>
) : null}
</div>
) : null}
{mode === "capture-range-mismatch" ? (
<div className="minimizer-control-grid">
<label>
<span>Zero-based match index</span>
<input
type="number"
value={matchIndex}
min={0}
onChange={(event) =>
setMatchIndex(Number(event.target.value))
}
/>
</label>
<label>
<span>Capture number or name</span>
<input
type="text"
value={captureSelector}
onChange={(event) => setCaptureSelector(event.target.value)}
/>
</label>
<label>
<span>Expected UTF-16 start</span>
<input
type="number"
value={expectedStart}
min={0}
max={subject.length}
onChange={(event) =>
setExpectedStart(Number(event.target.value))
}
/>
</label>
<label>
<span>Expected UTF-16 end</span>
<input
type="number"
value={expectedEnd}
min={0}
max={subject.length}
onChange={(event) =>
setExpectedEnd(Number(event.target.value))
}
/>
</label>
</div>
) : null}
{mode === "comparison-mismatch" ? (
<>
<div className="minimizer-control-grid">
<label>
<span>Operation</span>
<select
value={comparisonOperation}
onChange={(event) =>
setComparisonOperation(
event.target.value as "match" | "replace",
)
}
>
<option value="match">Match</option>
<option value="replace">Replace</option>
</select>
</label>
</div>
<div className="minimizer-variant-grid">
<label>
<span>ECMAScript pattern</span>
<textarea
value={ecmaPattern}
onChange={(event) => setEcmaPattern(event.target.value)}
/>
</label>
<label>
<span>PCRE2 pattern</span>
<textarea
value={pcrePattern}
onChange={(event) => setPcrePattern(event.target.value)}
/>
</label>
{comparisonOperation === "replace" ? (
<>
<label>
<span>ECMAScript replacement</span>
<textarea
value={ecmaReplacement}
onChange={(event) =>
setEcmaReplacement(event.target.value)
}
/>
</label>
<label>
<span>PCRE2 replacement</span>
<textarea
value={pcreReplacement}
onChange={(event) =>
setPcreReplacement(event.target.value)
}
/>
</label>
</>
) : null}
</div>
</>
) : null}
<label className="minimizer-subject">
<span>Failing subject · local text only</span>
<textarea
aria-label="Subject to minimize"
value={subject}
disabled={running}
onChange={(event) => setSubject(event.target.value)}
/>
</label>
</section>
<section className="minimizer-config" aria-labelledby="minimize-bounds">
<header>
<h3 id="minimize-bounds">Hard bounds</h3>
<span>Fixed deadline per candidate</span>
</header>
<div className="minimizer-control-grid">
<label>
<span>Candidate evaluations</span>
<input
type="number"
value={maximumEvaluations}
min={1}
max={DEFAULT_REGEX_LIMITS.maximumMinimizerRuns}
disabled={running}
onChange={(event) =>
setMaximumEvaluations(Number(event.target.value))
}
/>
</label>
<label>
<span>Aggregate wall time ms</span>
<input
type="number"
value={maximumWallTimeMs}
min={1}
max={60_000}
disabled={running}
onChange={(event) =>
setMaximumWallTimeMs(Number(event.target.value))
}
/>
</label>
<label>
<span>Each candidate ms</span>
<input
type="number"
value={candidateTimeoutMs}
min={1}
max={DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs}
disabled={running}
onChange={(event) =>
setCandidateTimeoutMs(Number(event.target.value))
}
/>
</label>
</div>
</section>
<div className="minimizer-actions">
<button
type="button"
className="primary-button"
disabled={running}
onClick={() => void run()}
>
{running ? "Minimizing…" : "Verify & minimize"}
</button>
{running ? (
<button type="button" className="secondary-button" onClick={cancel}>
Cancel
</button>
) : null}
<p className="minimizer-status" aria-live="polite">
{status}
</p>
</div>
{progress ? (
<div className="minimizer-progress">
<progress
aria-label="Minimizer evaluation progress"
value={progress.evaluations}
max={progress.maximumEvaluations}
/>
<span>
{progress.currentScalars.toLocaleString()} scalars ·{" "}
{progress.currentUtf8Bytes.toLocaleString()} bytes ·{" "}
{progress.elapsedMs.toFixed(0)}/
{progress.maximumWallTimeMs.toLocaleString()} ms
</span>
</div>
) : null}
{result ? (
<section
className="minimizer-result"
aria-labelledby="minimize-result"
>
<header>
<div>
<p className="eyebrow">Exact target {result.targetIdentity}</p>
<h3 id="minimize-result">{resultLabel(result)}</h3>
</div>
<span className={`minimizer-result-badge is-${result.status}`}>
{result.status}
</span>
</header>
<dl>
<div>
<dt>Scalars</dt>
<dd>
{result.originalScalars.toLocaleString()} {" "}
{result.minimizedScalars.toLocaleString()}
</dd>
</div>
<div>
<dt>UTF-8 bytes</dt>
<dd>
{result.originalUtf8Bytes.toLocaleString()} {" "}
{result.minimizedUtf8Bytes.toLocaleString()}
</dd>
</div>
<div>
<dt>Evaluations</dt>
<dd>
{result.evaluations.toLocaleString()} /{" "}
{result.budgets.maximumEvaluations.toLocaleString()}
</dd>
</div>
<div>
<dt>Accepted reductions</dt>
<dd>{result.acceptedReductions.toLocaleString()}</dd>
</div>
<div>
<dt>Wall time</dt>
<dd>{result.elapsedMs.toFixed(1)} ms</dd>
</div>
<div>
<dt>Stop reason</dt>
<dd>{result.stopReason.replaceAll("-", " ")}</dd>
</div>
</dl>
<div className="minimizer-observation-grid">
<Observation
label="Baseline verification"
observation={result.baseline}
/>
<Observation
label="Final accepted candidate"
observation={result.final}
/>
</div>
<label className="minimizer-output">
<span>Minimized subject</span>
<textarea
aria-label="Minimized subject"
readOnly
value={result.minimizedSubject}
/>
</label>
<div className="minimizer-actions">
<button
type="button"
className="primary-button"
disabled={!result.final.reproduced}
onClick={() => onUseSubject(result.minimizedSubject)}
>
Use minimized subject
</button>
<button
type="button"
className="secondary-button"
onClick={() => {
void writeClipboardText(result.minimizedSubject)
.then(() => setCopyStatus("Copied locally."))
.catch((error: unknown) =>
setCopyStatus(
error instanceof Error ? error.message : String(error),
),
);
}}
>
Copy
</button>
<p aria-live="polite">{copyStatus}</p>
</div>
</section>
) : null}
</div>
</section>
);
}

View File

@@ -0,0 +1,71 @@
import { useEffect, useRef, type ReactNode } from "react";
export function ModalDialog({
id,
open,
labelledBy,
onClose,
children,
className = "",
}: {
readonly id: string;
readonly open: boolean;
readonly labelledBy: string;
readonly onClose: () => void;
readonly children: ReactNode;
readonly className?: string;
}) {
const dialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
const element = dialog.current;
if (!element) return;
if (open && !element.open) {
if (typeof element.showModal === "function") {
element.showModal();
} else {
// jsdom and older browsers can still expose the content for a usable
// non-modal fallback.
element.setAttribute("open", "");
}
(
element.querySelector<HTMLElement>("[data-dialog-initial-focus]") ??
element
).focus();
return;
}
if (!open && element.open) {
if (typeof element.close === "function") {
element.close();
} else {
element.removeAttribute("open");
}
}
}, [open]);
return (
<dialog
ref={dialog}
id={id}
className={`app-dialog auxiliary-dialog ${className}`.trim()}
aria-labelledby={labelledBy}
aria-modal="true"
tabIndex={-1}
onClose={onClose}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onKeyDownCapture={(event) => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
onClose();
}}
>
{children}
</dialog>
);
}

View File

@@ -0,0 +1,342 @@
.formatter-dialog {
width: min(90rem, calc(100% - 2rem));
}
.formatter-panel {
max-height: calc(100vh - 2rem);
overflow: auto;
}
.formatter-body {
display: grid;
gap: 0.8rem;
padding: 0.8rem;
}
.formatter-advisory,
.formatter-unavailable,
.formatter-empty,
.formatter-result-note {
margin: 0;
color: var(--toolbox-muted);
font-size: 0.78rem;
line-height: 1.5;
}
.formatter-advisory {
border-left: 3px solid var(--toolbox-accent);
padding: 0.7rem 0.8rem;
background: var(--toolbox-accent-soft);
}
.formatter-unavailable {
margin: 0.8rem;
border: 1px solid
color-mix(in srgb, var(--toolbox-danger) 45%, var(--toolbox-border));
border-radius: calc(var(--toolbox-radius) * 0.72);
padding: 0.8rem;
background: color-mix(
in srgb,
var(--toolbox-danger) 8%,
var(--toolbox-surface)
);
color: color-mix(in srgb, var(--toolbox-danger) 78%, var(--toolbox-text));
}
.formatter-preview,
.formatter-validation {
display: grid;
gap: 0.75rem;
overflow: hidden;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.82);
background: var(--toolbox-surface);
}
.formatter-preview > header,
.formatter-validation > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.7rem 0.8rem;
border-bottom: 1px solid var(--toolbox-border);
background: var(--toolbox-surface-soft);
}
.formatter-preview > header p,
.formatter-preview > header h3,
.formatter-validation > header p,
.formatter-validation > header h3 {
margin: 0;
}
.formatter-preview > header h3,
.formatter-validation > header h3 {
font-size: 0.95rem;
}
.formatter-preview > header > span,
.formatter-validation > header > span {
color: var(--toolbox-muted);
font-size: 0.7rem;
font-weight: 750;
text-transform: uppercase;
}
.formatter-pattern-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
padding: 0 0.8rem;
}
.formatter-pattern-grid article {
display: grid;
min-width: 0;
gap: 0.4rem;
color: var(--toolbox-muted);
font-size: 0.72rem;
}
.formatter-pattern-grid pre {
min-height: 5rem;
max-height: 16rem;
overflow: auto;
margin: 0;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.66);
padding: 0.65rem;
background: var(--toolbox-surface-soft);
color: var(--toolbox-text);
font-size: 0.75rem;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.formatter-table-scroll {
overflow: auto;
margin: 0 0.8rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.66);
}
.formatter-table-scroll table {
width: 100%;
border-collapse: collapse;
font-size: 0.72rem;
}
.formatter-table-scroll th,
.formatter-table-scroll td {
padding: 0.5rem 0.6rem;
border-bottom: 1px solid var(--toolbox-border);
text-align: left;
vertical-align: top;
}
.formatter-table-scroll th {
position: sticky;
top: 0;
background: var(--toolbox-surface-soft);
color: var(--toolbox-muted);
}
.formatter-table-scroll code {
overflow-wrap: anywhere;
}
.formatter-table-scroll > p,
.formatter-empty {
padding: 0.65rem;
}
.formatter-notices {
display: grid;
gap: 0.35rem;
margin: 0;
padding: 0 1.8rem 0.8rem 2.2rem;
color: var(--toolbox-muted);
font-size: 0.72rem;
line-height: 1.45;
}
.formatter-status {
margin: 0 0.8rem;
border-left: 3px solid var(--toolbox-accent);
padding: 0.65rem 0.75rem;
background: var(--toolbox-surface-soft);
font-size: 0.76rem;
}
.formatter-status.is-different {
border-left-color: var(--toolbox-danger);
}
.formatter-status.is-inconclusive {
border-left-color: var(--regex-warning);
}
.formatter-status p {
margin: 0.25rem 0 0;
color: var(--toolbox-danger);
}
.formatter-progress {
display: grid;
grid-template-columns: minmax(8rem, 1fr) auto;
align-items: center;
gap: 0.65rem;
padding: 0 0.8rem;
color: var(--toolbox-muted);
font-size: 0.7rem;
}
.formatter-progress progress {
width: 100%;
accent-color: var(--toolbox-accent);
}
.formatter-actions {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
padding: 0 0.8rem 0.8rem;
}
.formatter-result {
display: grid;
gap: 0.75rem;
border-top: 1px solid var(--toolbox-border);
padding-top: 0.75rem;
}
.formatter-result > dl {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
margin: 0;
padding: 0 0.8rem;
}
.formatter-result > dl > div {
padding: 0.5rem;
border-bottom: 1px solid var(--toolbox-border);
}
.formatter-result dt {
color: var(--toolbox-muted);
font-size: 0.65rem;
font-weight: 750;
text-transform: uppercase;
}
.formatter-result dd {
margin: 0.15rem 0 0;
font-size: 0.77rem;
}
.formatter-checks {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: 0.6rem;
padding: 0 0.8rem;
}
.formatter-check {
display: grid;
gap: 0.35rem;
min-width: 0;
border: 1px solid var(--toolbox-border);
border-left: 4px solid var(--toolbox-accent);
border-radius: calc(var(--toolbox-radius) * 0.7);
padding: 0.65rem;
background: var(--toolbox-surface-soft);
}
.formatter-check.is-different {
border-left-color: var(--toolbox-danger);
}
.formatter-check.is-inconclusive {
border-left-color: var(--regex-warning);
}
.formatter-check header {
display: flex;
justify-content: space-between;
gap: 0.6rem;
}
.formatter-check header span {
color: var(--toolbox-muted);
font-size: 0.65rem;
font-weight: 800;
text-transform: uppercase;
}
.formatter-check p,
.formatter-check small {
margin: 0;
color: var(--toolbox-muted);
font-size: 0.71rem;
line-height: 1.45;
}
.formatter-check code {
overflow-wrap: anywhere;
font-size: 0.67rem;
}
.formatter-result-note {
padding: 0 0.8rem;
}
.formatter-differences {
margin: 0 0.8rem;
border: 1px solid
color-mix(in srgb, var(--toolbox-danger) 35%, var(--toolbox-border));
border-radius: calc(var(--toolbox-radius) * 0.7);
padding: 0.7rem;
background: color-mix(
in srgb,
var(--toolbox-danger) 6%,
var(--toolbox-surface)
);
}
.formatter-differences h4 {
margin: 0 0 0.45rem;
font-size: 0.82rem;
}
.formatter-differences ul {
display: grid;
gap: 0.35rem;
margin: 0;
padding-left: 1.25rem;
color: var(--toolbox-muted);
font-size: 0.71rem;
line-height: 1.45;
}
.formatter-confirmation {
margin: 0 0.8rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.7);
padding: 0.65rem;
background: var(--toolbox-surface-soft);
color: var(--toolbox-muted);
font-size: 0.74rem;
line-height: 1.4;
}
@media (max-width: 720px) {
.formatter-pattern-grid {
grid-template-columns: 1fr;
}
.formatter-progress {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,245 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { EcmaScriptSyntaxProvider } from "../regex/syntax/providers/ecmascript/EcmaScriptSyntaxProvider";
import type {
PatternFormatSnapshotCheck,
PatternFormatValidationResult,
} from "../regex/formatting/formatting.types";
import {
PatternFormatterPanel,
type PatternFormatValidatorClient,
type PatternFormatterPanelProps,
} from "./PatternFormatterPanel";
const provider = new EcmaScriptSyntaxProvider();
function sameCheck(): PatternFormatSnapshotCheck {
return {
comparison: "same",
before: {
status: "complete",
engineIdentity: "Native ECMAScript RegExp · fixture",
message: "Source completed.",
},
after: {
status: "complete",
engineIdentity: "Native ECMAScript RegExp · fixture",
message: "Candidate completed.",
},
summary: "Exact normalized match and replacement output is unchanged.",
mismatchKinds: [],
};
}
function validationResult(
overrides: Partial<PatternFormatValidationResult> = {},
): PatternFormatValidationResult {
return {
status: "equivalent",
canApply: true,
sourceSyntaxAccepted: true,
formattedSyntaxAccepted: true,
captureShapePreserved: true,
current: sameCheck(),
tests: [],
applicableTestCount: 0,
skippedTestCount: 0,
completedTestCount: 0,
differences: [],
elapsedMs: 3,
notices: [
"No enabled unit test has the exact active pattern; the current snapshot was compared.",
],
...overrides,
};
}
function validator(
result: PatternFormatValidationResult = validationResult(),
): PatternFormatValidatorClient {
return {
validate: vi.fn().mockImplementation(async (_input, onProgress) => {
onProgress?.(0, 0);
return result;
}),
cancel: vi.fn(),
dispose: vi.fn(),
};
}
async function renderFormatter(
overrides: Partial<PatternFormatterPanelProps> = {},
result: PatternFormatValidationResult = validationResult(),
) {
const pattern = overrides.pattern ?? "a/b";
const syntax =
overrides.syntax ??
(await provider.parsePattern({
flavour: "ecmascript",
flavourVersion: "2025",
pattern,
flags: [],
options: {},
}));
const currentValidator = validator(result);
const onApply = vi.fn();
const rendered = render(
<PatternFormatterPanel
flavour="ecmascript"
flavourVersion="2025"
pattern={pattern}
flags={[]}
options={{}}
syntax={syntax}
subject="a/b a/b"
replacement="[$&]"
scanAll
timeoutMs={2_000}
tests={[]}
onApply={onApply}
createValidator={() => currentValidator}
{...overrides}
/>,
);
return { ...rendered, currentValidator, onApply, syntax };
}
afterEach(cleanup);
describe("PatternFormatterPanel", () => {
it("shows an exact preview and requires validation plus confirmation before apply", async () => {
const user = userEvent.setup();
const { currentValidator, onApply } = await renderFormatter();
expect(screen.getByTestId("formatted-pattern-preview").textContent).toBe(
"a\\/b",
);
expect(
screen.queryByRole("button", { name: "Apply formatted pattern" }),
).not.toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Validate exact snapshots" }),
);
await waitFor(() => {
expect(currentValidator.validate).toHaveBeenCalledTimes(1);
});
expect(
await screen.findByText(
"All completed safety gates retained the same observable behavior.",
),
).toBeVisible();
expect(screen.getByRole("checkbox")).toBeEnabled();
expect(
screen.getByRole("button", { name: "Apply formatted pattern" }),
).toBeDisabled();
await user.click(screen.getByRole("checkbox"));
await user.click(
screen.getByRole("button", { name: "Apply formatted pattern" }),
);
expect(onApply).toHaveBeenCalledWith("a\\/b");
});
it("keeps a behavior-changing candidate blocked", async () => {
const user = userEvent.setup();
await renderFormatter(
{},
validationResult({
status: "different",
canApply: false,
current: {
...sameCheck(),
comparison: "different",
summary: "Match count changed.",
mismatchKinds: ["match-count"],
},
differences: [
{
scope: "current-subject",
code: "current-behavior-changed",
summary: "Match count changed.",
},
],
}),
);
await user.click(
screen.getByRole("button", { name: "Validate exact snapshots" }),
);
expect(await screen.findByText("current-behavior-changed")).toBeVisible();
expect(screen.getByRole("checkbox")).toBeDisabled();
expect(
screen.getByRole("button", { name: "Apply formatted pattern" }),
).toBeDisabled();
});
it("reports PCRE2 and stale syntax snapshots as unavailable", async () => {
const { rerender, syntax } = await renderFormatter({
flavour: "pcre2",
syntax: undefined,
});
expect(
screen.getByText(/currently available only for ECMAScript/),
).toBeVisible();
expect(
screen.queryByRole("button", { name: "Validate exact snapshots" }),
).not.toBeInTheDocument();
rerender(
<PatternFormatterPanel
flavour="ecmascript"
flavourVersion="2025"
pattern="a/c"
flags={[]}
options={{}}
syntax={syntax}
subject=""
replacement=""
scanAll={false}
timeoutMs={2_000}
tests={[]}
onApply={vi.fn()}
/>,
);
expect(
screen.getByText(/accepted, non-recovered syntax-provider snapshot/),
).toBeVisible();
});
it("clears a validation result when an exact input snapshot changes", async () => {
const user = userEvent.setup();
const rendered = await renderFormatter();
await user.click(
screen.getByRole("button", { name: "Validate exact snapshots" }),
);
expect(await screen.findByRole("checkbox")).toBeVisible();
rendered.rerender(
<PatternFormatterPanel
flavour="ecmascript"
flavourVersion="2025"
pattern="a/b"
flags={[]}
options={{}}
syntax={rendered.syntax}
subject="different"
replacement="[$&]"
scanAll
timeoutMs={2_000}
tests={[]}
onApply={rendered.onApply}
createValidator={() => rendered.currentValidator}
/>,
);
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Validate exact snapshots" }),
).toBeEnabled();
});
});

View File

@@ -0,0 +1,617 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type {
RegexEngineOptions,
RegexFlavourId,
} from "../regex/model/flavour";
import type { RegexSyntaxResult } from "../regex/model/syntax";
import type { RegexTestCase } from "../regex/tests/test-case.types";
import { WorkerRequestError } from "../regex/execution/WorkerSupervisor";
import { formatEcmaScriptPattern } from "../regex/formatting/ecmascript-format";
import { PatternFormatValidator } from "../regex/formatting/PatternFormatValidator";
import type {
PatternFormatPreview,
PatternFormatSnapshotCheck,
PatternFormatValidationInput,
PatternFormatValidationResult,
} from "../regex/formatting/formatting.types";
import "./PatternFormatterPanel.css";
const MAXIMUM_VISIBLE_CHANGES = 200;
const MAXIMUM_VISIBLE_CHECKS = 200;
export interface PatternFormatValidatorClient {
validate(
input: PatternFormatValidationInput,
onProgress?: (completed: number, total: number) => void,
): Promise<PatternFormatValidationResult>;
cancel(): void;
dispose(): void;
}
export interface PatternFormatterPanelProps {
readonly flavour: RegexFlavourId;
readonly flavourVersion?: string;
readonly pattern: string;
readonly flags: readonly string[];
readonly options: RegexEngineOptions;
readonly syntax?: RegexSyntaxResult;
readonly subject: string;
readonly replacement: string;
readonly scanAll: boolean;
readonly timeoutMs: number;
readonly tests: readonly RegexTestCase[];
readonly onApply: (formattedPattern: string) => void;
readonly onClose?: () => void;
readonly createValidator?: () => PatternFormatValidatorClient;
}
type PreviewState =
| { readonly status: "available"; readonly preview: PatternFormatPreview }
| { readonly status: "unavailable"; readonly reason: string };
interface ValidationConfiguration {
readonly flavourVersion?: string;
readonly preview: PatternFormatPreview;
readonly flags: readonly string[];
readonly options: RegexEngineOptions;
readonly subject: string;
readonly replacement: string;
readonly scanAll: boolean;
readonly timeoutMs: number;
readonly tests: readonly RegexTestCase[];
}
interface ValidationRecord {
readonly configuration: ValidationConfiguration;
readonly result: PatternFormatValidationResult;
}
interface ConfirmationRecord {
readonly configuration: ValidationConfiguration;
readonly checked: boolean;
}
function previewState(
flavour: RegexFlavourId,
pattern: string,
syntax: RegexSyntaxResult | undefined,
): PreviewState {
if (flavour !== "ecmascript") {
return {
status: "unavailable",
reason:
"Canonical formatting is currently available only for ECMAScript. PCRE2 and other flavours remain unchanged because no complete grammar-backed formatter is implemented for them.",
};
}
if (!syntax) {
return {
status: "unavailable",
reason:
"Wait for the ECMAScript syntax provider to finish parsing the current pattern.",
};
}
try {
return {
status: "available",
preview: formatEcmaScriptPattern(pattern, syntax),
};
} catch (cause) {
return {
status: "unavailable",
reason:
cause instanceof Error
? cause.message
: "The current syntax snapshot cannot be formatted.",
};
}
}
function formatDuration(milliseconds: number): string {
if (milliseconds < 1) return "<1 ms";
if (milliseconds < 1_000) return `${milliseconds.toFixed(0)} ms`;
return `${(milliseconds / 1_000).toFixed(2)} s`;
}
function observationIdentity(check: PatternFormatSnapshotCheck): string {
const before = check.before.engineIdentity ?? check.before.status;
const after = check.after.engineIdentity ?? check.after.status;
return before === after ? before : `${before}${after}`;
}
function ValidationCheck({
title,
check,
}: {
readonly title: string;
readonly check: PatternFormatSnapshotCheck;
}) {
return (
<article className={`formatter-check is-${check.comparison}`}>
<header>
<strong>{title}</strong>
<span>{check.comparison}</span>
</header>
<p>{check.summary}</p>
<small>{observationIdentity(check)}</small>
{check.mismatchKinds.length > 0 ? (
<code>{check.mismatchKinds.join(" · ")}</code>
) : null}
</article>
);
}
export function PatternFormatterPanel({
flavour,
flavourVersion,
pattern,
flags,
options,
syntax,
subject,
replacement,
scanAll,
timeoutMs,
tests,
onApply,
onClose,
createValidator = () => new PatternFormatValidator(),
}: PatternFormatterPanelProps) {
const preview = useMemo(
() => previewState(flavour, pattern, syntax),
[flavour, pattern, syntax],
);
const configuration = useMemo<ValidationConfiguration | undefined>(
() =>
preview.status === "available"
? {
flavourVersion,
preview: preview.preview,
flags,
options,
subject,
replacement,
scanAll,
timeoutMs,
tests,
}
: undefined,
[
flags,
flavourVersion,
options,
preview,
replacement,
scanAll,
subject,
tests,
timeoutMs,
],
);
const validator = useRef<PatternFormatValidatorClient | undefined>(undefined);
const revision = useRef(0);
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState({ completed: 0, total: 0 });
const [message, setMessage] = useState(
"Review the exact preview before running bounded validation.",
);
const [error, setError] = useState<string>();
const [record, setRecord] = useState<ValidationRecord>();
const [confirmation, setConfirmation] = useState<ConfirmationRecord>();
const currentRecord =
record?.configuration === configuration ? record : undefined;
const confirmed =
confirmation !== undefined &&
confirmation.configuration === configuration &&
confirmation.checked;
useEffect(
() => () => {
revision.current += 1;
validator.current?.dispose();
validator.current = undefined;
},
[],
);
const cancel = () => {
revision.current += 1;
validator.current?.cancel();
setRunning(false);
setMessage("Validation cancelled; formatter workers were terminated.");
};
const runValidation = async () => {
if (
!configuration ||
!configuration.preview.changed ||
!configuration.preview.withinPatternLimit
) {
return;
}
const currentValidator =
validator.current ?? (validator.current = createValidator());
currentValidator.cancel();
revision.current += 1;
const runRevision = revision.current;
setRunning(true);
setConfirmation(undefined);
setError(undefined);
setRecord(undefined);
setProgress({ completed: 0, total: 0 });
setMessage(
"Reparsing, compiling and comparing exact source/candidate snapshots…",
);
try {
const result = await currentValidator.validate(
{
flavour: "ecmascript",
...(configuration.flavourVersion === undefined
? {}
: { flavourVersion: configuration.flavourVersion }),
sourcePattern: configuration.preview.sourcePattern,
formattedPattern: configuration.preview.formattedPattern,
flags: configuration.flags,
options: configuration.options,
subject: configuration.subject,
replacement: configuration.replacement,
scanAll: configuration.scanAll,
timeoutMs: configuration.timeoutMs,
tests: configuration.tests,
},
(completed, total) => {
if (revision.current !== runRevision) return;
setProgress({ completed, total });
setMessage(
total === 0
? "The current subject/replacement snapshot is being compared."
: `Validated ${completed.toLocaleString()} of ${total.toLocaleString()} applicable unit tests.`,
);
},
);
if (revision.current !== runRevision) return;
setRecord({ configuration, result });
setMessage(
result.status === "equivalent"
? "All completed safety gates retained the same observable behavior."
: result.status === "different"
? "Formatting changed at least one checked observation."
: "Validation was inconclusive; the candidate cannot be applied.",
);
} catch (cause) {
if (revision.current !== runRevision) return;
const cancelled =
cause instanceof WorkerRequestError && cause.kind === "cancelled";
setError(
cancelled
? "Validation was cancelled."
: cause instanceof Error
? cause.message
: "Formatter validation failed.",
);
setMessage(
cancelled
? "Validation cancelled; no candidate can be applied."
: "Validation failed; no candidate can be applied.",
);
} finally {
if (revision.current === runRevision) setRunning(false);
}
};
const close = () => {
if (running) cancel();
onClose?.();
};
const result = currentRecord?.result;
const canApply =
result?.canApply === true &&
confirmed &&
configuration?.preview.changed === true;
return (
<section
className="panel formatter-panel"
aria-labelledby="formatter-heading"
>
<header className="panel-heading">
<div>
<p className="eyebrow">
Grammar-backed preview + bounded proof gates
</p>
<h2 id="formatter-heading">Format pattern</h2>
</div>
<div className="panel-heading-actions">
<span className="provenance-badge">ECMAScript only</span>
{onClose ? (
<button
type="button"
className="icon-button"
aria-label="Close pattern formatter"
data-dialog-initial-focus
onClick={close}
>
×
</button>
) : null}
</div>
</header>
{preview.status === "unavailable" ? (
<p className="formatter-unavailable" role="status">
{preview.reason}
</p>
) : (
<div className="formatter-body">
<p className="formatter-advisory">
This deliberately narrow formatter escapes literal slashes and raw
control/line-separator characters. It does not insert insignificant
whitespaceECMAScript patterns generally have no such whitespace.
</p>
<section className="formatter-preview" aria-label="Format preview">
<header>
<div>
<p className="eyebrow">
{preview.preview.formatter.id} · v
{preview.preview.formatter.version}
</p>
<h3>Exact preview</h3>
</div>
<span>
{preview.preview.totalChanges.toLocaleString()} transformation
{preview.preview.totalChanges === 1 ? "" : "s"}
</span>
</header>
<div className="formatter-pattern-grid">
<article>
<strong>Source</strong>
<pre>
<code>{preview.preview.sourcePattern}</code>
</pre>
</article>
<article>
<strong>Formatted candidate</strong>
<pre data-testid="formatted-pattern-preview">
<code>{preview.preview.formattedPattern}</code>
</pre>
</article>
</div>
{preview.preview.changed ? (
<div className="formatter-table-scroll">
<table aria-label="Formatter transformations">
<thead>
<tr>
<th scope="col">UTF-16 range</th>
<th scope="col">Kind</th>
<th scope="col">Before</th>
<th scope="col">After</th>
</tr>
</thead>
<tbody>
{preview.preview.changes
.slice(0, MAXIMUM_VISIBLE_CHANGES)
.map((change) => (
<tr
key={`${change.startUtf16}:${change.endUtf16}:${change.kind}`}
title={change.explanation}
>
<td>
{change.startUtf16}{change.endUtf16}
</td>
<td>{change.kind.replaceAll("-", " ")}</td>
<td>
<code>{JSON.stringify(change.before)}</code>
</td>
<td>
<code>{JSON.stringify(change.after)}</code>
</td>
</tr>
))}
</tbody>
</table>
{preview.preview.totalChanges > MAXIMUM_VISIBLE_CHANGES ? (
<p>
The table shows the first{" "}
{MAXIMUM_VISIBLE_CHANGES.toLocaleString()} transformations.
</p>
) : null}
</div>
) : (
<p className="formatter-empty">
This pattern already uses the formatters canonical literal and
control escapes.
</p>
)}
<ul className="formatter-notices">
{preview.preview.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</section>
{preview.preview.changed ? (
<section className="formatter-validation">
<header>
<div>
<p className="eyebrow">Required before apply</p>
<h3>Safety validation</h3>
</div>
<span>
{result?.status ?? (running ? "running" : "not run")}
</span>
</header>
<div
className={`formatter-status is-${result?.status ?? (error ? "different" : "idle")}`}
role="status"
aria-live="polite"
>
<strong>{message}</strong>
{error ? <p>{error}</p> : null}
</div>
{running ? (
<div className="formatter-progress">
<progress
max={Math.max(1, progress.total)}
value={Math.min(progress.completed, progress.total)}
/>
<span>
{progress.completed.toLocaleString()} /{" "}
{progress.total.toLocaleString()} tests
</span>
</div>
) : null}
<div className="formatter-actions">
<button
type="button"
className="primary-button"
disabled={
running ||
!preview.preview.withinPatternLimit ||
!preview.preview.changed
}
onClick={() => void runValidation()}
>
Validate exact snapshots
</button>
{running ? (
<button
type="button"
className="secondary-button"
onClick={cancel}
>
Cancel
</button>
) : null}
</div>
{result ? (
<div className="formatter-result">
<dl>
<div>
<dt>Syntax</dt>
<dd>
{result.sourceSyntaxAccepted &&
result.formattedSyntaxAccepted
? "both accepted"
: "rejected"}
</dd>
</div>
<div>
<dt>Capture shape</dt>
<dd>
{result.captureShapePreserved ? "preserved" : "changed"}
</dd>
</div>
<div>
<dt>Unit tests</dt>
<dd>
{result.completedTestCount.toLocaleString()} /{" "}
{result.applicableTestCount.toLocaleString()} applicable
</dd>
</div>
<div>
<dt>Unrelated / disabled</dt>
<dd>{result.skippedTestCount.toLocaleString()}</dd>
</div>
<div>
<dt>Elapsed</dt>
<dd>{formatDuration(result.elapsedMs)}</dd>
</div>
</dl>
<div className="formatter-checks">
<ValidationCheck
title="Current subject and replacement"
check={result.current}
/>
{result.tests
.slice(0, MAXIMUM_VISIBLE_CHECKS)
.map((test) => (
<ValidationCheck
key={test.testId}
title={test.name}
check={test}
/>
))}
</div>
{result.tests.length > MAXIMUM_VISIBLE_CHECKS ? (
<p className="formatter-result-note">
The panel shows the first{" "}
{MAXIMUM_VISIBLE_CHECKS.toLocaleString()} completed test
comparisons.
</p>
) : null}
{result.differences.length > 0 ? (
<section
className="formatter-differences"
aria-labelledby="formatter-differences-heading"
>
<h4 id="formatter-differences-heading">
Differences and inconclusive gates
</h4>
<ul>
{result.differences
.slice(0, MAXIMUM_VISIBLE_CHECKS)
.map((difference, index) => (
<li
key={`${difference.code}:${difference.testId ?? ""}:${index}`}
>
<strong>{difference.code}</strong>{" "}
{difference.summary}
</li>
))}
</ul>
</section>
) : null}
<ul className="formatter-notices">
{result.notices.map((notice) => (
<li key={notice}>{notice}</li>
))}
</ul>
<label className="check-control formatter-confirmation">
<input
type="checkbox"
checked={confirmed}
disabled={!result.canApply}
onChange={(event) => {
if (!configuration) return;
setConfirmation({
configuration,
checked: event.target.checked,
});
}}
/>
<span>
I understand that this bounded validation is not a proof
for every possible subject, and I want to apply the exact
candidate shown above.
</span>
</label>
<div className="formatter-actions">
<button
type="button"
className="primary-button"
disabled={!canApply}
onClick={() => {
if (!canApply || !configuration) return;
onApply(configuration.preview.formattedPattern);
}}
>
Apply formatted pattern
</button>
</div>
</div>
) : null}
</section>
) : null}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,59 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Pcre2TracePanel } from "./Pcre2TracePanel";
function renderPanel(flavour: "ecmascript" | "pcre2") {
return render(
<Pcre2TracePanel
active
flavour={flavour}
pattern="(a+)+$"
flags={["g"]}
options={
flavour === "pcre2"
? {
matchLimit: 1_000_000,
depthLimit: 1_000,
heapLimitKib: 32_768,
}
: {}
}
subject="aaaa!"
timeoutMs={2_000}
onClose={vi.fn()}
onSelectRanges={vi.fn()}
/>,
);
}
describe("Pcre2TracePanel", () => {
it("discloses reported versus derived trace semantics and hard caps", () => {
renderPanel("pcre2");
expect(
screen.getByRole("heading", { name: "Execution trace" }),
).toBeInTheDocument();
expect(
screen.getByText(/actual stream reported by PCRE2_AUTO_CALLOUT/u),
).toBeInTheDocument();
expect(
screen.getByText(/Movement labels are derived only/u),
).toBeInTheDocument();
expect(
screen.getByRole("spinbutton", { name: "Maximum events" }),
).toHaveAttribute("max", "50000");
expect(
screen.getByRole("spinbutton", {
name: "Maximum serialized bytes",
}),
).toHaveAttribute("max", String(10 * 1024 * 1024));
expect(screen.getByRole("button", { name: "Run trace" })).toBeEnabled();
});
it("does not offer an invented ECMAScript trace", () => {
renderPanel("ecmascript");
expect(screen.getByRole("button", { name: "Run trace" })).toBeDisabled();
expect(screen.getByText(/Select PCRE2 to enable/u)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,407 @@
import { useEffect, useRef, useState } from "react";
import { Pcre2TraceSupervisor } from "../regex/execution/Pcre2TraceSupervisor";
import { WorkerRequestError } from "../regex/execution/WorkerSupervisor";
import { DEFAULT_REGEX_LIMITS } from "../regex/execution/request-limits";
import type {
RegexEngineOptions,
RegexFlavourId,
} from "../regex/model/flavour";
import type { Pcre2TraceEvent, Pcre2TraceResult } from "../regex/model/trace";
import type { SourceRange } from "../regex/model/syntax";
const DEFAULT_TRACE_EVENTS = 5_000;
const DEFAULT_TRACE_BYTES = 2 * 1024 * 1024;
const MAXIMUM_RENDERED_TRACE_EVENTS = 2_000;
type TraceState =
| { readonly status: "idle"; readonly message: string }
| { readonly status: "running"; readonly message: string }
| { readonly status: "ready"; readonly message: string }
| { readonly status: "timeout"; readonly message: string }
| { readonly status: "error"; readonly message: string };
function movementLabel(event: Pcre2TraceEvent): string {
switch (event.movement.classification) {
case "first-event":
return "first";
case "same-position":
return "same position";
case "apparent-backtrack":
return "apparent backtrack";
case "forward":
return "forward";
}
}
export function Pcre2TracePanel({
active,
flavour,
pattern,
flags,
options,
subject,
timeoutMs,
onClose,
onSelectRanges,
}: {
readonly active: boolean;
readonly flavour: RegexFlavourId;
readonly pattern: string;
readonly flags: readonly string[];
readonly options: RegexEngineOptions;
readonly subject: string;
readonly timeoutMs: number;
readonly onClose: () => void;
readonly onSelectRanges: (
patternRange: SourceRange,
subjectRange: SourceRange,
) => void;
}) {
const supervisor = useRef<Pcre2TraceSupervisor | null>(null);
const requestRevision = useRef(0);
const [maximumEvents, setMaximumEvents] = useState(DEFAULT_TRACE_EVENTS);
const [maximumBytes, setMaximumBytes] = useState(DEFAULT_TRACE_BYTES);
const [completed, setCompleted] = useState<{
readonly result: Pcre2TraceResult;
readonly input: {
readonly pattern: string;
readonly subject: string;
readonly flags: string;
readonly options: string;
};
}>();
const [state, setState] = useState<TraceState>({
status: "idle",
message: "Run an isolated PCRE2 automatic-callout trace.",
});
const traceEngine = () => {
supervisor.current ??= new Pcre2TraceSupervisor();
return supervisor.current;
};
useEffect(() => {
return () => supervisor.current?.dispose();
}, []);
useEffect(() => {
if (active) return;
supervisor.current?.cancel();
}, [active]);
const limitsValid =
Number.isSafeInteger(maximumEvents) &&
maximumEvents >= 1 &&
maximumEvents <= DEFAULT_REGEX_LIMITS.maximumTraceEvents &&
Number.isSafeInteger(maximumBytes) &&
maximumBytes >= 32 &&
maximumBytes <= DEFAULT_REGEX_LIMITS.maximumTraceBytes;
const runTrace = async () => {
if (flavour !== "pcre2" || !limitsValid) return;
const revision = (requestRevision.current += 1);
setCompleted(undefined);
setState({
status: "running",
message: "Collecting reported callouts in a dedicated worker…",
});
try {
const next = await traceEngine().trace(
{
flavour,
pattern,
flags,
options,
subject,
maximumTraceEvents: maximumEvents,
maximumTraceBytes: maximumBytes,
},
timeoutMs,
);
if (revision !== requestRevision.current) return;
setCompleted({
result: next,
input: {
pattern,
subject,
flags: flags.join(""),
options: JSON.stringify(options),
},
});
const error = next.diagnostics.find(
(diagnostic) => diagnostic.severity === "error",
);
setState(
next.accepted
? {
status: "ready",
message: next.truncated
? "Bounded trace complete; collection stopped at a configured cap."
: "Bounded trace complete.",
}
: {
status: "error",
message: error?.message ?? "PCRE2 rejected the trace request.",
},
);
} catch (error) {
if (revision !== requestRevision.current) return;
if (error instanceof WorkerRequestError && error.kind === "timeout") {
setState({
status: "timeout",
message: `${error.message}; the trace worker was terminated.`,
});
} else if (
error instanceof WorkerRequestError &&
error.kind === "cancelled"
) {
setState({
status: "idle",
message: "Trace cancelled; its worker was terminated.",
});
} else {
setState({
status: "error",
message:
error instanceof Error
? error.message
: "The PCRE2 trace worker failed.",
});
}
}
};
const resultIsCurrent =
completed?.input.pattern === pattern &&
completed.input.subject === subject &&
completed.input.flags === flags.join("") &&
completed.input.options === JSON.stringify(options);
const visibleResult = resultIsCurrent ? completed.result : undefined;
const visibleState =
resultIsCurrent || state.status === "running"
? state
: {
status: "idle" as const,
message: "Inputs changed; run a new isolated trace.",
};
const renderedEvents =
visibleResult?.events.slice(0, MAXIMUM_RENDERED_TRACE_EVENTS) ?? [];
return (
<section className="panel trace-panel" aria-labelledby="trace-heading">
<header className="panel-heading">
<div>
<p className="eyebrow">PCRE2 10.47 · reported automatic callouts</p>
<h2 id="trace-heading">Execution trace</h2>
</div>
<button
type="button"
className="icon-button"
aria-label="Close execution trace"
data-dialog-initial-focus
onClick={onClose}
>
×
</button>
</header>
<p className="trace-disclosure">
This is an actual stream reported by PCRE2_AUTO_CALLOUT for one{" "}
<code>pcre2_match()</code> invocation. Movement labels are derived only
from adjacent reported subject positions; the stream is not a complete
record of every internal engine action.
</p>
<div className="trace-controls">
<label>
<span>Maximum events</span>
<input
type="number"
min={1}
max={DEFAULT_REGEX_LIMITS.maximumTraceEvents}
step={1}
value={maximumEvents}
onChange={(event) => setMaximumEvents(Number(event.target.value))}
/>
</label>
<label>
<span>Maximum serialized bytes</span>
<input
type="number"
min={32}
max={DEFAULT_REGEX_LIMITS.maximumTraceBytes}
step={1024}
value={maximumBytes}
onChange={(event) => setMaximumBytes(Number(event.target.value))}
/>
</label>
<button
type="button"
className="primary-button"
disabled={
flavour !== "pcre2" ||
!limitsValid ||
visibleState.status === "running"
}
onClick={() => void runTrace()}
>
{visibleState.status === "running" ? "Tracing…" : "Run trace"}
</button>
{visibleState.status === "running" ? (
<button
type="button"
className="secondary-button"
onClick={() => {
requestRevision.current += 1;
supervisor.current?.cancel();
setState({
status: "idle",
message: "Trace cancelled; its worker was terminated.",
});
}}
>
Cancel trace
</button>
) : null}
</div>
{flavour !== "pcre2" ? (
<p className="empty-state" role="status">
Select PCRE2 to enable its automatic-callout trace.
</p>
) : null}
<div
className={`trace-status status-${visibleState.status}`}
role="status"
aria-live="polite"
>
<strong>{visibleState.message}</strong>
{visibleResult?.accepted ? (
<small>
{visibleResult.events.length.toLocaleString()} complete retained
events of {visibleResult.totalEventCount.toLocaleString()} callback
event(s) observed before return ·{" "}
{visibleResult.traceBytes.toLocaleString()} serialized bytes · last
complete event{" "}
{visibleResult.lastCompleteEvent?.toLocaleString() ?? "none"} ·{" "}
native status {visibleResult.nativeMatchStatus} ·{" "}
{visibleResult.matched ? "matched" : "not matched or bounded stop"}{" "}
· {visibleResult.elapsedMs.toFixed(2)} ms
</small>
) : null}
</div>
{visibleResult?.diagnostics.length ? (
<ul className="trace-diagnostics">
{visibleResult.diagnostics.map((diagnostic) => (
<li
key={diagnostic.id}
className={`diagnostic-${diagnostic.severity}`}
>
<strong>{diagnostic.severity}</strong> {diagnostic.message}
</li>
))}
</ul>
) : null}
{visibleResult && visibleResult.events.length > renderedEvents.length ? (
<p className="render-limit-banner" role="status">
<strong>Trace viewer limited.</strong> Showing the first{" "}
{renderedEvents.length.toLocaleString()} of{" "}
{visibleResult.events.length.toLocaleString()} complete retained
events. The worker result remains bounded by both configured caps.
</p>
) : null}
{renderedEvents.length > 0 ? (
<div className="trace-table-scroll">
<table className="trace-table">
<thead>
<tr>
<th scope="col">Event</th>
<th scope="col">Callout</th>
<th scope="col">Pattern</th>
<th scope="col">Next item</th>
<th scope="col">Subject</th>
<th scope="col">Captures</th>
<th scope="col">Mark</th>
<th scope="col">Movement</th>
</tr>
</thead>
<tbody>
{renderedEvents.map((event) => (
<tr key={event.eventNumber}>
<td>
<button
type="button"
className="trace-event-select"
aria-label={`Select trace event ${event.eventNumber}`}
onClick={() =>
onSelectRanges(
{
startUtf16:
event.reported.nextPatternItem.startUtf16,
endUtf16: event.reported.nextPatternItem.endUtf16,
},
{
startUtf16: event.reported.subjectPosition.utf16,
endUtf16: event.reported.subjectPosition.utf16,
},
)
}
>
{event.eventNumber}
</button>
</td>
<td>{event.reported.calloutNumber}</td>
<td>
{event.reported.patternPosition.utf16} UTF-16
<small>
{event.reported.patternPosition.nativeByte} byte
</small>
</td>
<td>
<code>
{pattern.slice(
event.reported.nextPatternItem.startUtf16,
event.reported.nextPatternItem.endUtf16,
) || "end"}
</code>
<small>
{event.reported.nextPatternItem.startNativeByte}
{event.reported.nextPatternItem.endNativeByte} bytes
</small>
</td>
<td>
{event.reported.subjectPosition.utf16} UTF-16
<small>
{event.reported.subjectPosition.nativeByte} byte
</small>
</td>
<td>
top {event.reported.captureTop} · last{" "}
{event.reported.captureLast}
</td>
<td>
{event.reported.mark ? (
<code>{event.reported.mark}</code>
) : (
"—"
)}
</td>
<td>
<span
className={`trace-movement trace-${event.movement.classification}`}
title={event.movement.explanation}
>
{movementLabel(event)}
</span>
<small>
{event.movement.subjectDeltaBytes > 0 ? "+" : ""}
{event.movement.subjectDeltaBytes} bytes · derived
</small>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : visibleResult?.accepted ? (
<p className="empty-state">No callout event was retained.</p>
) : null}
</section>
);
}

View File

@@ -65,6 +65,10 @@ export function ProjectPanel({
<p> <p>
<strong>{project.name}</strong> · schema {project.schemaVersion} <strong>{project.name}</strong> · schema {project.schemaVersion}
</p> </p>
<p className="field-help">
Corpus documents, applied outputs and corpus results are always excluded
from project JSON and local saves.
</p>
<label className="check-control"> <label className="check-control">
<input <input
type="checkbox" type="checkbox"

View File

@@ -5,9 +5,9 @@ import { QuickReference } from "./QuickReference";
describe("QuickReference", () => { describe("QuickReference", () => {
it("shows flavour, flags, example and authoritative source before insertion", async () => { it("shows flavour, flags, example and authoritative source before insertion", async () => {
const onInsert = vi.fn(); const onUsePattern = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();
render(<QuickReference onInsert={onInsert} />); render(<QuickReference onUsePattern={onUsePattern} />);
await user.type( await user.type(
screen.getByLabelText("Filter constructs"), screen.getByLabelText("Filter constructs"),
@@ -20,7 +20,37 @@ describe("QuickReference", () => {
screen.getByRole("link", { name: "ECMAScript Atom grammar" }), screen.getByRole("link", { name: "ECMAScript Atom grammar" }),
).toHaveAttribute("href", expect.stringContaining("tc39.es")); ).toHaveAttribute("href", expect.stringContaining("tc39.es"));
await user.click(screen.getByRole("button", { name: "Insert example" })); await user.click(
expect(onInsert).toHaveBeenCalledWith("a.b"); screen.getByRole("button", { name: "Use example as pattern" }),
);
expect(onUsePattern).toHaveBeenCalledWith("a.b");
});
it("shows an explicit empty state for a filter without matches", async () => {
const user = userEvent.setup();
render(<QuickReference onUsePattern={vi.fn()} />);
await user.type(
screen.getByLabelText("Filter constructs"),
"no such construct",
);
expect(screen.getByRole("status")).toHaveTextContent(
"No reference constructs match",
);
expect(screen.getByText("0 of 12 constructs")).toBeInTheDocument();
});
it("switches to the authoritative PCRE2 construct set", async () => {
const user = userEvent.setup();
render(<QuickReference flavour="pcre2" onUsePattern={vi.fn()} />);
expect(screen.getByText("Original PCRE2 reference")).toBeInTheDocument();
expect(screen.getByText("8 of 8 constructs")).toBeInTheDocument();
await user.type(screen.getByLabelText("Filter constructs"), "branch reset");
expect(screen.getByText("Branch-reset group")).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "PCRE2 pattern documentation" }),
).toHaveAttribute("href", expect.stringContaining("pcre.org"));
}); });
}); });

View File

@@ -1,16 +1,32 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { ECMASCRIPT_REFERENCE } from "../regex/reference/token-reference"; import type { RegexFlavourId } from "../regex/model/flavour";
import {
ECMASCRIPT_REFERENCE,
PCRE2_REFERENCE,
} from "../regex/reference/token-reference";
export function QuickReference({ export function QuickReference({
onInsert, onUsePattern,
onClose,
flavour = "ecmascript",
}: { }: {
readonly onInsert: (syntax: string) => void; readonly onUsePattern: (syntax: string) => void;
readonly onClose?: () => void;
readonly flavour?: RegexFlavourId;
}) { }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const reference =
flavour === "pcre2" ? PCRE2_REFERENCE : ECMASCRIPT_REFERENCE;
const entries = useMemo(() => { const entries = useMemo(() => {
const needle = query.toLocaleLowerCase(); const normalizeSearchText = (value: string) =>
value
.toLocaleLowerCase()
.replaceAll(/[^\p{L}\p{N}]+/gu, " ")
.trim();
const needle = normalizeSearchText(query);
return needle return needle
? ECMASCRIPT_REFERENCE.filter((entry) => ? reference.filter((entry) =>
normalizeSearchText(
[ [
entry.construct, entry.construct,
entry.syntax, entry.syntax,
@@ -18,13 +34,11 @@ export function QuickReference({
entry.example, entry.example,
entry.flags.join(" "), entry.flags.join(" "),
entry.source, entry.source,
] ].join(" "),
.join(" ") ).includes(needle),
.toLocaleLowerCase()
.includes(needle),
) )
: ECMASCRIPT_REFERENCE; : reference;
}, [query]); }, [query, reference]);
return ( return (
<section <section
className="panel reference-panel" className="panel reference-panel"
@@ -32,9 +46,27 @@ export function QuickReference({
> >
<header className="panel-heading"> <header className="panel-heading">
<div> <div>
<p className="eyebrow">Original ECMAScript reference</p> <p className="eyebrow">
Original {flavour === "pcre2" ? "PCRE2" : "ECMAScript"} reference
</p>
<h2 id="reference-heading">Quick reference</h2> <h2 id="reference-heading">Quick reference</h2>
</div> </div>
<div className="panel-heading-actions">
<span>
{entries.length} of {reference.length} constructs
</span>
{onClose ? (
<button
type="button"
className="icon-button"
aria-label="Close quick reference"
data-dialog-initial-focus
onClick={onClose}
>
×
</button>
) : null}
</div>
</header> </header>
<label className="stacked-control"> <label className="stacked-control">
<span>Filter constructs</span> <span>Filter constructs</span>
@@ -44,6 +76,7 @@ export function QuickReference({
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
/> />
</label> </label>
{entries.length > 0 ? (
<ul className="reference-list"> <ul className="reference-list">
{entries.map((entry) => ( {entries.map((entry) => (
<li key={entry.id}> <li key={entry.id}>
@@ -82,13 +115,18 @@ export function QuickReference({
<button <button
type="button" type="button"
className="secondary-button" className="secondary-button"
onClick={() => onInsert(entry.example)} onClick={() => onUsePattern(entry.example)}
> >
Insert example Use example as pattern
</button> </button>
</li> </li>
))} ))}
</ul> </ul>
) : (
<p className="empty-state" role="status">
No reference constructs match {query.trim()}.
</p>
)}
</section> </section>
); );
} }

View File

@@ -9,6 +9,7 @@ import type {
ReplacementContributionPreview, ReplacementContributionPreview,
ReplacementPreview, ReplacementPreview,
} from "../regex/replacement/replacement-preview"; } from "../regex/replacement/replacement-preview";
import type { RegexFlavourId } from "../regex/model/flavour";
import { import {
DEFAULT_REGEX_LIMITS, DEFAULT_REGEX_LIMITS,
TEMPLATE_PRESENTATION_LIMITS, TEMPLATE_PRESENTATION_LIMITS,
@@ -50,6 +51,7 @@ function ReplacementTokenRow({
} }
export function ReplacementPanel({ export function ReplacementPanel({
flavour,
replacement, replacement,
onReplacementChange, onReplacementChange,
syntax, syntax,
@@ -61,6 +63,7 @@ export function ReplacementPanel({
onSelectToken, onSelectToken,
onSelectContribution, onSelectContribution,
}: { }: {
readonly flavour?: RegexFlavourId;
readonly replacement: string; readonly replacement: string;
readonly onReplacementChange: (value: string) => void; readonly onReplacementChange: (value: string) => void;
readonly syntax?: ReplacementSyntaxResult; readonly syntax?: ReplacementSyntaxResult;
@@ -74,6 +77,9 @@ export function ReplacementPanel({
contribution: ReplacementContributionPreview, contribution: ReplacementContributionPreview,
) => void; ) => void;
}) { }) {
const activeFlavour =
flavour ?? result?.execution.engine.flavour ?? "ecmascript";
const engineLabel = activeFlavour === "pcre2" ? "PCRE2" : "ECMAScript";
const retainedTokens = syntax?.tokens ?? []; const retainedTokens = syntax?.tokens ?? [];
const editorTokens = retainedTokens.slice( const editorTokens = retainedTokens.slice(
0, 0,
@@ -94,7 +100,7 @@ export function ReplacementPanel({
<section className="panel editor-panel"> <section className="panel editor-panel">
<header className="panel-heading"> <header className="panel-heading">
<div> <div>
<p className="eyebrow">ECMAScript template syntax</p> <p className="eyebrow">{engineLabel} template syntax</p>
<h2>Replacement</h2> <h2>Replacement</h2>
</div> </div>
<span className="provenance-badge">Templates only · no code</span> <span className="provenance-badge">Templates only · no code</span>
@@ -230,7 +236,7 @@ export function ReplacementPanel({
<header className="panel-heading"> <header className="panel-heading">
<div> <div>
<p className="eyebrow"> <p className="eyebrow">
Native matches · bounded ECMAScript substitution Native matches · bounded {engineLabel} substitution
</p> </p>
<h2>Replacement output</h2> <h2>Replacement output</h2>
</div> </div>

View File

@@ -25,6 +25,7 @@ function renderPanel(
const props: UnitTestPanelProps = { const props: UnitTestPanelProps = {
tests: [], tests: [],
results: new Map<string, RegexTestResult>(), results: new Map<string, RegexTestResult>(),
currentFlavour: "ecmascript",
currentPattern: "current-pattern", currentPattern: "current-pattern",
currentFlags: ["u"], currentFlags: ["u"],
currentScanAll: false, currentScanAll: false,
@@ -489,6 +490,41 @@ describe("UnitTestPanel", () => {
expect(screen.queryByText("Passing case")).not.toBeInTheDocument(); expect(screen.queryByText("Passing case")).not.toBeInTheDocument();
}); });
it("shows generated seed provenance and removes it from an edited draft", async () => {
const user = userEvent.setup();
const generated: RegexTestCase = {
id: "generated",
name: "Generated positive",
enabled: true,
flavour: "ecmascript",
pattern: "cat",
flags: [],
options: {},
subject: "cat",
expectation: { kind: "should-match" },
generation: {
kind: "generated",
generatorId: "regex-tools-ast-cases",
generatorVersion: "1",
seed: "browser-seed-42",
candidateId: "generated-aabbccdd-1",
intendedOutcome: "match",
},
};
const props = renderPanel({ tests: [generated] });
expect(screen.getByText(/generated · seed browser-seed-42/u)).toBeVisible();
await user.click(
screen.getByRole("button", { name: "Edit Generated positive" }),
);
await user.click(screen.getByRole("button", { name: "Update test" }));
expect(props.onUpdate).toHaveBeenCalledWith(
"generated",
expect.not.objectContaining({ generation: expect.anything() }),
);
});
it("adds a bounded snapshot of the latest current result", async () => { it("adds a bounded snapshot of the latest current result", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const currentResultDraft: RegexTestDraft = { const currentResultDraft: RegexTestDraft = {

View File

@@ -22,21 +22,17 @@ import {
MAXIMUM_REGEX_TEST_SUITE_FILE_BYTES, MAXIMUM_REGEX_TEST_SUITE_FILE_BYTES,
serializeRegexTestSuite, serializeRegexTestSuite,
} from "../regex/tests/test-suite.serialization"; } from "../regex/tests/test-suite.serialization";
import type { RegexFlavourId } from "../regex/model/flavour";
import {
AVAILABLE_REGEX_FLAVOURS,
parseRegexFlags,
type RegexFlavourDefinition,
} from "../regex/flavours/flavour-registry";
const MAXIMUM_MATCH_COUNT = 1_000_000; const MAXIMUM_MATCH_COUNT = 1_000_000;
const MAXIMUM_MATCH_INDEX = 1_000_000; const MAXIMUM_MATCH_INDEX = 1_000_000;
const MAXIMUM_CAPTURE_NUMBER = 1_000; const MAXIMUM_CAPTURE_NUMBER = 1_000;
const MAXIMUM_TIMEOUT_MS = DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs; const MAXIMUM_TIMEOUT_MS = DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs;
const FLAG_OPTIONS = [
["g", "Global"],
["i", "Ignore case"],
["m", "Multiline"],
["s", "Dot all"],
["u", "Unicode"],
["v", "Unicode sets"],
["y", "Sticky"],
["d", "Expose indices"],
] as const;
type CaptureSelector = "number" | "name"; type CaptureSelector = "number" | "name";
type CaptureStatus = "participated" | "did-not-participate" | "matched-empty"; type CaptureStatus = "participated" | "did-not-participate" | "matched-empty";
@@ -52,11 +48,13 @@ export interface RegexTestDraft {
readonly resourceLimits?: Readonly< readonly resourceLimits?: Readonly<
Pick<RegexResourceLimits, "manualExecutionTimeoutMs"> Pick<RegexResourceLimits, "manualExecutionTimeoutMs">
>; >;
readonly generation?: RegexTestCase["generation"];
} }
export interface UnitTestPanelProps { export interface UnitTestPanelProps {
readonly tests: readonly RegexTestCase[]; readonly tests: readonly RegexTestCase[];
readonly results: ReadonlyMap<string, RegexTestResult>; readonly results: ReadonlyMap<string, RegexTestResult>;
readonly currentFlavour: RegexFlavourId;
readonly currentPattern: string; readonly currentPattern: string;
readonly currentFlags: readonly string[]; readonly currentFlags: readonly string[];
readonly currentScanAll: boolean; readonly currentScanAll: boolean;
@@ -104,7 +102,10 @@ function boundedText(value: string, label: string, maximum: number): string {
return value; return value;
} }
function validateDraft(draft: RegexTestDraft): RegexTestDraft { function validateDraft(
draft: RegexTestDraft,
flavour: RegexFlavourDefinition,
): RegexTestDraft {
boundedText(draft.name, "Test name", MAXIMUM_REGEX_TEST_NAME_UTF16); boundedText(draft.name, "Test name", MAXIMUM_REGEX_TEST_NAME_UTF16);
boundedText(draft.pattern, "Pattern", MAXIMUM_REGEX_TEST_PATTERN_UTF16); boundedText(draft.pattern, "Pattern", MAXIMUM_REGEX_TEST_PATTERN_UTF16);
boundedText(draft.subject, "Subject", MAXIMUM_REGEX_TEST_SUBJECT_UTF16); boundedText(draft.subject, "Subject", MAXIMUM_REGEX_TEST_SUBJECT_UTF16);
@@ -114,19 +115,7 @@ function validateDraft(draft: RegexTestDraft): RegexTestDraft {
`Subject exceeds the ${DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes.toLocaleString()} UTF-8 byte limit.`, `Subject exceeds the ${DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes.toLocaleString()} UTF-8 byte limit.`,
); );
} }
if (new Set(draft.flags).size !== draft.flags.length) { parseRegexFlags(draft.flags, flavour, "Flags");
throw new Error("Flags must not contain duplicates.");
}
if (
draft.flags.some(
(flag) => !FLAG_OPTIONS.some(([supported]) => supported === flag),
)
) {
throw new Error("Flags contain an unsupported ECMAScript flag.");
}
if (draft.flags.includes("u") && draft.flags.includes("v")) {
throw new Error("ECMAScript flags u and v cannot be combined.");
}
if ( if (
draft.replacement !== undefined && draft.replacement !== undefined &&
draft.replacement.length > draft.replacement.length >
@@ -211,6 +200,7 @@ function downloadJson(serialized: string): void {
export function UnitTestPanel({ export function UnitTestPanel({
tests, tests,
results, results,
currentFlavour,
currentPattern, currentPattern,
currentFlags, currentFlags,
currentScanAll, currentScanAll,
@@ -228,6 +218,7 @@ export function UnitTestPanel({
onCancel, onCancel,
running, running,
}: UnitTestPanelProps) { }: UnitTestPanelProps) {
const flavourDefinition = AVAILABLE_REGEX_FLAVOURS.require(currentFlavour);
const helpId = useId(); const helpId = useId();
const errorId = useId(); const errorId = useId();
const formId = useId(); const formId = useId();
@@ -357,7 +348,8 @@ export function UnitTestPanel({
1, 1,
MAXIMUM_TIMEOUT_MS, MAXIMUM_TIMEOUT_MS,
); );
return validateDraft({ return validateDraft(
{
name: name.trim() || "Untitled test", name: name.trim() || "Untitled test",
pattern, pattern,
flags, flags,
@@ -374,7 +366,9 @@ export function UnitTestPanel({
manualExecutionTimeoutMs: timeout, manualExecutionTimeoutMs: timeout,
}, },
}), }),
}); },
flavourDefinition,
);
} }
function submitTest(): void { function submitTest(): void {
@@ -499,7 +493,7 @@ export function UnitTestPanel({
function addCurrentResult(): void { function addCurrentResult(): void {
if (running || maximumReached || !currentResultDraft) return; if (running || maximumReached || !currentResultDraft) return;
try { try {
onAdd(validateDraft(currentResultDraft)); onAdd(validateDraft(currentResultDraft, flavourDefinition));
setFormError(undefined); setFormError(undefined);
} catch (error) { } catch (error) {
setFormError(error instanceof Error ? error.message : String(error)); setFormError(error instanceof Error ? error.message : String(error));
@@ -585,27 +579,36 @@ export function UnitTestPanel({
</label> </label>
<fieldset className="test-flag-selector"> <fieldset className="test-flag-selector">
<legend>Flags</legend> <legend>Flags</legend>
{FLAG_OPTIONS.map(([flag, label]) => ( {flavourDefinition.flags.map((flag) => (
<label key={flag} title={label}> <label key={flag.value} title={flag.label}>
<input <input
type="checkbox" type="checkbox"
aria-label={`${label} (${flag})`} aria-label={`${flag.label} (${flag.value})`}
checked={flags.includes(flag)} checked={flags.includes(flag.value)}
onChange={(event) => { onChange={(event) => {
setFlags((current) => { setFlags((current) => {
const next = new Set(current); const next = new Set(current);
if (event.target.checked) { if (event.target.checked) {
next.add(flag); next.add(flag.value);
if (flag === "u") next.delete("v"); for (const group of flavourDefinition.mutuallyExclusiveFlags ??
if (flag === "v") next.delete("u"); []) {
} else { if (!group.includes(flag.value)) continue;
next.delete(flag); for (const incompatible of group) {
if (incompatible !== flag.value) {
next.delete(incompatible);
} }
return [...next]; }
}
} else {
next.delete(flag.value);
}
return flavourDefinition.flags
.map((definition) => definition.value)
.filter((value) => next.has(value));
}); });
}} }}
/> />
<span>{flag}</span> <span>{flag.value}</span>
</label> </label>
))} ))}
</fieldset> </fieldset>
@@ -1041,6 +1044,9 @@ export function UnitTestPanel({
/{test.pattern}/{test.flags.join("")} ·{" "} /{test.pattern}/{test.flags.join("")} ·{" "}
{expectationSummary(test.expectation)} ·{" "} {expectationSummary(test.expectation)} ·{" "}
{test.subject.length.toLocaleString()} UTF-16 units {test.subject.length.toLocaleString()} UTF-16 units
{test.generation
? ` · generated · seed ${test.generation.seed}`
: ""}
</small> </small>
</span> </span>
</label> </label>

View File

@@ -240,6 +240,16 @@ describe("Workbench syntax coordination", () => {
fireEvent.click(screen.getByRole("checkbox", { name: "Live" })); fireEvent.click(screen.getByRole("checkbox", { name: "Live" }));
await advanceParseDebounce(); await advanceParseDebounce();
expect(syntaxHarness.patterns[0]?.request).toMatchObject({
flavour: "ecmascript",
flavourVersion: "2025",
flags: ["g", "u"],
options: {},
});
expect(
screen.getByRole("combobox", { name: "Flavour" }),
).not.toBeDisabled();
expect(screen.getByRole("combobox", { name: "Version" })).toBeDisabled();
await resolvePattern(0, [ await resolvePattern(0, [
{ {
number: 1, number: 1,
@@ -308,6 +318,9 @@ describe("Workbench syntax coordination", () => {
}); });
expect(engineHarness.requests).toHaveLength(1); expect(engineHarness.requests).toHaveLength(1);
expect(engineHarness.requests[0]).toMatchObject({ expect(engineHarness.requests[0]).toMatchObject({
flavour: "ecmascript",
flavourVersion: "ECMAScript 2025 syntax / current browser runtime",
options: {},
pattern: "(?<new>b)", pattern: "(?<new>b)",
captureMetadata: [{ number: 1, name: "new" }], captureMetadata: [{ number: 1, name: "new" }],
}); });
@@ -380,4 +393,47 @@ describe("Workbench syntax coordination", () => {
)?.cancelCalls, )?.cancelCalls,
).toBeGreaterThan(0); ).toBeGreaterThan(0);
}); });
it("opens the bounded subject minimizer with the current editor snapshot", () => {
vi.useFakeTimers();
render(<Workbench />);
fireEvent.click(screen.getByRole("button", { name: "Minimize" }));
expect(
screen.getByRole("heading", { name: "Minimize a reproducer" }),
).toBeVisible();
expect(screen.getByLabelText("Subject to minimize")).toHaveValue(
"2026-07-24 alice\nInvalid row\n2025-12-31 Bérénice",
);
expect(screen.getByLabelText("Minimization failure kind")).toHaveValue(
"unit-test-failure",
);
fireEvent.click(
screen.getByRole("button", { name: "Close subject minimizer" }),
);
expect(
screen.queryByRole("heading", { name: "Minimize a reproducer" }),
).not.toBeInTheDocument();
});
it("opens and closes the grammar-backed formatter workspace", () => {
vi.useFakeTimers();
render(<Workbench />);
fireEvent.click(screen.getByRole("button", { name: "Format" }));
expect(
screen.getByRole("heading", { name: "Format pattern" }),
).toBeVisible();
expect(
screen.getByText(/Wait for the ECMAScript syntax provider/),
).toBeVisible();
fireEvent.click(
screen.getByRole("button", { name: "Close pattern formatter" }),
);
expect(
screen.queryByRole("heading", { name: "Format pattern" }),
).not.toBeInTheDocument();
});
}); });

View File

@@ -9,6 +9,10 @@ import { EngineSupervisor } from "../regex/execution/EngineSupervisor";
import { SyntaxSupervisor } from "../regex/execution/SyntaxSupervisor"; import { SyntaxSupervisor } from "../regex/execution/SyntaxSupervisor";
import { WorkerRequestError } from "../regex/execution/WorkerSupervisor"; import { WorkerRequestError } from "../regex/execution/WorkerSupervisor";
import type { RegexDiagnostic } from "../regex/model/diagnostics"; import type { RegexDiagnostic } from "../regex/model/diagnostics";
import type {
RegexEngineOptions,
RegexFlavourId,
} from "../regex/model/flavour";
import type { import type {
ExtractionNode, ExtractionNode,
RegexExecutionRequest, RegexExecutionRequest,
@@ -67,6 +71,20 @@ import { UnitTestPanel, type RegexTestDraft } from "./UnitTestPanel";
import { QuickReference } from "./QuickReference"; import { QuickReference } from "./QuickReference";
import { CapabilityPanel } from "./CapabilityPanel"; import { CapabilityPanel } from "./CapabilityPanel";
import { ProjectPanel } from "./ProjectPanel"; import { ProjectPanel } from "./ProjectPanel";
import { ModalDialog } from "./ModalDialog";
import { CorpusPanel } from "./CorpusPanel";
import { Pcre2TracePanel } from "./Pcre2TracePanel";
import { AnalysisPanel } from "./AnalysisPanel";
import { ComparisonCodePanel } from "./ComparisonCodePanel";
import { MinimizePanel } from "./MinimizePanel";
import { GenerationPanel } from "./GenerationPanel";
import { PatternFormatterPanel } from "./PatternFormatterPanel";
import type { VerifiedGeneratedCase } from "../regex/generation/generation.types";
import {
AVAILABLE_REGEX_FLAVOURS,
defaultRegexOptions,
resolveRegexFlavourVersion,
} from "../regex/flavours/flavour-registry";
const INITIAL_PATTERN = const INITIAL_PATTERN =
"(?<date>\\d{4}-\\d{2}-\\d{2})\\s+(?<user>[\\p{Letter}._-]+)"; "(?<date>\\d{4}-\\d{2}-\\d{2})\\s+(?<user>[\\p{Letter}._-]+)";
@@ -77,16 +95,7 @@ const INITIAL_REPLACEMENT = "$<user> — $<date>";
const MAXIMUM_PATTERN_EDITOR_MARKS = 2_000; const MAXIMUM_PATTERN_EDITOR_MARKS = 2_000;
const MAXIMUM_SUBJECT_EDITOR_MARKS = 2_000; const MAXIMUM_SUBJECT_EDITOR_MARKS = 2_000;
const MAXIMUM_TEST_SUITE_WALL_TIME_MS = 60_000; const MAXIMUM_TEST_SUITE_WALL_TIME_MS = 60_000;
const FLAG_OPTIONS = [ const INITIAL_FLAVOUR = AVAILABLE_REGEX_FLAVOURS.require("ecmascript");
["g", "Global"],
["i", "Ignore case"],
["m", "Multiline"],
["s", "Dot all"],
["u", "Unicode"],
["v", "Unicode sets"],
["y", "Sticky"],
["d", "Expose indices"],
] as const;
type RunState = type RunState =
| { readonly status: "idle"; readonly message: string } | { readonly status: "idle"; readonly message: string }
@@ -314,16 +323,23 @@ function testCaseFromDraft(
id: string, id: string,
enabled: boolean, enabled: boolean,
draft: RegexTestDraft, draft: RegexTestDraft,
configuration: {
readonly flavour: RegexFlavourId;
readonly flavourVersion?: string;
readonly options: RegexEngineOptions;
},
): RegexTestCase { ): RegexTestCase {
return { return {
id, id,
name: draft.name, name: draft.name,
enabled, enabled,
flavour: "ecmascript", flavour: configuration.flavour,
flavourVersion: "ECMAScript 2025 syntax / current browser runtime", ...(configuration.flavourVersion === undefined
? {}
: { flavourVersion: configuration.flavourVersion }),
pattern: draft.pattern, pattern: draft.pattern,
flags: draft.flags, flags: draft.flags,
options: {}, options: configuration.options,
scanAll: draft.scanAll, scanAll: draft.scanAll,
subject: draft.subject, subject: draft.subject,
...(draft.replacement === undefined ...(draft.replacement === undefined
@@ -333,6 +349,7 @@ function testCaseFromDraft(
...(draft.resourceLimits === undefined ...(draft.resourceLimits === undefined
? {} ? {}
: { resourceLimits: draft.resourceLimits }), : { resourceLimits: draft.resourceLimits }),
...(draft.generation === undefined ? {} : { generation: draft.generation }),
}; };
} }
@@ -361,8 +378,17 @@ function failedTestResult(testId: string, message: string): RegexTestResult {
} }
export function Workbench() { export function Workbench() {
const [flavour, setFlavour] = useState<RegexFlavourId>(INITIAL_FLAVOUR.id);
const [flavourVersion, setFlavourVersion] = useState(
INITIAL_FLAVOUR.defaultVersion,
);
const [options, setOptions] = useState<RegexEngineOptions>(() =>
defaultRegexOptions(INITIAL_FLAVOUR),
);
const [pattern, setPattern] = useState(INITIAL_PATTERN); const [pattern, setPattern] = useState(INITIAL_PATTERN);
const [flags, setFlags] = useState<readonly string[]>(["g", "u"]); const [flags, setFlags] = useState<readonly string[]>(
INITIAL_FLAVOUR.defaultFlags,
);
const [subject, setSubject] = useState(INITIAL_SUBJECT); const [subject, setSubject] = useState(INITIAL_SUBJECT);
const [replacement, setReplacement] = useState(INITIAL_REPLACEMENT); const [replacement, setReplacement] = useState(INITIAL_REPLACEMENT);
const [listTemplate, setListTemplate] = useState("${user} — ${date}"); const [listTemplate, setListTemplate] = useState("${user} — ${date}");
@@ -406,8 +432,15 @@ export function Workbench() {
const [testsRunning, setTestsRunning] = useState(false); const [testsRunning, setTestsRunning] = useState(false);
const [showReference, setShowReference] = useState(false); const [showReference, setShowReference] = useState(false);
const [showCapabilities, setShowCapabilities] = useState(false); const [showCapabilities, setShowCapabilities] = useState(false);
const [showTrace, setShowTrace] = useState(false);
const [showAnalysis, setShowAnalysis] = useState(false);
const [showComparison, setShowComparison] = useState(false);
const [showMinimizer, setShowMinimizer] = useState(false);
const [showGeneration, setShowGeneration] = useState(false);
const [showFormatter, setShowFormatter] = useState(false);
const [showProject, setShowProject] = useState(false); const [showProject, setShowProject] = useState(false);
const [showSubjectWhitespace, setShowSubjectWhitespace] = useState(false); const [showSubjectWhitespace, setShowSubjectWhitespace] = useState(false);
const [corpusSession, setCorpusSession] = useState(0);
const [subjectCursor, setSubjectCursor] = useState({ line: 1, column: 1 }); const [subjectCursor, setSubjectCursor] = useState({ line: 1, column: 1 });
const [projectIdentity, setProjectIdentity] = useState(() => ({ const [projectIdentity, setProjectIdentity] = useState(() => ({
id: projectId(), id: projectId(),
@@ -423,6 +456,11 @@ export function Workbench() {
const executionRevision = useRef(0); const executionRevision = useRef(0);
const testRunRevision = useRef(0); const testRunRevision = useRef(0);
const liveExecutionTimer = useRef<number | undefined>(undefined); const liveExecutionTimer = useRef<number | undefined>(undefined);
const flavourDefinition = AVAILABLE_REGEX_FLAVOURS.require(flavour);
const activeTestConfiguration = useMemo(
() => ({ flavour, flavourVersion, options }),
[flavour, flavourVersion, options],
);
const patternSyntaxEngine = () => { const patternSyntaxEngine = () => {
patternSyntaxSupervisor.current ??= new SyntaxSupervisor( patternSyntaxSupervisor.current ??= new SyntaxSupervisor(
@@ -528,7 +566,22 @@ export function Workbench() {
[], [],
); );
const syntax = isPatternSyntaxForInput(syntaxSnapshot, pattern, flags) const syntaxRequest = useMemo(() => {
const definition = AVAILABLE_REGEX_FLAVOURS.require(flavour);
const version = resolveRegexFlavourVersion(
flavourVersion,
definition,
"Selected flavour version",
).definition;
return {
flavour,
flavourVersion: version.syntaxVersion,
pattern,
flags,
options,
};
}, [flags, flavour, flavourVersion, options, pattern]);
const syntax = isPatternSyntaxForInput(syntaxSnapshot, syntaxRequest)
? syntaxSnapshot.result ? syntaxSnapshot.result
: undefined; : undefined;
@@ -545,19 +598,12 @@ export function Workbench() {
return; return;
} }
void patternSyntaxEngine() void patternSyntaxEngine()
.parsePattern({ .parsePattern(syntaxRequest)
flavour: "ecmascript",
flavourVersion: "2025",
pattern,
flags,
options: {},
})
.then((result) => { .then((result) => {
if (syntaxRevision.current !== currentRevision) return; if (syntaxRevision.current !== currentRevision) return;
setSyntaxSnapshot({ setSyntaxSnapshot({
revision: currentRevision, revision: currentRevision,
pattern, request: syntaxRequest,
flags,
result, result,
}); });
setSelectedSyntax((selected) => setSelectedSyntax((selected) =>
@@ -598,14 +644,14 @@ export function Workbench() {
}); });
}, DEFAULT_REGEX_LIMITS.liveParseDebounceMs); }, DEFAULT_REGEX_LIMITS.liveParseDebounceMs);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [clearExecutionResults, flags, pattern]); }, [clearExecutionResults, pattern, syntaxRequest]);
useEffect(() => { useEffect(() => {
if (!syntax) return; if (!syntax) return;
const currentRevision = ++replacementSyntaxRevision.current; const currentRevision = ++replacementSyntaxRevision.current;
void replacementSyntaxEngine() void replacementSyntaxEngine()
.parseReplacement({ .parseReplacement({
flavour: "ecmascript", flavour,
replacement, replacement,
captureMetadata: syntax.captures, captureMetadata: syntax.captures,
}) })
@@ -622,22 +668,33 @@ export function Workbench() {
console.warn("Replacement parsing failed", error); console.warn("Replacement parsing failed", error);
} }
}); });
}, [replacement, syntax]); }, [flavour, replacement, syntax]);
const subjectBytes = useMemo(() => utf8ByteLength(subject), [subject]); const subjectBytes = useMemo(() => utf8ByteLength(subject), [subject]);
const makeRequest = useCallback( const makeRequest = useCallback(
(): RegexExecutionRequest => ({ (): RegexExecutionRequest => ({
flavour: "ecmascript", flavour,
flavourVersion,
pattern, pattern,
flags, flags,
options,
subject, subject,
captureMetadata: syntax?.captures ?? [], captureMetadata: syntax?.captures ?? [],
scanAll, scanAll,
maximumMatches: DEFAULT_REGEX_LIMITS.maximumMatches, maximumMatches: DEFAULT_REGEX_LIMITS.maximumMatches,
maximumCaptureRows: DEFAULT_REGEX_LIMITS.maximumCaptureRows, maximumCaptureRows: DEFAULT_REGEX_LIMITS.maximumCaptureRows,
}), }),
[flags, pattern, scanAll, subject, syntax?.captures], [
flags,
flavour,
flavourVersion,
options,
pattern,
scanAll,
subject,
syntax?.captures,
],
); );
const run = useCallback( const run = useCallback(
@@ -653,8 +710,7 @@ export function Workbench() {
!isPatternSyntaxCurrent( !isPatternSyntaxCurrent(
syntaxSnapshot, syntaxSnapshot,
syntaxRevision.current, syntaxRevision.current,
pattern, syntaxRequest,
flags,
) )
) { ) {
setRunState({ setRunState({
@@ -744,6 +800,7 @@ export function Workbench() {
"current-result-size-check", "current-result-size-check",
true, true,
candidate, candidate,
activeTestConfiguration,
), ),
]); ]);
return true; return true;
@@ -827,6 +884,7 @@ export function Workbench() {
} }
}, },
[ [
activeTestConfiguration,
clearExecutionResults, clearExecutionResults,
makeRequest, makeRequest,
mode, mode,
@@ -838,6 +896,7 @@ export function Workbench() {
syntax?.accepted, syntax?.accepted,
syntax?.captures.length, syntax?.captures.length,
syntaxSnapshot, syntaxSnapshot,
syntaxRequest,
flags, flags,
pattern, pattern,
tests, tests,
@@ -855,6 +914,7 @@ export function Workbench() {
!liveWithinLimits || !liveWithinLimits ||
!syntax?.accepted || !syntax?.accepted ||
mode === "tests" || mode === "tests" ||
mode === "corpus" ||
(mode === "replace" && replacementSyntax?.accepted !== true) (mode === "replace" && replacementSyntax?.accepted !== true)
) { ) {
return; return;
@@ -935,11 +995,11 @@ export function Workbench() {
name: "Regex workbench", name: "Regex workbench",
createdAt: projectIdentity.createdAt, createdAt: projectIdentity.createdAt,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
flavour: "ecmascript", flavour,
flavourVersion: "ECMAScript 2025 syntax / current browser runtime", flavourVersion,
pattern, pattern,
flags, flags,
options: {}, options,
replacement, replacement,
mode, mode,
testText: { included: true, value: subject }, testText: { included: true, value: subject },
@@ -1171,6 +1231,9 @@ export function Workbench() {
} }
const parsed = await testSyntaxEngine().parsePattern({ const parsed = await testSyntaxEngine().parsePattern({
flavour: test.flavour, flavour: test.flavour,
...(test.flavourVersion === undefined
? {}
: { flavourVersion: test.flavourVersion }),
pattern: test.pattern, pattern: test.pattern,
flags: test.flags, flags: test.flags,
options: test.options, options: test.options,
@@ -1250,14 +1313,55 @@ export function Workbench() {
`A suite is limited to ${MAXIMUM_REGEX_TESTS.toLocaleString()} tests.`, `A suite is limited to ${MAXIMUM_REGEX_TESTS.toLocaleString()} tests.`,
); );
} }
const next = [...tests, testCaseFromDraft(projectId(), true, draft)]; const next = [
...tests,
testCaseFromDraft(projectId(), true, draft, activeTestConfiguration),
];
assertRegexTestSuiteWithinLimit(next);
setTests(next);
};
const addGeneratedTests = (
generatedCases: readonly VerifiedGeneratedCase[],
) => {
if (generatedCases.length === 0) return;
if (tests.length + generatedCases.length > MAXIMUM_REGEX_TESTS) {
throw new Error(
`Adding ${generatedCases.length.toLocaleString()} generated cases would exceed the ${MAXIMUM_REGEX_TESTS.toLocaleString()} test limit.`,
);
}
const appended = generatedCases.map((candidate) =>
testCaseFromDraft(
projectId(),
true,
{
name: candidate.name.slice(0, MAXIMUM_REGEX_TEST_NAME_UTF16),
pattern,
flags,
scanAll,
subject: candidate.subject,
expectation: { kind: candidate.expectation },
generation: candidate.provenance,
},
activeTestConfiguration,
),
);
const next = [...tests, ...appended];
assertRegexTestSuiteWithinLimit(next); assertRegexTestSuiteWithinLimit(next);
setTests(next); setTests(next);
}; };
const updateTest = (id: string, draft: RegexTestDraft) => { const updateTest = (id: string, draft: RegexTestDraft) => {
const next = tests.map((test) => const next = tests.map((test) =>
test.id === id ? testCaseFromDraft(test.id, test.enabled, draft) : test, test.id === id
? testCaseFromDraft(test.id, test.enabled, draft, {
flavour: test.flavour,
...(test.flavourVersion === undefined
? {}
: { flavourVersion: test.flavourVersion }),
options: test.options,
})
: test,
); );
assertRegexTestSuiteWithinLimit(next); assertRegexTestSuiteWithinLimit(next);
setTests(next); setTests(next);
@@ -1308,10 +1412,20 @@ export function Workbench() {
}; };
const importProject = (project: RegexProjectV1) => { const importProject = (project: RegexProjectV1) => {
const importedFlavour = AVAILABLE_REGEX_FLAVOURS.require(project.flavour);
invalidatePatternSyntax(); invalidatePatternSyntax();
executionRevision.current += 1; executionRevision.current += 1;
engineSupervisor.current?.cancel(); engineSupervisor.current?.cancel();
cancelTestRun(); cancelTestRun();
setFlavour(importedFlavour.id);
setFlavourVersion(
resolveRegexFlavourVersion(
project.flavourVersion,
importedFlavour,
"Project flavour version",
).definition.value,
);
setOptions(project.options);
setPattern(project.pattern); setPattern(project.pattern);
setProjectIdentity({ id: project.id, createdAt: project.createdAt }); setProjectIdentity({ id: project.id, createdAt: project.createdAt });
setFlags(project.flags); setFlags(project.flags);
@@ -1322,6 +1436,7 @@ export function Workbench() {
setListTemplate(project.listTemplate?.source ?? "$0"); setListTemplate(project.listTemplate?.source ?? "$0");
setMode(project.mode); setMode(project.mode);
setTests(project.tests); setTests(project.tests);
setCorpusSession((current) => current + 1);
setCurrentResultDraft(undefined); setCurrentResultDraft(undefined);
setTestResults(new Map()); setTestResults(new Map());
setLive(false); setLive(false);
@@ -1336,51 +1451,142 @@ export function Workbench() {
}); });
}; };
const changeFlavour = (value: string) => {
const next = AVAILABLE_REGEX_FLAVOURS.parse(value, "Selected flavour");
if (next.id === flavour) return;
invalidateExecution();
invalidatePatternSyntax();
cancelTestRun();
setFlavour(next.id);
setFlavourVersion(next.defaultVersion);
setOptions(defaultRegexOptions(next));
setFlags(next.defaultFlags);
};
const changeFlavourVersion = (value: string) => {
if (
value === flavourVersion ||
!flavourDefinition.versions.some((version) => version.value === value)
) {
return;
}
invalidateExecution();
invalidatePatternSyntax();
setFlavourVersion(value);
};
const changeEngineOption = (name: string, value: number) => {
const definition = flavourDefinition.options.find(
(candidate) => candidate.name === name,
);
if (
!definition ||
definition.kind !== "integer" ||
!Number.isSafeInteger(value) ||
value < definition.minimum ||
value > definition.maximum ||
options[name] === value
) {
return;
}
invalidateExecution();
invalidatePatternSyntax();
setOptions((current) => ({ ...current, [name]: value }));
};
return ( return (
<main className="regex-application"> <main className="regex-application">
<section className="command-bar" aria-label="Regex execution controls"> <section className="command-bar" aria-label="Regex execution controls">
<label> <label>
<span>Flavour</span> <span>Flavour</span>
<select value="ecmascript" disabled> <select
<option value="ecmascript">ECMAScript</option> value={flavour}
disabled={AVAILABLE_REGEX_FLAVOURS.definitions.length < 2}
onChange={(event) => changeFlavour(event.target.value)}
>
{AVAILABLE_REGEX_FLAVOURS.definitions.map((definition) => (
<option key={definition.id} value={definition.id}>
{definition.label}
</option>
))}
</select> </select>
</label> </label>
<label> <label>
<span>Version</span> <span>Version</span>
<select value="current" disabled> <select
<option value="current">2025 syntax · current browser</option> value={flavourVersion}
disabled={flavourDefinition.versions.length < 2}
onChange={(event) => changeFlavourVersion(event.target.value)}
>
{flavourDefinition.versions.map((version) => (
<option key={version.value} value={version.value}>
{version.label}
</option>
))}
</select> </select>
</label> </label>
<fieldset className="flag-selector"> <fieldset className="flag-selector">
<legend>Flags</legend> <legend>Flags</legend>
{FLAG_OPTIONS.map(([flag, label]) => ( {flavourDefinition.flags.map((flag) => (
<label key={flag} title={label}> <label key={flag.value} title={flag.label}>
<input <input
type="checkbox" type="checkbox"
aria-label={`${label} (${flag})`} aria-label={`${flag.label} (${flag.value})`}
checked={flags.includes(flag)} checked={flags.includes(flag.value)}
onChange={(event) => { onChange={(event) => {
invalidateExecution(); invalidateExecution();
invalidatePatternSyntax(); invalidatePatternSyntax();
setFlags((current) => { setFlags((current) => {
const next = new Set(current); const next = new Set(current);
if (event.target.checked) { if (event.target.checked) {
next.add(flag); next.add(flag.value);
if (flag === "u") next.delete("v"); for (const group of flavourDefinition.mutuallyExclusiveFlags ??
if (flag === "v") next.delete("u"); []) {
} else { if (!group.includes(flag.value)) continue;
next.delete(flag); for (const incompatible of group) {
if (incompatible !== flag.value) {
next.delete(incompatible);
} }
return FLAG_OPTIONS.map(([value]) => value).filter( }
(value) => next.has(value), }
); } else {
next.delete(flag.value);
}
return flavourDefinition.flags
.map((definition) => definition.value)
.filter((value) => next.has(value));
}); });
}} }}
/> />
<span>{flag}</span> <span>{flag.value}</span>
</label> </label>
))} ))}
</fieldset> </fieldset>
{flavourDefinition.options.length > 0 ? (
<fieldset className="engine-option-selector">
<legend>Engine limits</legend>
{flavourDefinition.options.map((option) => (
<label key={option.name}>
<span>{option.label}</span>
{option.kind === "integer" ? (
<input
type="number"
value={Number(options[option.name])}
min={option.minimum}
max={option.maximum}
step={1}
onChange={(event) =>
changeEngineOption(
option.name,
Number(event.target.value),
)
}
/>
) : null}
</label>
))}
</fieldset>
) : null}
<label className="check-control live-control"> <label className="check-control live-control">
<input <input
type="checkbox" type="checkbox"
@@ -1421,6 +1627,7 @@ export function Workbench() {
runState.status === "running" || runState.status === "running" ||
testsRunning || testsRunning ||
mode === "tests" || mode === "tests" ||
mode === "corpus" ||
!syntax?.accepted || !syntax?.accepted ||
(mode === "replace" && replacementSyntax?.accepted !== true) (mode === "replace" && replacementSyntax?.accepted !== true)
} }
@@ -1451,8 +1658,9 @@ export function Workbench() {
) : null} ) : null}
{scanAll && !flags.some((flag) => flag === "g" || flag === "y") ? ( {scanAll && !flags.some((flag) => flag === "g" || flag === "y") ? (
<p className="scan-banner"> <p className="scan-banner">
Scan all is explicit: the worker adds an internal <code>g</code> only Scan all is explicit: the worker adds an internal global-iteration
for iteration. The patterns saved user flags remain unchanged. flag only for this request. The patterns saved user flags remain
unchanged.
</p> </p>
) : null} ) : null}
<div className="workspace-toolbar"> <div className="workspace-toolbar">
@@ -1462,6 +1670,7 @@ export function Workbench() {
["match", "Match"], ["match", "Match"],
["replace", "Replace"], ["replace", "Replace"],
["list", "List"], ["list", "List"],
["corpus", "Corpus / Apply"],
["tests", "Unit tests"], ["tests", "Unit tests"],
] as const ] as const
).map(([value, label]) => ( ).map(([value, label]) => (
@@ -1481,11 +1690,77 @@ export function Workbench() {
))} ))}
</nav> </nav>
<div className="workspace-actions"> <div className="workspace-actions">
<button
type="button"
className="secondary-button"
onClick={() => setShowGeneration(true)}
aria-expanded={showGeneration}
aria-controls="generation-dialog"
>
Generate cases
</button>
<button
type="button"
className="secondary-button"
onClick={() => setShowFormatter(true)}
aria-expanded={showFormatter}
aria-controls="formatter-dialog"
title={
flavour === "ecmascript"
? "Preview canonical literal/control escaping and validate it against exact snapshots"
: "Open the formatter to see current flavour availability"
}
>
Format
</button>
<button
type="button"
className="secondary-button"
onClick={() => setShowMinimizer(true)}
aria-expanded={showMinimizer}
aria-controls="minimize-dialog"
>
Minimize
</button>
<button
type="button"
className="secondary-button"
onClick={() => setShowComparison(true)}
aria-expanded={showComparison}
aria-controls="comparison-code-dialog"
>
Compare &amp; code
</button>
<button
type="button"
className="secondary-button"
onClick={() => setShowAnalysis(true)}
aria-expanded={showAnalysis}
aria-controls="analysis-dialog"
>
Analysis
</button>
<button
type="button"
className="secondary-button"
disabled={flavour !== "pcre2"}
title={
flavour === "pcre2"
? "Open the isolated PCRE2 automatic-callout viewer"
: "Select PCRE2 to enable its automatic-callout trace"
}
onClick={() => setShowTrace(true)}
aria-expanded={showTrace}
aria-controls="trace-dialog"
>
PCRE trace
</button>
<button <button
type="button" type="button"
className="secondary-button" className="secondary-button"
onClick={() => setShowReference((value) => !value)} onClick={() => setShowReference((value) => !value)}
aria-expanded={showReference} aria-expanded={showReference}
aria-controls="quick-reference-dialog"
> >
Quick reference Quick reference
</button> </button>
@@ -1494,6 +1769,7 @@ export function Workbench() {
className="secondary-button" className="secondary-button"
onClick={() => setShowCapabilities((value) => !value)} onClick={() => setShowCapabilities((value) => !value)}
aria-expanded={showCapabilities} aria-expanded={showCapabilities}
aria-controls="capabilities-dialog"
> >
Capabilities Capabilities
</button> </button>
@@ -1526,6 +1802,199 @@ export function Workbench() {
}} }}
/> />
</div> </div>
<ModalDialog
id="generation-dialog"
open={showGeneration}
labelledBy="generation-heading"
onClose={() => setShowGeneration(false)}
className="generation-dialog"
>
{showGeneration ? (
<GenerationPanel
active={showGeneration}
flavour={flavour}
flavourVersion={flavourVersion}
pattern={pattern}
flags={flags}
options={options}
scanAll={scanAll}
syntax={syntax}
onAddToTests={addGeneratedTests}
onSelectPatternRange={(range) => {
const node = syntax
? findSmallestNode(syntax.root, range)
: undefined;
if (node) {
selectSyntaxNode(node);
} else {
setRequestedPatternRange(range);
}
setShowGeneration(false);
}}
onClose={() => setShowGeneration(false)}
/>
) : null}
</ModalDialog>
<ModalDialog
id="formatter-dialog"
open={showFormatter}
labelledBy="formatter-heading"
onClose={() => setShowFormatter(false)}
className="formatter-dialog"
>
{showFormatter ? (
<PatternFormatterPanel
flavour={flavour}
flavourVersion={flavourVersion}
pattern={pattern}
flags={flags}
options={options}
syntax={syntax}
subject={subject}
replacement={replacement}
scanAll={scanAll}
timeoutMs={timeoutMs}
tests={tests}
onApply={(value) => {
changePattern(value);
setShowFormatter(false);
}}
onClose={() => setShowFormatter(false)}
/>
) : null}
</ModalDialog>
<ModalDialog
id="minimize-dialog"
open={showMinimizer}
labelledBy="minimize-heading"
onClose={() => setShowMinimizer(false)}
className="minimize-dialog"
>
{showMinimizer ? (
<MinimizePanel
activeFlavour={flavour}
activeFlavourVersion={flavourVersion}
activeFlags={flags}
activeOptions={options}
pattern={pattern}
subject={subject}
replacement={replacement}
scanAll={scanAll}
timeoutMs={timeoutMs}
onUseSubject={(value) => {
changeSubject(value);
setShowMinimizer(false);
}}
onClose={() => setShowMinimizer(false)}
/>
) : null}
</ModalDialog>
<ModalDialog
id="comparison-code-dialog"
open={showComparison}
labelledBy="comparison-code-heading"
onClose={() => setShowComparison(false)}
className="comparison-code-dialog"
>
{showComparison ? (
<ComparisonCodePanel
activeFlavour={flavour}
activeFlags={flags}
activeOptions={options}
pattern={pattern}
subject={subject}
replacement={replacement}
scanAll={scanAll}
timeoutMs={timeoutMs}
onClose={() => setShowComparison(false)}
/>
) : null}
</ModalDialog>
<ModalDialog
id="quick-reference-dialog"
open={showReference}
labelledBy="reference-heading"
onClose={() => setShowReference(false)}
className="reference-dialog"
>
<QuickReference
flavour={flavour}
onUsePattern={(example) => {
changePattern(example);
setShowReference(false);
}}
onClose={() => setShowReference(false)}
/>
</ModalDialog>
<ModalDialog
id="capabilities-dialog"
open={showCapabilities}
labelledBy="capability-heading"
onClose={() => setShowCapabilities(false)}
className="capabilities-dialog"
>
<CapabilityPanel
syntax={syntax}
execution={execution}
onClose={() => setShowCapabilities(false)}
/>
</ModalDialog>
<ModalDialog
id="trace-dialog"
open={showTrace}
labelledBy="trace-heading"
onClose={() => setShowTrace(false)}
className="trace-dialog"
>
<Pcre2TracePanel
active={showTrace}
flavour={flavour}
pattern={pattern}
flags={flags}
options={options}
subject={subject}
timeoutMs={timeoutMs}
onClose={() => setShowTrace(false)}
onSelectRanges={(patternRange, subjectRange) => {
setSelectedSyntax(undefined);
setSelectedExtraction(undefined);
setSelectedCaptureRow(undefined);
setRequestedPatternRange(patternRange);
setRequestedSubjectRange(subjectRange);
setShowTrace(false);
}}
/>
</ModalDialog>
<ModalDialog
id="analysis-dialog"
open={showAnalysis}
labelledBy="analysis-heading"
onClose={() => setShowAnalysis(false)}
className="analysis-dialog"
>
<AnalysisPanel
active={showAnalysis}
flavour={flavour}
pattern={pattern}
flags={flags}
subject={subject}
replacement={replacement}
scanAll={scanAll}
syntax={syntax}
onClose={() => setShowAnalysis(false)}
onSelectPatternRange={(range) => {
const node = syntax
? findSmallestNode(syntax.root, range)
: undefined;
if (node) {
selectSyntaxNode(node);
} else {
setRequestedPatternRange(range);
}
setShowAnalysis(false);
}}
/>
</ModalDialog>
<div <div
className={`run-status status-${runState.status}`} className={`run-status status-${runState.status}`}
role="status" role="status"
@@ -1596,7 +2065,7 @@ export function Workbench() {
onSelect={(node) => selectSyntaxNode(node)} onSelect={(node) => selectSyntaxNode(node)}
/> />
</div> </div>
{mode !== "tests" ? ( {mode !== "tests" && mode !== "corpus" ? (
<div className="result-workspace"> <div className="result-workspace">
<section className="panel editor-panel subject-panel"> <section className="panel editor-panel subject-panel">
<header className="panel-heading"> <header className="panel-heading">
@@ -1715,6 +2184,7 @@ export function Workbench() {
) : null} ) : null}
{mode === "replace" ? ( {mode === "replace" ? (
<ReplacementPanel <ReplacementPanel
flavour={flavour}
replacement={replacement} replacement={replacement}
onReplacementChange={changeReplacement} onReplacementChange={changeReplacement}
syntax={replacementSyntax} syntax={replacementSyntax}
@@ -1740,10 +2210,28 @@ export function Workbench() {
sourceResultTruncated={execution?.truncated ?? false} sourceResultTruncated={execution?.truncated ?? false}
/> />
) : null} ) : null}
<div hidden={mode !== "corpus"}>
<CorpusPanel
key={corpusSession}
active={mode === "corpus"}
flavour={flavour}
flavourVersion={flavourVersion}
options={options}
pattern={pattern}
flags={flags}
captureMetadata={syntax?.captures ?? []}
patternAccepted={syntax?.accepted === true}
replacement={replacement}
replacementAccepted={replacementSyntax?.accepted === true}
onReplacementChange={changeReplacement}
timeoutMs={timeoutMs}
/>
</div>
{mode === "tests" ? ( {mode === "tests" ? (
<UnitTestPanel <UnitTestPanel
tests={tests} tests={tests}
results={testResults} results={testResults}
currentFlavour={flavour}
currentPattern={pattern} currentPattern={pattern}
currentFlags={flags} currentFlags={flags}
currentScanAll={scanAll} currentScanAll={scanAll}
@@ -1800,10 +2288,6 @@ export function Workbench() {
} }
}} }}
/> />
{showReference ? <QuickReference onInsert={changePattern} /> : null}
{showCapabilities ? (
<CapabilityPanel syntax={syntax} execution={execution} />
) : null}
</main> </main>
); );
} }

View File

@@ -5,6 +5,11 @@ import {
} from "./project.serialization"; } from "./project.serialization";
import type { RegexProjectV1 } from "./project.types"; import type { RegexProjectV1 } from "./project.types";
import { DEFAULT_REGEX_LIMITS } from "../regex/execution/request-limits"; import { DEFAULT_REGEX_LIMITS } from "../regex/execution/request-limits";
import { parseRegexProject, parseRegexTests } from "./project.validation";
import {
RegexFlavourRegistry,
type RegexFlavourDefinition,
} from "../regex/flavours/flavour-registry";
const project: RegexProjectV1 = { const project: RegexProjectV1 = {
schemaVersion: 1, schemaVersion: 1,
@@ -58,6 +63,55 @@ describe("project import and export", () => {
expect(serialized).toContain('"subject": "42"'); expect(serialized).toContain('"subject": "42"');
}); });
it("retains validated generation provenance when private subjects are omitted", () => {
const generatedTest = {
...project.tests[0]!,
generation: {
kind: "generated" as const,
generatorId: "regex-tools-ast-cases" as const,
generatorVersion: "1" as const,
seed: "project-seed-17",
candidateId: "alternative:root:1",
intendedOutcome: "match" as const,
},
};
const imported = importProjectDocument(
serializeProject(
{ ...project, tests: [generatedTest] },
{
includeTestText: false,
includeUnitTestSubjects: false,
},
),
);
expect(imported.tests[0]?.subject).toBe("");
expect(imported.tests[0]?.generation).toEqual(generatedTest.generation);
});
it("persists corpus mode without accepting ephemeral corpus content", () => {
const imported = importProjectDocument(
serializeProject(
{ ...project, mode: "corpus" },
{
includeTestText: false,
includeUnitTestSubjects: false,
},
),
);
expect(imported.mode).toBe("corpus");
expect(() =>
importProjectDocument(
JSON.stringify({
...project,
mode: "corpus",
corpus: [{ name: "secret.txt", text: "secret" }],
}),
),
).toThrow(/ephemeral/iu);
});
it("preflights aggregate export size after applying privacy options", () => { it("preflights aggregate export size after applying privacy options", () => {
const escapedSubject = "\0".repeat(2 * 1024 * 1024); const escapedSubject = "\0".repeat(2 * 1024 * 1024);
const aggregateProject: RegexProjectV1 = { const aggregateProject: RegexProjectV1 = {
@@ -171,10 +225,39 @@ describe("project import and export", () => {
).toThrow(/Test 1 subject exceeds.*UTF-8 byte limit/u); ).toThrow(/Test 1 subject exceeds.*UTF-8 byte limit/u);
}); });
it("rejects unsupported flavour and option semantics instead of rewriting them", () => { it("accepts PCRE2 and rejects unsupported flavour or option semantics without rewriting", () => {
expect(
importProjectDocument(
JSON.stringify({
...project,
flavour: "pcre2",
flavourVersion: "PCRE2 10.47",
flags: ["g"],
options: {
matchLimit: 50_000,
depthLimit: 500,
heapLimitKib: 4_096,
},
tests: [],
}),
),
).toEqual(
expect.objectContaining({
flavour: "pcre2",
flavourVersion: "PCRE2 10.47",
flags: ["g"],
options: {
matchLimit: 50_000,
depthLimit: 500,
heapLimitKib: 4_096,
},
}),
);
expect(() => expect(() =>
importProjectDocument(JSON.stringify({ ...project, flavour: "pcre2" })), importProjectDocument(
).toThrow(/only supports ECMAScript/u); JSON.stringify({ ...project, flavourVersion: "ECMAScript 2026" }),
),
).toThrow(/flavour version.*unsupported/iu);
expect(() => expect(() =>
importProjectDocument( importProjectDocument(
JSON.stringify({ JSON.stringify({
@@ -182,7 +265,20 @@ describe("project import and export", () => {
tests: [{ ...project.tests[0], flavour: "python" }], tests: [{ ...project.tests[0], flavour: "python" }],
}), }),
), ),
).toThrow(/only supports ECMAScript/u); ).toThrow(/only supports ECMAScript and PCRE2/u);
expect(() =>
importProjectDocument(
JSON.stringify({
...project,
tests: [
{
...project.tests[0],
flavourVersion: "ECMAScript 2026",
},
],
}),
),
).toThrow(/flavour version.*unsupported/iu);
expect(() => expect(() =>
importProjectDocument( importProjectDocument(
JSON.stringify({ ...project, options: { ignoreCase: true } }), JSON.stringify({ ...project, options: { ignoreCase: true } }),
@@ -214,6 +310,84 @@ describe("project import and export", () => {
).toThrow(/test id.*duplicated/iu); ).toThrow(/test id.*duplicated/iu);
}); });
it("keeps schema v1 while delegating future flavour fields to a contract", () => {
const fixtureFlavour: RegexFlavourDefinition = {
id: "pcre2",
label: "Fixture flavour",
versions: [
{
value: "fixture-version",
label: "Fixture version",
syntaxVersion: "fixture-version",
},
],
defaultVersion: "fixture-version",
flags: [{ value: "i", label: "Caseless" }],
defaultFlags: [],
options: [
{
name: "ungreedy",
label: "Ungreedy",
kind: "boolean",
defaultValue: false,
},
],
};
const registry = new RegexFlavourRegistry([fixtureFlavour]);
const imported = parseRegexProject(
{
...project,
flavour: "pcre2",
flavourVersion: "fixture-version",
flags: ["i"],
options: { ungreedy: true },
tests: [],
},
registry,
);
const importedTests = parseRegexTests(
[
{
...project.tests[0],
flavour: "pcre2",
flavourVersion: "fixture-version",
flags: ["i"],
options: { ungreedy: true },
},
],
registry,
);
expect(imported).toEqual(
expect.objectContaining({
schemaVersion: 1,
flavour: "pcre2",
flavourVersion: "fixture-version",
flags: ["i"],
options: { ungreedy: true },
}),
);
expect(importedTests[0]).toEqual(
expect.objectContaining({
flavour: "pcre2",
flavourVersion: "fixture-version",
flags: ["i"],
options: { ungreedy: true },
}),
);
});
it("imports legacy schema-v1 projects without a flavour-version field", () => {
const imported = parseRegexProject(project);
expect(imported.schemaVersion).toBe(1);
expect(imported.flavour).toBe("ecmascript");
expect(imported.flavourVersion).toBe(
"ECMAScript 2025 syntax / current browser runtime",
);
expect(imported.tests[0]?.flavourVersion).toBeUndefined();
});
it("validates and preserves the supported per-test timeout limit", () => { it("validates and preserves the supported per-test timeout limit", () => {
const imported = importProjectDocument( const imported = importProjectDocument(
JSON.stringify({ JSON.stringify({

View File

@@ -1,9 +1,12 @@
import type { RegexFlavourId } from "../regex/model/flavour"; import type {
RegexEngineOptions,
RegexFlavourId,
} from "../regex/model/flavour";
import type { RegexResourceLimits } from "../regex/execution/request-limits"; import type { RegexResourceLimits } from "../regex/execution/request-limits";
import type { ListTemplate } from "../regex/list/list-template"; import type { ListTemplate } from "../regex/list/list-template";
import type { RegexTestCase } from "../regex/tests/test-case.types"; import type { RegexTestCase } from "../regex/tests/test-case.types";
export type WorkspaceMode = "match" | "replace" | "list" | "tests"; export type WorkspaceMode = "match" | "replace" | "list" | "corpus" | "tests";
export interface RegexProjectV1 { export interface RegexProjectV1 {
readonly schemaVersion: 1; readonly schemaVersion: 1;
@@ -15,7 +18,7 @@ export interface RegexProjectV1 {
readonly flavourVersion?: string; readonly flavourVersion?: string;
readonly pattern: string; readonly pattern: string;
readonly flags: readonly string[]; readonly flags: readonly string[];
readonly options: Readonly<Record<string, boolean | number | string>>; readonly options: RegexEngineOptions;
readonly replacement?: string; readonly replacement?: string;
readonly mode: WorkspaceMode; readonly mode: WorkspaceMode;
readonly testText?: { readonly testText?: {

View File

@@ -17,14 +17,22 @@ import {
utf8ByteLength, utf8ByteLength,
} from "../regex/execution/request-limits"; } from "../regex/execution/request-limits";
import { assertProjectDocumentWithinLimit } from "./project-limits"; import { assertProjectDocumentWithinLimit } from "./project-limits";
import {
AVAILABLE_REGEX_FLAVOURS,
parseRegexFlags,
parseRegexOptions,
resolveRegexFlavourVersion,
type RegexFlavourRegistry,
} from "../regex/flavours/flavour-registry";
import { parseGeneratedCaseProvenance } from "../regex/generation/generation-provenance";
const IMPLEMENTED_MODES = new Set<WorkspaceMode>([ const IMPLEMENTED_MODES = new Set<WorkspaceMode>([
"match", "match",
"replace", "replace",
"list", "list",
"corpus",
"tests", "tests",
]); ]);
const VALID_FLAGS = new Set("dgimsuvy".split(""));
const MAXIMUM_PATTERN_UTF16 = MAXIMUM_REGEX_TEST_PATTERN_UTF16; const MAXIMUM_PATTERN_UTF16 = MAXIMUM_REGEX_TEST_PATTERN_UTF16;
const MAXIMUM_SUBJECT_UTF16 = MAXIMUM_REGEX_TEST_SUBJECT_UTF16; const MAXIMUM_SUBJECT_UTF16 = MAXIMUM_REGEX_TEST_SUBJECT_UTF16;
@@ -58,44 +66,6 @@ function subjectValue(value: unknown, label: string): string {
return subject; return subject;
} }
function parseFlags(value: unknown): readonly string[] {
if (!Array.isArray(value)) throw new Error("Project flags must be an array.");
const flags = value.map((flag) => stringValue(flag, "Flag", 1));
if (
new Set(flags).size !== flags.length ||
flags.some((flag) => !VALID_FLAGS.has(flag))
) {
throw new Error("Project flags contain duplicates or unsupported values.");
}
if (flags.includes("u") && flags.includes("v")) {
throw new Error("ECMAScript flags u and v cannot be combined.");
}
return flags;
}
function parseFlavour(value: unknown, label: string): "ecmascript" {
const flavour = stringValue(value, label, 32);
if (flavour !== "ecmascript") {
throw new Error(
`${label} ${JSON.stringify(flavour)} is unsupported; version 0.1.0 only supports ECMAScript.`,
);
}
return flavour;
}
function parseOptions(
value: unknown,
label: string,
): Readonly<Record<string, never>> {
const options = value === undefined ? {} : objectValue(value, label);
if (Object.keys(options).length > 0) {
throw new Error(
`${label} contains unsupported entries; version 0.1.0 does not implement ECMAScript engine options.`,
);
}
return {};
}
function parseTestResourceLimits( function parseTestResourceLimits(
value: unknown, value: unknown,
label: string, label: string,
@@ -125,7 +95,10 @@ function parseTestResourceLimits(
return { manualExecutionTimeoutMs: timeout }; return { manualExecutionTimeoutMs: timeout };
} }
export function parseRegexTests(value: unknown): readonly RegexTestCase[] { export function parseRegexTests(
value: unknown,
registry: RegexFlavourRegistry = AVAILABLE_REGEX_FLAVOURS,
): readonly RegexTestCase[] {
if (!Array.isArray(value)) throw new Error("Project tests must be an array."); if (!Array.isArray(value)) throw new Error("Project tests must be an array.");
if (value.length > MAXIMUM_REGEX_TESTS) { if (value.length > MAXIMUM_REGEX_TESTS) {
throw new Error(`Project contains more than ${MAXIMUM_REGEX_TESTS} tests.`); throw new Error(`Project contains more than ${MAXIMUM_REGEX_TESTS} tests.`);
@@ -133,8 +106,22 @@ export function parseRegexTests(value: unknown): readonly RegexTestCase[] {
const testIds = new Set<string>(); const testIds = new Set<string>();
return value.map((candidate, index) => { return value.map((candidate, index) => {
const test = objectValue(candidate, `Test ${index + 1}`); const test = objectValue(candidate, `Test ${index + 1}`);
const flavour = parseFlavour(test.flavour, `Test ${index + 1} flavour`); const flavour = registry.parse(test.flavour, `Test ${index + 1} flavour`);
const options = parseOptions(test.options, `Test ${index + 1} options`); const flags = parseRegexFlags(
test.flags,
flavour,
`Test ${index + 1} flags`,
);
const options = parseRegexOptions(
test.options,
flavour,
`Test ${index + 1} options`,
);
const flavourVersion = resolveRegexFlavourVersion(
test.flavourVersion,
flavour,
`Test ${index + 1} flavour version`,
).value;
const resourceLimits = parseTestResourceLimits( const resourceLimits = parseTestResourceLimits(
test.resourceLimits, test.resourceLimits,
`Test ${index + 1} resource limits`, `Test ${index + 1} resource limits`,
@@ -261,6 +248,29 @@ export function parseRegexTests(value: unknown): readonly RegexTestCase[] {
`Imported test ${index + 1} uses unsupported expectation ${kind}.`, `Imported test ${index + 1} uses unsupported expectation ${kind}.`,
); );
} }
const generation =
test.generation === undefined
? undefined
: parseGeneratedCaseProvenance(
test.generation,
`Test ${index + 1} generation`,
);
if (generation) {
if (flavour.id !== "ecmascript") {
throw new Error(
`Test ${index + 1} generation provenance is supported only for ECMAScript generator v1.`,
);
}
const expectedKind =
generation.intendedOutcome === "match"
? "should-match"
: "should-not-match";
if (parsedExpectation.kind !== expectedKind) {
throw new Error(
`Test ${index + 1} generation intendedOutcome does not match its ${parsedExpectation.kind} expectation.`,
);
}
}
const id = stringValue(test.id, `Test ${index + 1} id`, 128); const id = stringValue(test.id, `Test ${index + 1} id`, 128);
if (id.length === 0) { if (id.length === 0) {
throw new Error(`Test ${index + 1} id must not be empty.`); throw new Error(`Test ${index + 1} id must not be empty.`);
@@ -277,22 +287,14 @@ export function parseRegexTests(value: unknown): readonly RegexTestCase[] {
MAXIMUM_REGEX_TEST_NAME_UTF16, MAXIMUM_REGEX_TEST_NAME_UTF16,
), ),
enabled: test.enabled !== false, enabled: test.enabled !== false,
flavour, flavour: flavour.id,
...(test.flavourVersion === undefined ...(test.flavourVersion === undefined ? {} : { flavourVersion }),
? {}
: {
flavourVersion: stringValue(
test.flavourVersion,
`Test ${index + 1} flavour version`,
128,
),
}),
pattern: stringValue( pattern: stringValue(
test.pattern, test.pattern,
`Test ${index + 1} pattern`, `Test ${index + 1} pattern`,
MAXIMUM_REGEX_TEST_PATTERN_UTF16, MAXIMUM_REGEX_TEST_PATTERN_UTF16,
), ),
flags: parseFlags(test.flags), flags,
options, options,
...(test.scanAll === undefined ...(test.scanAll === undefined
? {} ? {}
@@ -313,28 +315,44 @@ export function parseRegexTests(value: unknown): readonly RegexTestCase[] {
}), }),
...(resourceLimits === undefined ? {} : { resourceLimits }), ...(resourceLimits === undefined ? {} : { resourceLimits }),
expectation: parsedExpectation, expectation: parsedExpectation,
...(generation === undefined ? {} : { generation }),
}; };
}); });
} }
export function parseRegexProject(value: unknown): RegexProjectV1 { export function parseRegexProject(
value: unknown,
registry: RegexFlavourRegistry = AVAILABLE_REGEX_FLAVOURS,
): RegexProjectV1 {
const input = objectValue(value); const input = objectValue(value);
if (input.schemaVersion !== 1) { if (input.schemaVersion !== 1) {
throw new Error("Only regex-tools project schema version 1 is supported."); throw new Error("Only regex-tools project schema version 1 is supported.");
} }
const flavour = parseFlavour(input.flavour, "Project flavour"); const flavour = registry.parse(input.flavour, "Project flavour");
const options = parseOptions(input.options, "Project options"); const flavourVersion = resolveRegexFlavourVersion(
input.flavourVersion,
flavour,
"Project flavour version",
).value;
const flags = parseRegexFlags(input.flags, flavour, "Project flags");
const options = parseRegexOptions(input.options, flavour, "Project options");
if (input.resourceOverrides !== undefined) { if (input.resourceOverrides !== undefined) {
const overrides = objectValue( const overrides = objectValue(
input.resourceOverrides, input.resourceOverrides,
"Project resource overrides", "Project resource overrides",
); );
if (Object.keys(overrides).length > 0) { if (Object.keys(overrides).length > 0) {
throw new Error( throw new Error("Project resource overrides are not implemented.");
"Project resource overrides are not implemented in version 0.1.0.",
);
} }
} }
const corpusFields = Object.keys(input).filter((key) =>
key.toLocaleLowerCase("en-US").startsWith("corpus"),
);
if (corpusFields.length > 0) {
throw new Error(
`Corpus documents and results are ephemeral and cannot be imported from a schema-v1 project (${corpusFields.join(", ")}).`,
);
}
const mode = stringValue(input.mode, "Project mode", 16) as WorkspaceMode; const mode = stringValue(input.mode, "Project mode", 16) as WorkspaceMode;
if (!IMPLEMENTED_MODES.has(mode)) { if (!IMPLEMENTED_MODES.has(mode)) {
throw new Error(`Project mode ${mode} is not implemented in this release.`); throw new Error(`Project mode ${mode} is not implemented in this release.`);
@@ -358,10 +376,10 @@ export function parseRegexProject(value: unknown): RegexProjectV1 {
typeof input.createdAt === "string" ? input.createdAt.slice(0, 64) : now, typeof input.createdAt === "string" ? input.createdAt.slice(0, 64) : now,
updatedAt: updatedAt:
typeof input.updatedAt === "string" ? input.updatedAt.slice(0, 64) : now, typeof input.updatedAt === "string" ? input.updatedAt.slice(0, 64) : now,
flavour, flavour: flavour.id,
flavourVersion: "ECMAScript 2025 syntax / current browser runtime", flavourVersion,
pattern: stringValue(input.pattern, "Pattern", MAXIMUM_PATTERN_UTF16), pattern: stringValue(input.pattern, "Pattern", MAXIMUM_PATTERN_UTF16),
flags: parseFlags(input.flags), flags,
options, options,
replacement: replacement:
input.replacement === undefined input.replacement === undefined
@@ -381,7 +399,7 @@ export function parseRegexProject(value: unknown): RegexProjectV1 {
: {}), : {}),
}, },
listTemplate: parseListTemplate(listSource), listTemplate: parseListTemplate(listSource),
tests: parseRegexTests(input.tests ?? []), tests: parseRegexTests(input.tests ?? [], registry),
ui: { ui: {
live: live:
typeof input.ui === "object" && typeof input.ui === "object" &&

View File

@@ -0,0 +1,161 @@
import { describe, expect, it } from "vitest";
import type {
WorkerLike,
WorkerRequestError,
} from "../execution/WorkerSupervisor";
import {
WORKER_PROTOCOL_VERSION,
type WorkerRequest,
type WorkerResponse,
} from "../execution/worker-protocol";
import { AnalysisSupervisor } from "./AnalysisSupervisor";
import type {
AnalysisWorkerOperation,
AnalysisWorkerResult,
} from "./analysis.types";
class ResponsiveWorker implements WorkerLike {
onmessage: ((event: MessageEvent<unknown>) => void) | null = null;
onerror: ((event: ErrorEvent) => void) | null = null;
onmessageerror: ((event: MessageEvent<unknown>) => void) | null = null;
terminated = false;
postMessage(value: unknown): void {
const request = value as WorkerRequest<AnalysisWorkerOperation>;
let payload: AnalysisWorkerResult;
if (request.payload.kind === "identity") {
payload = {
kind: "identity",
result: {
flavour: "ecmascript",
engineName: "Native ECMAScript RegExp",
engineVersion: "Fixture runtime",
runtimeVersion: "Fixture runtime",
nativeOffsetUnit: "utf16",
},
};
} else if (request.payload.kind === "benchmark-sample") {
payload = {
kind: "benchmark-sample",
result: {
accepted: true,
effectiveFlags: "dg",
subjectBytes: 1,
subjectUtf16: 1,
compileMs: 1,
firstMatchMs: 1,
allMatchesMs: 1,
replacementMs: 1,
throughputBytesPerSecond: 1_000,
matchCount: 1,
matched: true,
matchCollectionTruncated: false,
replacementOutputUtf16: 1,
},
};
} else {
payload = {
kind: "growth-probe",
result: {
accepted: true,
effectiveFlags: "d",
subjectBytes: 1,
subjectUtf16: 1,
executionMs: 1,
matchCount: 0,
matched: false,
matchCollectionTruncated: false,
},
};
}
const response: WorkerResponse<AnalysisWorkerResult> = {
protocolVersion: WORKER_PROTOCOL_VERSION,
requestId: request.requestId,
generation: request.generation,
ok: true,
payload,
};
globalThis.queueMicrotask(() => {
if (!this.terminated) {
this.onmessage?.(new MessageEvent("message", { data: response }));
}
});
}
terminate(): void {
this.terminated = true;
}
}
class SilentWorker implements WorkerLike {
onmessage: ((event: MessageEvent<unknown>) => void) | null = null;
onerror: ((event: ErrorEvent) => void) | null = null;
onmessageerror: ((event: MessageEvent<unknown>) => void) | null = null;
terminated = false;
postMessage(): void {}
terminate(): void {
this.terminated = true;
}
}
describe("AnalysisSupervisor", () => {
it("routes typed identity, benchmark and growth responses", async () => {
const created: ResponsiveWorker[] = [];
const supervisor = new AnalysisSupervisor(() => {
const next = new ResponsiveWorker();
created.push(next);
return next;
});
await expect(supervisor.identity(100)).resolves.toEqual(
expect.objectContaining({ engineVersion: "Fixture runtime" }),
);
await expect(
supervisor.benchmarkSample(
{
flavour: "ecmascript",
pattern: "a",
flags: [],
subject: "a",
replacement: "x",
scanAll: false,
maximumMatches: 10,
},
100,
),
).resolves.toEqual(expect.objectContaining({ compileMs: 1 }));
await expect(
supervisor.growthProbe(
{
flavour: "ecmascript",
pattern: "a",
flags: [],
subject: "b",
scanAll: false,
maximumMatches: 10,
},
100,
),
).resolves.toEqual(expect.objectContaining({ matched: false }));
expect(created).toHaveLength(1);
supervisor.dispose();
expect(created[0]?.terminated).toBe(true);
});
it("terminates and rejects an active request on cancellation", async () => {
const worker = new SilentWorker();
const supervisor = new AnalysisSupervisor(() => worker);
const pending = supervisor.identity(1_000);
supervisor.cancel();
await expect(pending).rejects.toMatchObject({
kind: "cancelled",
} satisfies Partial<WorkerRequestError>);
expect(worker.terminated).toBe(true);
supervisor.dispose();
});
});

View File

@@ -0,0 +1,94 @@
import {
WorkerSupervisor,
type WorkerFactory,
} from "../execution/WorkerSupervisor";
import type {
AnalysisEngineIdentity,
AnalysisWorkerOperation,
AnalysisWorkerResult,
BenchmarkSubject,
BenchmarkWorkerSample,
GrowthProbeSubject,
GrowthWorkerSample,
} from "./analysis.types";
export interface AnalysisWorkerClient {
identity(timeoutMs: number): Promise<AnalysisEngineIdentity>;
benchmarkSample(
request: BenchmarkSubject,
timeoutMs: number,
): Promise<BenchmarkWorkerSample>;
growthProbe(
request: GrowthProbeSubject,
timeoutMs: number,
): Promise<GrowthWorkerSample>;
cancel(): void;
dispose(): void;
}
const createAnalysisWorker: WorkerFactory = () =>
new Worker(new URL("../../workers/analysis.worker.ts", import.meta.url), {
type: "module",
name: "regex-tools-ecmascript-analysis",
});
export class AnalysisSupervisor implements AnalysisWorkerClient {
readonly #supervisor: WorkerSupervisor<
AnalysisWorkerOperation,
AnalysisWorkerResult
>;
constructor(workerFactory: WorkerFactory = createAnalysisWorker) {
this.#supervisor = new WorkerSupervisor(
"ECMAScript analysis worker",
workerFactory,
);
}
async identity(timeoutMs: number): Promise<AnalysisEngineIdentity> {
const response = await this.#supervisor.run(
{ kind: "identity" },
timeoutMs,
);
if (response.kind !== "identity") {
throw new Error("Analysis worker returned the wrong identity response.");
}
return response.result;
}
async benchmarkSample(
request: BenchmarkSubject,
timeoutMs: number,
): Promise<BenchmarkWorkerSample> {
const response = await this.#supervisor.run(
{ kind: "benchmark-sample", request },
timeoutMs,
);
if (response.kind !== "benchmark-sample") {
throw new Error("Analysis worker returned the wrong benchmark response.");
}
return response.result;
}
async growthProbe(
request: GrowthProbeSubject,
timeoutMs: number,
): Promise<GrowthWorkerSample> {
const response = await this.#supervisor.run(
{ kind: "growth-probe", request },
timeoutMs,
);
if (response.kind !== "growth-probe") {
throw new Error("Analysis worker returned the wrong growth response.");
}
return response.result;
}
cancel(): void {
this.#supervisor.cancel();
}
dispose(): void {
this.#supervisor.dispose();
}
}

View File

@@ -0,0 +1,36 @@
import { DEFAULT_REGEX_LIMITS } from "../execution/request-limits";
import type { BenchmarkSettings, GrowthSettings } from "./analysis.types";
export const ANALYSIS_LIMITS = {
maximumWarmupIterations: 100,
maximumMeasuredIterations: 1_000,
maximumGrowthSteps: 24,
maximumGrowthFragmentUtf16: 4_096,
maximumGrowthAffixUtf16: 16_384,
maximumGrowthRepetitions: 10_000_000,
maximumReplacementBenchmarkOutputUtf16: 8 * 1024 * 1024,
minimumSampleTimeoutMs: 25,
maximumSampleTimeoutMs: DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs,
maximumAnalysisWallTimeMs: DEFAULT_REGEX_LIMITS.maximumBenchmarkWallTimeMs,
} as const;
export const DEFAULT_BENCHMARK_SETTINGS: BenchmarkSettings = {
warmupIterations: 3,
measuredIterations: 15,
sampleTimeoutMs: 2_000,
maximumWallTimeMs: DEFAULT_REGEX_LIMITS.maximumBenchmarkWallTimeMs,
};
export const DEFAULT_GROWTH_SETTINGS: GrowthSettings = {
prefix: "",
repeatedFragment: "a",
suffix: "!",
startingRepetitions: 16,
maximumRepetitions: 16_384,
multiplier: 2,
maximumSteps: 11,
sampleTimeoutMs: 1_000,
maximumWallTimeMs: DEFAULT_REGEX_LIMITS.maximumBenchmarkWallTimeMs,
maximumSubjectBytes: 1024 * 1024,
normalizedGrowthThreshold: 4,
};

View File

@@ -0,0 +1,336 @@
import { describe, expect, it, vi } from "vitest";
import { WorkerRequestError } from "../execution/WorkerSupervisor";
import {
runGrowthAnalysis,
runRegexBenchmark,
validateBenchmarkRequest,
validateGrowthRequest,
} from "./analysis-runner";
import type { AnalysisWorkerClient } from "./AnalysisSupervisor";
import type {
BenchmarkWorkerSample,
GrowthWorkerSample,
RegexBenchmarkRequest,
GrowthAnalysisRequest,
} from "./analysis.types";
function benchmarkSample(
milliseconds: number,
overrides: Partial<BenchmarkWorkerSample> = {},
): BenchmarkWorkerSample {
return {
accepted: true,
effectiveFlags: "dg",
subjectBytes: 3,
subjectUtf16: 3,
compileMs: milliseconds,
firstMatchMs: milliseconds + 1,
allMatchesMs: milliseconds + 2,
replacementMs: milliseconds + 3,
throughputBytesPerSecond: milliseconds * 100,
matchCount: 1,
matched: true,
matchCollectionTruncated: false,
replacementOutputUtf16: 3,
...overrides,
};
}
function growthSample(
executionMs: number,
overrides: Partial<GrowthWorkerSample> = {},
): GrowthWorkerSample {
return {
accepted: true,
effectiveFlags: "d",
subjectBytes: 1,
subjectUtf16: 1,
executionMs,
matchCount: 0,
matched: false,
matchCollectionTruncated: false,
...overrides,
};
}
function client(
overrides: Partial<AnalysisWorkerClient> = {},
): AnalysisWorkerClient {
return {
identity: vi.fn().mockResolvedValue({
flavour: "ecmascript",
engineName: "Native ECMAScript RegExp",
engineVersion: "Test Browser 1",
runtimeVersion: "Test Browser 1",
nativeOffsetUnit: "utf16",
}),
benchmarkSample: vi.fn().mockResolvedValue(benchmarkSample(1)),
growthProbe: vi.fn().mockResolvedValue(growthSample(1)),
cancel: vi.fn(),
dispose: vi.fn(),
...overrides,
};
}
function benchmarkRequest(
overrides: Partial<RegexBenchmarkRequest> = {},
): RegexBenchmarkRequest {
return {
flavour: "ecmascript",
pattern: "a+",
flags: ["g"],
subject: "aaa",
replacement: "x",
scanAll: true,
maximumMatches: 10_000,
settings: {
warmupIterations: 1,
measuredIterations: 3,
sampleTimeoutMs: 100,
maximumWallTimeMs: 1_000,
},
...overrides,
};
}
function growthRequest(
overrides: Partial<GrowthAnalysisRequest> = {},
): GrowthAnalysisRequest {
return {
flavour: "ecmascript",
pattern: "(a+)+$",
flags: [],
scanAll: false,
maximumMatches: 10_000,
settings: {
prefix: "",
repeatedFragment: "a",
suffix: "!",
startingRepetitions: 16,
maximumRepetitions: 64,
multiplier: 2,
maximumSteps: 3,
sampleTimeoutMs: 100,
maximumWallTimeMs: 1_000,
maximumSubjectBytes: 1_024,
normalizedGrowthThreshold: 4,
},
...overrides,
};
}
describe("bounded analysis runner", () => {
it("keeps cold use, warm-up and measured statistics separate", async () => {
const samples = [
benchmarkSample(9),
benchmarkSample(99),
benchmarkSample(1),
benchmarkSample(2),
benchmarkSample(10),
];
const worker = client({
benchmarkSample: vi
.fn()
.mockImplementation(() => Promise.resolve(samples.shift()!)),
});
const phases: string[] = [];
const result = await runRegexBenchmark(benchmarkRequest(), worker, {
onProgress: (value) => phases.push(value.phase),
});
expect(result.status).toBe("complete");
expect(result.coldSample?.compileMs).toBe(9);
expect(result.completedWarmups).toBe(1);
expect(result.completedMeasuredIterations).toBe(3);
expect(result.warmStatistics.compileMs).toEqual({
count: 3,
minimum: 1,
median: 2,
p95: 10,
maximum: 10,
});
expect(result.identity?.engineVersion).toBe("Test Browser 1");
expect(phases).toEqual([
"starting-worker",
"cold-sample",
"warming-up",
"measuring",
"measuring",
"measuring",
]);
});
it("returns partial measured samples and a distinct timeout state", async () => {
let calls = 0;
const worker = client({
benchmarkSample: vi.fn().mockImplementation(() => {
calls += 1;
if (calls === 3) {
return Promise.reject(
new WorkerRequestError("timeout", "sample timed out"),
);
}
return Promise.resolve(benchmarkSample(calls));
}),
});
const result = await runRegexBenchmark(
benchmarkRequest({
settings: {
warmupIterations: 0,
measuredIterations: 3,
sampleTimeoutMs: 100,
maximumWallTimeMs: 1_000,
},
}),
worker,
);
expect(result.status).toBe("timeout");
expect(result.completedMeasuredIterations).toBe(1);
expect(result.stoppedReason).toContain("worker was terminated");
});
it("does not mislabel an aggregate wall limit as a regex timeout", async () => {
let time = 0;
const clock = { now: () => time };
const worker = client({
identity: vi.fn().mockImplementation(async () => {
time = 950;
return {
flavour: "ecmascript",
engineName: "Native ECMAScript RegExp",
engineVersion: "Test Browser 1",
runtimeVersion: "Test Browser 1",
nativeOffsetUnit: "utf16",
} as const;
}),
benchmarkSample: vi
.fn()
.mockRejectedValue(new WorkerRequestError("timeout", "wall stop")),
});
const result = await runRegexBenchmark(
benchmarkRequest({
settings: {
warmupIterations: 0,
measuredIterations: 1,
sampleTimeoutMs: 100,
maximumWallTimeMs: 1_000,
},
}),
worker,
{},
clock,
);
expect(result.status).toBe("wall-time-limit");
expect(result.stoppedReason).toContain("aggregate wall-time");
});
it("rejects out-of-bound benchmark and growth settings before worker use", () => {
expect(() =>
validateBenchmarkRequest(
benchmarkRequest({
settings: {
warmupIterations: 101,
measuredIterations: 1,
sampleTimeoutMs: 100,
maximumWallTimeMs: 1_000,
},
}),
),
).toThrow(/Warm-up iterations/u);
expect(() =>
validateGrowthRequest(
growthRequest({
settings: {
...growthRequest().settings,
repeatedFragment: "",
},
}),
),
).toThrow(/must not be empty/u);
});
it("stops on observed normalized growth and records dynamic evidence", async () => {
const probes = [growthSample(0.5), growthSample(10)];
const worker = client({
growthProbe: vi
.fn()
.mockImplementation(() => Promise.resolve(probes.shift()!)),
});
const result = await runGrowthAnalysis(growthRequest(), worker);
expect(result.status).toBe("complete");
expect(result.stopReason).toBe("growth-threshold");
expect(result.samples).toHaveLength(2);
expect(result.samples[1]?.normalizedGrowth).toBeGreaterThan(4);
expect(result.dynamicFindings[0]).toEqual(
expect.objectContaining({
rule: "observed-growth",
evidence: "dynamically-observed",
confidence: "medium",
}),
);
});
it("distinguishes an observed timeout from a crash", async () => {
const worker = client({
growthProbe: vi
.fn()
.mockRejectedValue(
new WorkerRequestError("timeout", "probe timed out"),
),
});
const result = await runGrowthAnalysis(growthRequest(), worker);
expect(result.status).toBe("timeout");
expect(result.stopReason).toBe("timeout");
expect(result.samples[0]?.status).toBe("timeout");
expect(result.dynamicFindings[0]?.rule).toBe("observed-timeout");
});
it("reports a worker crash without relabelling it as a timeout", async () => {
const worker = client({
growthProbe: vi
.fn()
.mockRejectedValue(new WorkerRequestError("crash", "fixture crash")),
});
const result = await runGrowthAnalysis(growthRequest(), worker);
expect(result.status).toBe("error");
expect(result.stopReason).toBe("crash");
expect(result.samples[0]?.status).toBe("crash");
expect(result.dynamicFindings).toEqual([]);
expect(result.stoppedReason).toContain("fixture crash");
});
it("preflights generated bytes and honours cancellation without allocating a subject", async () => {
const worker = client();
const tooLarge = await runGrowthAnalysis(
growthRequest({
settings: {
...growthRequest().settings,
prefix: "prefix",
maximumSubjectBytes: 8,
},
}),
worker,
);
expect(tooLarge.stopReason).toBe("maximum-subject-size");
expect(worker.growthProbe).not.toHaveBeenCalled();
const controller = new AbortController();
controller.abort();
const cancelled = await runGrowthAnalysis(growthRequest(), client(), {
signal: controller.signal,
});
expect(cancelled.status).toBe("cancelled");
expect(cancelled.stopReason).toBe("cancelled");
});
});

View File

@@ -0,0 +1,819 @@
import { WorkerRequestError } from "../execution/WorkerSupervisor";
import {
DEFAULT_REGEX_LIMITS,
utf8ByteLength,
} from "../execution/request-limits";
import { ANALYSIS_LIMITS } from "./analysis-limits";
import type { AnalysisWorkerClient } from "./AnalysisSupervisor";
import type {
AnalysisProgress,
AnalysisRunOptions,
AnalysisRunStatus,
BenchmarkWorkerSample,
GrowthAnalysisRequest,
GrowthAnalysisResult,
GrowthSample,
GrowthStopReason,
RegexBenchmarkRequest,
RegexBenchmarkResult,
RegexRiskFinding,
} from "./analysis.types";
import { summarizeBenchmarkSamples } from "./statistics";
interface Clock {
now(): number;
}
const SYSTEM_CLOCK: Clock = {
now: () => performance.now(),
};
function assertInteger(
value: number,
label: string,
minimum: number,
maximum: number,
): void {
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
throw new RangeError(
`${label} must be an integer from ${minimum.toLocaleString()} to ${maximum.toLocaleString()}.`,
);
}
}
function assertFiniteRange(
value: number,
label: string,
minimum: number,
maximum: number,
): void {
if (!Number.isFinite(value) || value < minimum || value > maximum) {
throw new RangeError(
`${label} must be from ${minimum.toLocaleString()} to ${maximum.toLocaleString()}.`,
);
}
}
export function validateBenchmarkRequest(request: RegexBenchmarkRequest): void {
if (request.flavour !== "ecmascript") {
throw new RangeError("Benchmarking currently supports ECMAScript only.");
}
if (request.pattern.length > DEFAULT_REGEX_LIMITS.patternHardLengthUtf16) {
throw new RangeError("Pattern exceeds the configured hard limit.");
}
if (
utf8ByteLength(request.subject) >
DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes
) {
throw new RangeError(
"Benchmark subject exceeds the configured hard limit.",
);
}
if (
request.replacement.length >
DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16
) {
throw new RangeError(
"Benchmark replacement exceeds the configured hard limit.",
);
}
assertInteger(
request.maximumMatches,
"Maximum matches",
1,
DEFAULT_REGEX_LIMITS.maximumMatches,
);
assertInteger(
request.settings.warmupIterations,
"Warm-up iterations",
0,
ANALYSIS_LIMITS.maximumWarmupIterations,
);
assertInteger(
request.settings.measuredIterations,
"Measured iterations",
1,
ANALYSIS_LIMITS.maximumMeasuredIterations,
);
assertInteger(
request.settings.sampleTimeoutMs,
"Sample timeout",
ANALYSIS_LIMITS.minimumSampleTimeoutMs,
ANALYSIS_LIMITS.maximumSampleTimeoutMs,
);
assertInteger(
request.settings.maximumWallTimeMs,
"Benchmark wall time",
request.settings.sampleTimeoutMs,
ANALYSIS_LIMITS.maximumAnalysisWallTimeMs,
);
if (
request.settings.warmupIterations +
request.settings.measuredIterations +
1 >
DEFAULT_REGEX_LIMITS.maximumBenchmarkIterations
) {
throw new RangeError(
`Benchmark exceeds the ${DEFAULT_REGEX_LIMITS.maximumBenchmarkIterations.toLocaleString()}-iteration limit.`,
);
}
}
export function validateGrowthRequest(request: GrowthAnalysisRequest): void {
if (request.flavour !== "ecmascript") {
throw new RangeError(
"Dynamic growth analysis currently supports ECMAScript only.",
);
}
if (request.pattern.length > DEFAULT_REGEX_LIMITS.patternHardLengthUtf16) {
throw new RangeError("Pattern exceeds the configured hard limit.");
}
assertInteger(
request.maximumMatches,
"Maximum matches",
1,
DEFAULT_REGEX_LIMITS.maximumMatches,
);
if (request.settings.repeatedFragment.length === 0) {
throw new RangeError("Repeated fragment must not be empty.");
}
if (
request.settings.repeatedFragment.length >
ANALYSIS_LIMITS.maximumGrowthFragmentUtf16
) {
throw new RangeError(
`Repeated fragment exceeds ${ANALYSIS_LIMITS.maximumGrowthFragmentUtf16.toLocaleString()} UTF-16 units.`,
);
}
for (const [label, value] of [
["Growth prefix", request.settings.prefix],
["Growth suffix", request.settings.suffix],
] as const) {
if (value.length > ANALYSIS_LIMITS.maximumGrowthAffixUtf16) {
throw new RangeError(
`${label} exceeds ${ANALYSIS_LIMITS.maximumGrowthAffixUtf16.toLocaleString()} UTF-16 units.`,
);
}
}
assertInteger(
request.settings.startingRepetitions,
"Starting repetitions",
1,
ANALYSIS_LIMITS.maximumGrowthRepetitions,
);
assertInteger(
request.settings.maximumRepetitions,
"Maximum repetitions",
request.settings.startingRepetitions,
ANALYSIS_LIMITS.maximumGrowthRepetitions,
);
assertFiniteRange(request.settings.multiplier, "Growth multiplier", 1.1, 10);
assertInteger(
request.settings.maximumSteps,
"Maximum growth steps",
1,
ANALYSIS_LIMITS.maximumGrowthSteps,
);
assertInteger(
request.settings.sampleTimeoutMs,
"Sample timeout",
ANALYSIS_LIMITS.minimumSampleTimeoutMs,
ANALYSIS_LIMITS.maximumSampleTimeoutMs,
);
assertInteger(
request.settings.maximumWallTimeMs,
"Growth wall time",
request.settings.sampleTimeoutMs,
ANALYSIS_LIMITS.maximumAnalysisWallTimeMs,
);
assertInteger(
request.settings.maximumSubjectBytes,
"Maximum generated subject bytes",
1,
DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes,
);
assertFiniteRange(
request.settings.normalizedGrowthThreshold,
"Normalized growth threshold",
1.1,
100,
);
}
function errorKind(
error: unknown,
): "timeout" | "cancelled" | "crash" | "error" {
if (error instanceof WorkerRequestError) {
if (error.kind === "timeout") return "timeout";
if (error.kind === "cancelled") return "cancelled";
if (error.kind === "crash") return "crash";
return "error";
}
if (
error &&
typeof error === "object" &&
"kind" in error &&
typeof error.kind === "string"
) {
if (
error.kind === "timeout" ||
error.kind === "cancelled" ||
error.kind === "crash"
) {
return error.kind;
}
}
return "error";
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function aborted(signal: AbortSignal | undefined): boolean {
return signal?.aborted === true;
}
function timeoutWithinDeadline(
configuredTimeoutMs: number,
deadline: number,
clock: Clock,
): number {
return Math.max(
1,
Math.min(configuredTimeoutMs, Math.floor(deadline - clock.now())),
);
}
function benchmarkResult(
request: RegexBenchmarkRequest,
started: number,
clock: Clock,
values: {
readonly status: AnalysisRunStatus;
readonly identity?: RegexBenchmarkResult["identity"];
readonly coldStartMs?: number;
readonly coldSample?: BenchmarkWorkerSample;
readonly warmSamples: readonly BenchmarkWorkerSample[];
readonly completedWarmups: number;
readonly stoppedReason?: string;
readonly warnings: readonly string[];
},
): RegexBenchmarkResult {
return {
status: values.status,
...(values.identity ? { identity: values.identity } : {}),
...(values.coldStartMs === undefined
? {}
: { coldStartMs: values.coldStartMs }),
...(values.coldSample ? { coldSample: values.coldSample } : {}),
warmSamples: values.warmSamples,
warmStatistics: summarizeBenchmarkSamples(values.warmSamples),
completedWarmups: values.completedWarmups,
completedMeasuredIterations: values.warmSamples.length,
requestedSettings: request.settings,
wallTimeMs: Math.max(0, clock.now() - started),
...(values.stoppedReason ? { stoppedReason: values.stoppedReason } : {}),
warnings: values.warnings,
};
}
function progress(options: AnalysisRunOptions, value: AnalysisProgress): void {
options.onProgress?.(value);
}
export async function runRegexBenchmark(
request: RegexBenchmarkRequest,
client: AnalysisWorkerClient,
options: AnalysisRunOptions = {},
clock: Clock = SYSTEM_CLOCK,
): Promise<RegexBenchmarkResult> {
validateBenchmarkRequest(request);
const started = clock.now();
const deadline = started + request.settings.maximumWallTimeMs;
let identity: RegexBenchmarkResult["identity"];
let coldStartMs: number | undefined;
let coldSample: BenchmarkWorkerSample | undefined;
let completedWarmups = 0;
const warmSamples: BenchmarkWorkerSample[] = [];
let requestWasWallLimited = false;
const nextRequestTimeout = () => {
requestWasWallLimited =
deadline - clock.now() < request.settings.sampleTimeoutMs;
return timeoutWithinDeadline(
request.settings.sampleTimeoutMs,
deadline,
clock,
);
};
const warnings = [
"One browser, pattern and subject are not a complete performance characterization.",
"Native browser and WebAssembly engine timings are not directly comparable.",
"Displayed precision does not imply nanosecond measurement accuracy.",
];
const partial = (
status: AnalysisRunStatus,
stoppedReason: string,
): RegexBenchmarkResult =>
benchmarkResult(request, started, clock, {
status,
identity,
coldStartMs,
coldSample,
warmSamples,
completedWarmups,
stoppedReason,
warnings,
});
try {
if (aborted(options.signal)) {
return partial("cancelled", "Benchmark cancelled before it started.");
}
progress(options, {
phase: "starting-worker",
completed: 0,
total: 1,
message: "Starting a fresh ECMAScript analysis worker…",
});
const coldStart = clock.now();
identity = await client.identity(nextRequestTimeout());
coldStartMs = Math.max(0, clock.now() - coldStart);
if (clock.now() >= deadline) {
return partial(
"wall-time-limit",
"Benchmark stopped at its aggregate wall-time limit.",
);
}
progress(options, {
phase: "cold-sample",
completed: 0,
total: 1,
message: "Measuring the first engine use separately…",
});
coldSample = await client.benchmarkSample(request, nextRequestTimeout());
if (!coldSample.accepted) {
return partial(
"error",
coldSample.compileError ??
"The native ECMAScript engine rejected the benchmark pattern.",
);
}
for (let index = 0; index < request.settings.warmupIterations; index += 1) {
if (aborted(options.signal)) {
return partial("cancelled", "Benchmark cancelled during warm-up.");
}
if (clock.now() >= deadline) {
return partial(
"wall-time-limit",
"Benchmark stopped at its aggregate wall-time limit.",
);
}
progress(options, {
phase: "warming-up",
completed: index,
total: request.settings.warmupIterations,
message: `Warm-up ${index + 1} of ${request.settings.warmupIterations}`,
});
const sample = await client.benchmarkSample(
request,
nextRequestTimeout(),
);
if (!sample.accepted) {
return partial(
"error",
sample.compileError ??
"The native ECMAScript engine rejected a warm-up sample.",
);
}
completedWarmups += 1;
}
for (
let index = 0;
index < request.settings.measuredIterations;
index += 1
) {
if (aborted(options.signal)) {
return partial("cancelled", "Benchmark cancelled while measuring.");
}
if (clock.now() >= deadline) {
return partial(
"wall-time-limit",
"Benchmark stopped at its aggregate wall-time limit.",
);
}
progress(options, {
phase: "measuring",
completed: index,
total: request.settings.measuredIterations,
message: `Measured sample ${index + 1} of ${request.settings.measuredIterations}`,
});
const sample = await client.benchmarkSample(
request,
nextRequestTimeout(),
);
if (!sample.accepted) {
return partial(
"error",
sample.compileError ??
"The native ECMAScript engine rejected a measured sample.",
);
}
warmSamples.push(sample);
}
} catch (error) {
const kind = errorKind(error);
if (kind === "timeout") {
if (requestWasWallLimited || clock.now() >= deadline) {
return partial(
"wall-time-limit",
"Benchmark stopped at its aggregate wall-time limit.",
);
}
return partial(
"timeout",
"A benchmark sample timed out; its worker was terminated.",
);
}
if (kind === "cancelled" || aborted(options.signal)) {
return partial(
"cancelled",
"Benchmark cancelled; its worker was terminated.",
);
}
return partial("error", `Benchmark worker failed: ${errorMessage(error)}`);
}
for (const sample of [coldSample, ...warmSamples]) {
if (sample?.matchCollectionTruncated) {
warnings.push(
`Match collection reached the ${request.maximumMatches.toLocaleString()}-match benchmark limit.`,
);
break;
}
}
const skippedReplacement = [coldSample, ...warmSamples].find(
(sample) => sample?.replacementSkippedReason,
)?.replacementSkippedReason;
if (skippedReplacement) {
warnings.push(`Replacement timing skipped: ${skippedReplacement}`);
}
return benchmarkResult(request, started, clock, {
status: "complete",
identity,
coldStartMs,
coldSample,
warmSamples,
completedWarmups,
warnings,
});
}
function generatedSubjectSize(
request: GrowthAnalysisRequest,
repetitions: number,
): { readonly utf16: number; readonly bytes: number } {
return {
utf16:
request.settings.prefix.length +
request.settings.repeatedFragment.length * repetitions +
request.settings.suffix.length,
bytes:
utf8ByteLength(request.settings.prefix) +
utf8ByteLength(request.settings.repeatedFragment) * repetitions +
utf8ByteLength(request.settings.suffix),
};
}
function dynamicFinding(
request: GrowthAnalysisRequest,
rule: "observed-growth" | "observed-timeout",
details: {
readonly confidence: "medium" | "high";
readonly severity: "warning" | "high";
readonly explanation: string;
readonly exampleRisk: string;
},
): RegexRiskFinding {
return {
id: `risk-${rule}-0-${request.pattern.length}`,
flavour: "ecmascript",
range: { startUtf16: 0, endUtf16: request.pattern.length },
rule,
title:
rule === "observed-timeout"
? "Observed timeout under selected limits"
: "Observed disproportionate growth",
explanation: details.explanation,
exampleRisk: details.exampleRisk,
confidence: details.confidence,
severity: details.severity,
limitations:
"This observation applies only to the generated subjects, browser runtime and limits shown in this result.",
suggestedInvestigation:
"Repeat with representative production boundaries, retain strict worker limits, and simplify ambiguous repetition where possible.",
evidence: "dynamically-observed",
};
}
function growthResult(
request: GrowthAnalysisRequest,
started: number,
clock: Clock,
values: {
readonly status: AnalysisRunStatus;
readonly identity?: GrowthAnalysisResult["identity"];
readonly samples: readonly GrowthSample[];
readonly stopReason: GrowthStopReason;
readonly stoppedReason: string;
readonly dynamicFindings: readonly RegexRiskFinding[];
},
): GrowthAnalysisResult {
return {
status: values.status,
...(values.identity ? { identity: values.identity } : {}),
samples: values.samples,
stopReason: values.stopReason,
stoppedReason: values.stoppedReason,
wallTimeMs: Math.max(0, clock.now() - started),
dynamicFindings: values.dynamicFindings,
settings: request.settings,
};
}
export async function runGrowthAnalysis(
request: GrowthAnalysisRequest,
client: AnalysisWorkerClient,
options: AnalysisRunOptions = {},
clock: Clock = SYSTEM_CLOCK,
): Promise<GrowthAnalysisResult> {
validateGrowthRequest(request);
const started = clock.now();
const deadline = started + request.settings.maximumWallTimeMs;
const samples: GrowthSample[] = [];
const dynamicFindings: RegexRiskFinding[] = [];
let identity: GrowthAnalysisResult["identity"];
let currentRepetitions = request.settings.startingRepetitions;
let requestWasWallLimited = false;
const nextRequestTimeout = () => {
requestWasWallLimited =
deadline - clock.now() < request.settings.sampleTimeoutMs;
return timeoutWithinDeadline(
request.settings.sampleTimeoutMs,
deadline,
clock,
);
};
const finish = (
status: AnalysisRunStatus,
stopReason: GrowthStopReason,
stoppedReason: string,
) =>
growthResult(request, started, clock, {
status,
identity,
samples,
stopReason,
stoppedReason,
dynamicFindings,
});
try {
if (aborted(options.signal)) {
return finish(
"cancelled",
"cancelled",
"Growth analysis cancelled before it started.",
);
}
identity = await client.identity(nextRequestTimeout());
while (samples.length < request.settings.maximumSteps) {
if (aborted(options.signal)) {
return finish(
"cancelled",
"cancelled",
"Growth analysis cancelled; its worker was terminated.",
);
}
if (clock.now() >= deadline) {
return finish(
"wall-time-limit",
"wall-time-limit",
"Growth analysis stopped at its aggregate wall-time limit.",
);
}
if (currentRepetitions > request.settings.maximumRepetitions) {
return finish(
"complete",
"maximum-repetitions",
"Growth analysis reached the configured repetition bound.",
);
}
const size = generatedSubjectSize(request, currentRepetitions);
if (
!Number.isSafeInteger(size.bytes) ||
!Number.isSafeInteger(size.utf16) ||
size.bytes > request.settings.maximumSubjectBytes
) {
return finish(
"complete",
"maximum-subject-size",
"Growth analysis stopped before exceeding the generated-subject byte limit.",
);
}
progress(options, {
phase: "growth-probe",
completed: samples.length,
total: request.settings.maximumSteps,
message: `Probing ${currentRepetitions.toLocaleString()} fragment repetitions…`,
});
const subject =
request.settings.prefix +
request.settings.repeatedFragment.repeat(currentRepetitions) +
request.settings.suffix;
let workerSample;
try {
workerSample = await client.growthProbe(
{
flavour: "ecmascript",
pattern: request.pattern,
flags: request.flags,
subject,
scanAll: request.scanAll,
maximumMatches: request.maximumMatches,
},
nextRequestTimeout(),
);
} catch (error) {
const kind = errorKind(error);
if (kind === "timeout") {
if (requestWasWallLimited || clock.now() >= deadline) {
return finish(
"wall-time-limit",
"wall-time-limit",
"Growth analysis stopped at its aggregate wall-time limit.",
);
}
samples.push({
repetitions: currentRepetitions,
inputUtf16: size.utf16,
inputBytes: size.bytes,
status: "timeout",
message: "Worker timed out and was terminated.",
});
dynamicFindings.push(
dynamicFinding(request, "observed-timeout", {
confidence: "high",
severity: "high",
explanation: `Execution did not finish within ${request.settings.sampleTimeoutMs.toLocaleString()} ms at ${currentRepetitions.toLocaleString()} generated repetitions.`,
exampleRisk:
"The selected generated near-miss exhausted the configured per-sample wall time.",
}),
);
return finish(
"timeout",
"timeout",
"Observed timeout under selected limits; the worker was terminated.",
);
}
if (kind === "cancelled" || aborted(options.signal)) {
return finish(
"cancelled",
"cancelled",
"Growth analysis cancelled; its worker was terminated.",
);
}
samples.push({
repetitions: currentRepetitions,
inputUtf16: size.utf16,
inputBytes: size.bytes,
status: "crash",
message: errorMessage(error),
});
return finish(
"error",
"crash",
`Growth worker failed: ${errorMessage(error)}`,
);
}
if (!workerSample.accepted) {
samples.push({
repetitions: currentRepetitions,
inputUtf16: size.utf16,
inputBytes: size.bytes,
status: "compile-error",
message: workerSample.compileError,
});
return finish(
"error",
"compile-error",
workerSample.compileError ??
"The native ECMAScript engine rejected the pattern.",
);
}
const previous = samples.at(-1);
const executionMs = workerSample.executionMs ?? 0;
let normalizedGrowth: number | undefined;
if (
previous?.status === "complete" &&
previous.executionMs !== undefined &&
previous.executionMs >= 0.25 &&
executionMs >= 1 &&
previous.inputBytes > 0
) {
const inputRatio = size.bytes / previous.inputBytes;
const timeRatio = executionMs / previous.executionMs;
if (inputRatio > 0) normalizedGrowth = timeRatio / inputRatio;
}
samples.push({
repetitions: currentRepetitions,
inputUtf16: size.utf16,
inputBytes: size.bytes,
status: "complete",
executionMs,
matched: workerSample.matched,
matchCount: workerSample.matchCount,
matchCollectionTruncated: workerSample.matchCollectionTruncated,
...(normalizedGrowth === undefined ? {} : { normalizedGrowth }),
});
if (
normalizedGrowth !== undefined &&
normalizedGrowth >= request.settings.normalizedGrowthThreshold
) {
dynamicFindings.push(
dynamicFinding(request, "observed-growth", {
confidence: "medium",
severity: "warning",
explanation: `Observed time growth was ${normalizedGrowth.toFixed(2)}× the input growth between the last two generated samples.`,
exampleRisk:
"Execution time increased disproportionately for the selected generated subject family.",
}),
);
return finish(
"complete",
"growth-threshold",
"Growth analysis stopped at the configured normalized-growth threshold.",
);
}
if (currentRepetitions >= request.settings.maximumRepetitions) {
return finish(
"complete",
"maximum-repetitions",
"Growth analysis reached the configured repetition bound.",
);
}
const next = Math.min(
request.settings.maximumRepetitions,
Math.max(
currentRepetitions + 1,
Math.ceil(currentRepetitions * request.settings.multiplier),
),
);
currentRepetitions = next;
}
} catch (error) {
const kind = errorKind(error);
if (kind === "timeout") {
if (requestWasWallLimited || clock.now() >= deadline) {
return finish(
"wall-time-limit",
"wall-time-limit",
"Growth analysis stopped at its aggregate wall-time limit.",
);
}
return finish(
"timeout",
"timeout",
"The analysis worker timed out while starting and was terminated.",
);
}
if (kind === "cancelled" || aborted(options.signal)) {
return finish(
"cancelled",
"cancelled",
"Growth analysis cancelled; its worker was terminated.",
);
}
return finish(
"error",
"crash",
`Growth worker failed: ${errorMessage(error)}`,
);
}
return finish(
"complete",
"maximum-steps",
"Growth analysis reached the configured step limit.",
);
}

View File

@@ -0,0 +1,266 @@
import type { RegexFlavourId } from "../model/flavour";
import type { NormalizedRegexNode, SourceRange } from "../model/syntax";
export type RegexRiskConfidence = "low" | "medium" | "high";
export type RegexRiskSeverity = "notice" | "warning" | "high";
export type RegexRiskEvidence = "static" | "dynamically-observed";
export type RegexRiskRule =
| "nested-quantifier"
| "ambiguous-repeated-alternatives"
| "overlapping-alternatives"
| "repeated-wildcard"
| "backreference-in-repetition"
| "nullable-repeated-expression"
| "repeated-lookaround-body"
| "unanchored-expensive-prefix"
| "excessive-captures"
| "excessive-nesting"
| "potential-replacement-expansion"
| "observed-growth"
| "observed-timeout";
export interface RegexRiskFinding {
readonly id: string;
readonly flavour: RegexFlavourId;
readonly range: SourceRange;
readonly rule: RegexRiskRule;
readonly title: string;
readonly explanation: string;
readonly exampleRisk: string;
readonly confidence: RegexRiskConfidence;
readonly severity: RegexRiskSeverity;
readonly limitations: string;
readonly suggestedInvestigation: string;
readonly evidence: RegexRiskEvidence;
}
export interface StaticRiskRequest {
readonly flavour: RegexFlavourId;
readonly root: NormalizedRegexNode;
readonly flags: readonly string[];
readonly scanAll: boolean;
readonly replacement?: string;
}
export interface StaticRiskReport {
readonly flavour: RegexFlavourId;
readonly analyser: {
readonly id: "regex-tools-ecmascript-heuristics";
readonly version: "1";
readonly support: "experimental-ecmascript-2025" | "unsupported-flavour";
};
readonly findings: readonly RegexRiskFinding[];
readonly analysedNodes: number;
readonly findingsTruncated: boolean;
readonly traversalTruncated: boolean;
readonly summary: string;
readonly limitations: readonly string[];
}
export interface AnalysisEngineIdentity {
readonly flavour: "ecmascript";
readonly engineName: "Native ECMAScript RegExp";
readonly engineVersion: string;
readonly runtimeVersion: string;
readonly nativeOffsetUnit: "utf16";
}
export interface BenchmarkSubject {
readonly flavour: "ecmascript";
readonly pattern: string;
readonly flags: readonly string[];
readonly subject: string;
readonly replacement: string;
readonly scanAll: boolean;
readonly maximumMatches: number;
}
export interface BenchmarkWorkerSample {
readonly accepted: boolean;
readonly compileError?: string;
readonly effectiveFlags: string;
readonly subjectBytes: number;
readonly subjectUtf16: number;
readonly compileMs: number;
readonly firstMatchMs?: number;
readonly allMatchesMs?: number;
readonly replacementMs?: number;
readonly replacementSkippedReason?: string;
readonly throughputBytesPerSecond?: number;
readonly matchCount: number;
readonly matched: boolean;
readonly matchCollectionTruncated: boolean;
readonly replacementOutputUtf16?: number;
}
export interface BenchmarkSettings {
readonly warmupIterations: number;
readonly measuredIterations: number;
readonly sampleTimeoutMs: number;
readonly maximumWallTimeMs: number;
}
export interface RegexBenchmarkRequest extends BenchmarkSubject {
readonly settings: BenchmarkSettings;
}
export interface MetricStatistics {
readonly count: number;
readonly minimum: number;
readonly median: number;
readonly p95: number;
readonly maximum: number;
}
export interface BenchmarkMetricSummary {
readonly compileMs?: MetricStatistics;
readonly firstMatchMs?: MetricStatistics;
readonly allMatchesMs?: MetricStatistics;
readonly replacementMs?: MetricStatistics;
readonly throughputBytesPerSecond?: MetricStatistics;
}
export type AnalysisRunStatus =
"complete" | "timeout" | "cancelled" | "wall-time-limit" | "error";
export interface RegexBenchmarkResult {
readonly status: AnalysisRunStatus;
readonly identity?: AnalysisEngineIdentity;
readonly coldStartMs?: number;
readonly coldSample?: BenchmarkWorkerSample;
readonly warmSamples: readonly BenchmarkWorkerSample[];
readonly warmStatistics: BenchmarkMetricSummary;
readonly completedWarmups: number;
readonly completedMeasuredIterations: number;
readonly requestedSettings: BenchmarkSettings;
readonly wallTimeMs: number;
readonly stoppedReason?: string;
readonly warnings: readonly string[];
}
export interface GrowthProbeSubject {
readonly flavour: "ecmascript";
readonly pattern: string;
readonly flags: readonly string[];
readonly subject: string;
readonly scanAll: boolean;
readonly maximumMatches: number;
}
export interface GrowthWorkerSample {
readonly accepted: boolean;
readonly compileError?: string;
readonly effectiveFlags: string;
readonly subjectBytes: number;
readonly subjectUtf16: number;
readonly executionMs?: number;
readonly matchCount: number;
readonly matched: boolean;
readonly matchCollectionTruncated: boolean;
}
export interface GrowthSettings {
readonly prefix: string;
readonly repeatedFragment: string;
readonly suffix: string;
readonly startingRepetitions: number;
readonly maximumRepetitions: number;
readonly multiplier: number;
readonly maximumSteps: number;
readonly sampleTimeoutMs: number;
readonly maximumWallTimeMs: number;
readonly maximumSubjectBytes: number;
readonly normalizedGrowthThreshold: number;
}
export type GrowthSampleStatus =
"complete" | "timeout" | "crash" | "compile-error";
export interface GrowthSample {
readonly repetitions: number;
readonly inputUtf16: number;
readonly inputBytes: number;
readonly status: GrowthSampleStatus;
readonly executionMs?: number;
readonly matched?: boolean;
readonly matchCount?: number;
readonly matchCollectionTruncated?: boolean;
readonly normalizedGrowth?: number;
readonly message?: string;
}
export type GrowthStopReason =
| "maximum-repetitions"
| "maximum-steps"
| "maximum-subject-size"
| "growth-threshold"
| "timeout"
| "compile-error"
| "crash"
| "cancelled"
| "wall-time-limit";
export interface GrowthAnalysisRequest {
readonly flavour: "ecmascript";
readonly pattern: string;
readonly flags: readonly string[];
readonly scanAll: boolean;
readonly maximumMatches: number;
readonly settings: GrowthSettings;
}
export interface GrowthAnalysisResult {
readonly status: AnalysisRunStatus;
readonly identity?: AnalysisEngineIdentity;
readonly samples: readonly GrowthSample[];
readonly stopReason: GrowthStopReason;
readonly stoppedReason: string;
readonly wallTimeMs: number;
readonly dynamicFindings: readonly RegexRiskFinding[];
readonly settings: GrowthSettings;
}
export type AnalysisWorkerOperation =
| { readonly kind: "identity" }
| {
readonly kind: "benchmark-sample";
readonly request: BenchmarkSubject;
}
| {
readonly kind: "growth-probe";
readonly request: GrowthProbeSubject;
};
export type AnalysisWorkerResult =
| {
readonly kind: "identity";
readonly result: AnalysisEngineIdentity;
}
| {
readonly kind: "benchmark-sample";
readonly result: BenchmarkWorkerSample;
}
| {
readonly kind: "growth-probe";
readonly result: GrowthWorkerSample;
};
export interface AnalysisProgress {
readonly phase:
| "starting-worker"
| "cold-sample"
| "warming-up"
| "measuring"
| "growth-probe";
readonly completed: number;
readonly total: number;
readonly message: string;
}
export interface AnalysisRunOptions {
readonly signal?: AbortSignal;
readonly onProgress?: (progress: AnalysisProgress) => void;
}

View File

@@ -0,0 +1,180 @@
import { describe, expect, it } from "vitest";
import {
benchmarkEcmaScriptSample,
ecmaScriptAnalysisIdentity,
probeEcmaScriptGrowth,
} from "./ecmascript-analysis";
function benchmarkRequest(
overrides: Partial<Parameters<typeof benchmarkEcmaScriptSample>[0]> = {},
) {
return {
flavour: "ecmascript" as const,
pattern: "\\d+",
flags: ["g"],
subject: "12 345",
replacement: "[$&]",
scanAll: true,
maximumMatches: 10_000,
...overrides,
};
}
describe("native ECMAScript analysis worker primitives", () => {
it("retains exact runtime identity and separates benchmark phases", () => {
const identity = ecmaScriptAnalysisIdentity();
const sample = benchmarkEcmaScriptSample(benchmarkRequest());
expect(identity).toEqual(
expect.objectContaining({
flavour: "ecmascript",
engineName: "Native ECMAScript RegExp",
nativeOffsetUnit: "utf16",
}),
);
expect(sample).toEqual(
expect.objectContaining({
accepted: true,
effectiveFlags: expect.stringContaining("g"),
matchCount: 2,
matched: true,
matchCollectionTruncated: false,
replacementOutputUtf16: 10,
}),
);
expect(sample.compileMs).toBeGreaterThanOrEqual(0);
expect(sample.firstMatchMs).toBeGreaterThanOrEqual(0);
expect(sample.allMatchesMs).toBeGreaterThanOrEqual(0);
expect(sample.replacementMs).toBeGreaterThanOrEqual(0);
expect(sample.throughputBytesPerSecond).toBeGreaterThanOrEqual(0);
});
it("bounds zero-length global iteration", () => {
const sample = benchmarkEcmaScriptSample(
benchmarkRequest({
pattern: "(?=.)",
flags: ["g", "u"],
subject: "😀x",
replacement: "",
}),
);
expect(sample.matchCount).toBe(2);
expect(sample.matchCollectionTruncated).toBe(false);
});
it("preserves single sticky execution unless scan-all is explicit", () => {
const single = benchmarkEcmaScriptSample(
benchmarkRequest({
pattern: "a",
flags: ["y"],
subject: "aa",
replacement: "x",
scanAll: false,
}),
);
const scanAll = benchmarkEcmaScriptSample(
benchmarkRequest({
pattern: "a",
flags: ["y"],
subject: "aa",
replacement: "x",
scanAll: true,
}),
);
expect(single.matchCount).toBe(1);
expect(scanAll.matchCount).toBe(2);
expect(scanAll.replacementMs).toBeUndefined();
expect(scanAll.replacementSkippedReason).toContain(
"multi-match sticky replacement",
);
});
it("returns compile rejection as data", () => {
const sample = benchmarkEcmaScriptSample(
benchmarkRequest({ pattern: "(", subject: "" }),
);
const growth = probeEcmaScriptGrowth({
flavour: "ecmascript",
pattern: "(",
flags: [],
subject: "",
scanAll: false,
maximumMatches: 10,
});
expect(sample.accepted).toBe(false);
expect(sample.compileError).toBeTruthy();
expect(growth).toEqual(
expect.objectContaining({
accepted: false,
compileError: expect.any(String),
}),
);
});
it("skips replacement timing before a conservative output can grow beyond its bound", () => {
const sample = benchmarkEcmaScriptSample(
benchmarkRequest({
pattern: "a",
flags: ["g"],
subject: "a".repeat(4_096),
replacement: "$`$'",
maximumMatches: 10_000,
}),
);
expect(sample.replacementMs).toBeUndefined();
expect(sample.replacementSkippedReason).toContain(
"Conservative output estimate",
);
});
it("does not run unbounded replacement work after match collection truncates", () => {
const sample = benchmarkEcmaScriptSample(
benchmarkRequest({
pattern: "a",
flags: ["g"],
subject: "aaaa",
maximumMatches: 1,
}),
);
expect(sample.matchCollectionTruncated).toBe(true);
expect(sample.replacementMs).toBeUndefined();
expect(sample.replacementSkippedReason).toContain(
"Match collection reached its bound",
);
});
it("rejects flags outside the supported ECMAScript set", () => {
expect(() =>
benchmarkEcmaScriptSample(
benchmarkRequest({ flags: ["g", "unsupported"] }),
),
).toThrow(/Unsupported ECMAScript analysis flag/u);
});
it("reports bounded growth probes without materializing match values", () => {
const sample = probeEcmaScriptGrowth({
flavour: "ecmascript",
pattern: "a+$",
flags: [],
subject: "aaaa!",
scanAll: false,
maximumMatches: 10,
});
expect(sample).toEqual(
expect.objectContaining({
accepted: true,
subjectBytes: 5,
subjectUtf16: 5,
matched: false,
matchCount: 0,
}),
);
expect(sample.executionMs).toBeGreaterThanOrEqual(0);
});
});

View File

@@ -0,0 +1,299 @@
import { utf8ByteLength } from "../execution/request-limits";
import { ANALYSIS_LIMITS } from "./analysis-limits";
import type {
AnalysisEngineIdentity,
BenchmarkSubject,
BenchmarkWorkerSample,
GrowthProbeSubject,
GrowthWorkerSample,
} from "./analysis.types";
const FLAG_ORDER = "dgimsuvy";
function runtimeIdentity(): string {
return typeof navigator === "undefined"
? "Unknown ECMAScript runtime"
: navigator.userAgent;
}
export function ecmaScriptAnalysisIdentity(): AnalysisEngineIdentity {
const identity = runtimeIdentity();
return {
flavour: "ecmascript",
engineName: "Native ECMAScript RegExp",
engineVersion: identity,
runtimeVersion: identity,
nativeOffsetUnit: "utf16",
};
}
function hasIndicesSupport(): boolean {
try {
return new RegExp("", "d").hasIndices;
} catch {
return false;
}
}
function effectiveFlags(flags: readonly string[], scanAll: boolean): string {
for (const flag of flags) {
if (!FLAG_ORDER.includes(flag)) {
throw new RangeError(
`Unsupported ECMAScript analysis flag ${JSON.stringify(flag)}.`,
);
}
}
const unique = new Set(flags);
if (hasIndicesSupport()) unique.add("d");
if (scanAll && !unique.has("g") && !unique.has("y")) unique.add("g");
return FLAG_ORDER.split("")
.filter((flag) => unique.has(flag))
.join("");
}
function advanceStringIndex(
subject: string,
index: number,
unicode: boolean,
): number {
if (!unicode) return index + 1;
const first = subject.charCodeAt(index);
if (first < 0xd800 || first > 0xdbff || index + 1 >= subject.length) {
return index + 1;
}
const second = subject.charCodeAt(index + 1);
return second >= 0xdc00 && second <= 0xdfff ? index + 2 : index + 1;
}
function collectMatches(
expression: RegExp,
subject: string,
iterate: boolean,
maximumMatches: number,
): {
readonly count: number;
readonly matched: boolean;
readonly truncated: boolean;
} {
const unicode = expression.unicode || expression.flags.includes("v");
expression.lastIndex = 0;
let count = 0;
while (true) {
const match = expression.exec(subject);
if (!match) break;
count += 1;
if (count >= maximumMatches) {
return { count, matched: true, truncated: iterate };
}
if (!iterate) break;
if (match[0].length === 0) {
expression.lastIndex = advanceStringIndex(
subject,
expression.lastIndex,
unicode,
);
if (expression.lastIndex > subject.length) break;
}
}
return { count, matched: count > 0, truncated: false };
}
function assertWorkerInput(
request: BenchmarkSubject | GrowthProbeSubject,
): void {
if (request.flavour !== "ecmascript") {
throw new RangeError("The analysis worker supports ECMAScript only.");
}
if (request.pattern.length > 64 * 1024) {
throw new RangeError("Pattern exceeds the 64 Ki UTF-16 analysis limit.");
}
if (utf8ByteLength(request.subject) > 16 * 1024 * 1024) {
throw new RangeError("Subject exceeds the 16 MiB analysis limit.");
}
if (
!Number.isSafeInteger(request.maximumMatches) ||
request.maximumMatches < 1 ||
request.maximumMatches > 10_000
) {
throw new RangeError("Analysis match limit must be between 1 and 10,000.");
}
}
function replacementWorstCaseUtf16(
replacement: string,
subjectUtf16: number,
matchCount: number,
): number {
let references = 0;
for (let index = 0; index < replacement.length - 1; index += 1) {
if (
replacement[index] === "$" &&
/[`'&0-9<]/u.test(replacement[index + 1] ?? "")
) {
references += 1;
index += 1;
}
}
const literal = replacement.length;
return (
subjectUtf16 +
matchCount * (literal + references * Math.max(1, subjectUtf16))
);
}
function compileFailure(
effective: string,
subject: string,
compileMs: number,
error: unknown,
): BenchmarkWorkerSample {
return {
accepted: false,
compileError:
error instanceof Error
? error.message
: "The native ECMAScript engine rejected the pattern.",
effectiveFlags: effective,
subjectBytes: utf8ByteLength(subject),
subjectUtf16: subject.length,
compileMs,
matchCount: 0,
matched: false,
matchCollectionTruncated: false,
};
}
export function benchmarkEcmaScriptSample(
request: BenchmarkSubject,
): BenchmarkWorkerSample {
assertWorkerInput(request);
if (request.replacement.length > 64 * 1024) {
throw new RangeError(
"Replacement exceeds the 64 Ki UTF-16 analysis limit.",
);
}
const flags = effectiveFlags(request.flags, request.scanAll);
const compileStart = performance.now();
let compiled: RegExp;
try {
compiled = new RegExp(request.pattern, flags);
} catch (error) {
return compileFailure(
flags,
request.subject,
performance.now() - compileStart,
error,
);
}
const compileMs = performance.now() - compileStart;
const iterate = flags.includes("g") || request.scanAll;
const firstStart = performance.now();
compiled.exec(request.subject);
const firstMatchMs = performance.now() - firstStart;
const allExpression = new RegExp(request.pattern, flags);
const allStart = performance.now();
const collection = collectMatches(
allExpression,
request.subject,
iterate,
request.maximumMatches,
);
const allMatchesMs = performance.now() - allStart;
const subjectBytes = utf8ByteLength(request.subject);
const throughputBytesPerSecond =
allMatchesMs > 0 ? (subjectBytes / allMatchesMs) * 1_000 : undefined;
const worstCaseOutput = replacementWorstCaseUtf16(
request.replacement,
request.subject.length,
collection.count,
);
let replacementMs: number | undefined;
let replacementOutputUtf16: number | undefined;
let replacementSkippedReason: string | undefined;
if (flags.includes("y") && request.scanAll) {
replacementSkippedReason =
"Explicit multi-match sticky replacement is not timed with native String.replace because that API replaces only one sticky match.";
} else if (collection.truncated) {
replacementSkippedReason =
"Match collection reached its bound, so total replacement work and output are not known.";
} else if (
worstCaseOutput > ANALYSIS_LIMITS.maximumReplacementBenchmarkOutputUtf16
) {
replacementSkippedReason = `Conservative output estimate exceeds ${ANALYSIS_LIMITS.maximumReplacementBenchmarkOutputUtf16.toLocaleString()} UTF-16 units.`;
} else {
const replacementExpression = new RegExp(request.pattern, flags);
const replacementStart = performance.now();
const output = request.subject.replace(
replacementExpression,
request.replacement,
);
replacementMs = performance.now() - replacementStart;
replacementOutputUtf16 = output.length;
}
return {
accepted: true,
effectiveFlags: flags,
subjectBytes,
subjectUtf16: request.subject.length,
compileMs,
firstMatchMs,
allMatchesMs,
...(replacementMs === undefined ? {} : { replacementMs }),
...(replacementSkippedReason ? { replacementSkippedReason } : {}),
...(throughputBytesPerSecond === undefined
? {}
: { throughputBytesPerSecond }),
matchCount: collection.count,
matched: collection.matched,
matchCollectionTruncated: collection.truncated,
...(replacementOutputUtf16 === undefined ? {} : { replacementOutputUtf16 }),
};
}
export function probeEcmaScriptGrowth(
request: GrowthProbeSubject,
): GrowthWorkerSample {
assertWorkerInput(request);
const flags = effectiveFlags(request.flags, request.scanAll);
let expression: RegExp;
try {
expression = new RegExp(request.pattern, flags);
} catch (error) {
return {
accepted: false,
compileError:
error instanceof Error
? error.message
: "The native ECMAScript engine rejected the pattern.",
effectiveFlags: flags,
subjectBytes: utf8ByteLength(request.subject),
subjectUtf16: request.subject.length,
matchCount: 0,
matched: false,
matchCollectionTruncated: false,
};
}
const iterate = flags.includes("g") || request.scanAll;
const started = performance.now();
const collection = collectMatches(
expression,
request.subject,
iterate,
request.maximumMatches,
);
const executionMs = performance.now() - started;
return {
accepted: true,
effectiveFlags: flags,
subjectBytes: utf8ByteLength(request.subject),
subjectUtf16: request.subject.length,
executionMs,
matchCount: collection.count,
matched: collection.matched,
matchCollectionTruncated: collection.truncated,
};
}

View File

@@ -0,0 +1,178 @@
import { describe, expect, it } from "vitest";
import { EcmaScriptSyntaxProvider } from "../syntax/providers/ecmascript/EcmaScriptSyntaxProvider";
import type { NormalizedRegexNode } from "../model/syntax";
import {
analyseStaticRisk,
MAXIMUM_STATIC_ANALYSIS_NODES,
} from "./static-risk";
const provider = new EcmaScriptSyntaxProvider();
async function analyse(
pattern: string,
overrides: {
readonly flags?: readonly string[];
readonly scanAll?: boolean;
readonly replacement?: string;
} = {},
) {
const flags = overrides.flags ?? [];
const syntax = await provider.parsePattern({
flavour: "ecmascript",
flavourVersion: "2025",
pattern,
flags,
options: {},
});
expect(syntax.accepted).toBe(true);
return analyseStaticRisk({
flavour: "ecmascript",
root: syntax.root,
flags,
scanAll: overrides.scanAll ?? false,
replacement: overrides.replacement,
});
}
describe("ECMAScript static risk analysis", () => {
it("identifies nested, nullable and unanchored repetition without claiming vulnerability", async () => {
const report = await analyse("(a*)+$");
const rules = report.findings.map((finding) => finding.rule);
expect(rules).toEqual(
expect.arrayContaining([
"nested-quantifier",
"nullable-repeated-expression",
"unanchored-expensive-prefix",
]),
);
expect(report.findings[0]).toEqual(
expect.objectContaining({
flavour: "ecmascript",
evidence: "static",
confidence: expect.stringMatching(/low|medium|high/u),
limitations: expect.any(String),
suggestedInvestigation: expect.any(String),
}),
);
expect(report.summary).toMatch(/potential/u);
expect(JSON.stringify(report)).not.toMatch(
/\bdefinitely vulnerable\b|\bguaranteed linear\b/iu,
);
});
it("detects ambiguous alternatives, wildcards and repeated backreferences", async () => {
const alternatives = await analyse("(?:a|aa)+$");
expect(alternatives.findings.map((finding) => finding.rule)).toEqual(
expect.arrayContaining([
"ambiguous-repeated-alternatives",
"overlapping-alternatives",
]),
);
const wildcard = await analyse("(?:.*)+x");
expect(wildcard.findings.map((finding) => finding.rule)).toContain(
"repeated-wildcard",
);
const backreference = await analyse("(a)(?:\\1+)+$");
expect(backreference.findings.map((finding) => finding.rule)).toContain(
"backreference-in-repetition",
);
});
it("reports repeated lookaround bodies and replacement expansion", async () => {
const report = await analyse("(?=(a+)+)a", {
flags: ["g"],
replacement: "$`$'",
});
expect(report.findings.map((finding) => finding.rule)).toEqual(
expect.arrayContaining([
"repeated-lookaround-body",
"potential-replacement-expansion",
]),
);
});
it("uses the required cautious empty result wording", async () => {
const report = await analyse("^\\d{4}-\\d{2}-\\d{2}$");
expect(report.findings).toEqual([]);
expect(report.summary).toBe(
"No issue found by this analyser. This is not a safety guarantee.",
);
});
it("does not apply ECMAScript heuristics to another flavour", async () => {
const syntax = await provider.parsePattern({
flavour: "ecmascript",
pattern: "(a+)+",
flags: [],
options: {},
});
const report = analyseStaticRisk({
flavour: "pcre2",
root: syntax.root,
flags: [],
scanAll: false,
});
expect(report.analyser.support).toBe("unsupported-flavour");
expect(report.findings).toEqual([]);
expect(report.summary).toContain("unavailable for pcre2");
});
it("bounds normalized-tree traversal independently of render limits", () => {
const literal = (index: number): NormalizedRegexNode => ({
id: `literal-${index}`,
kind: "literal",
range: { startUtf16: index, endUtf16: index + 1 },
raw: "a",
explanation: "literal",
children: [],
properties: {
zeroWidth: false,
nullable: false,
minimumLength: 1,
maximumLength: 1,
consumesInput: true,
},
support: {
flavour: "ecmascript",
status: "supported",
notes: [],
},
provenance: {
provider: "fixture",
providerVersion: "1",
source: "parsed",
},
});
const root: NormalizedRegexNode = {
...literal(0),
id: "root",
kind: "pattern",
range: {
startUtf16: 0,
endUtf16: MAXIMUM_STATIC_ANALYSIS_NODES + 10,
},
raw: "a".repeat(MAXIMUM_STATIC_ANALYSIS_NODES + 10),
children: Array.from(
{ length: MAXIMUM_STATIC_ANALYSIS_NODES + 10 },
(_, index) => literal(index),
),
};
const report = analyseStaticRisk({
flavour: "ecmascript",
root,
flags: [],
scanAll: false,
});
expect(report.analysedNodes).toBe(MAXIMUM_STATIC_ANALYSIS_NODES);
expect(report.traversalTruncated).toBe(true);
expect(report.limitations.at(-1)).toContain("Traversal stopped");
});
});

View File

@@ -0,0 +1,573 @@
import type { RegexFlavourId } from "../model/flavour";
import type { NormalizedRegexNode } from "../model/syntax";
import type {
RegexRiskConfidence,
RegexRiskFinding,
RegexRiskRule,
RegexRiskSeverity,
StaticRiskReport,
StaticRiskRequest,
} from "./analysis.types";
export const MAXIMUM_STATIC_ANALYSIS_NODES = 20_000;
export const MAXIMUM_STATIC_RISK_FINDINGS = 100;
const EXCESSIVE_CAPTURE_THRESHOLD = 100;
const EXCESSIVE_NESTING_THRESHOLD = 32;
const MAXIMUM_FIRST_SIGNATURES = 8;
type FirstSignature =
`literal:${string}` | `class:${string}` | "any" | "unknown";
interface NodeRecord {
readonly node: NormalizedRegexNode;
readonly depth: number;
readonly parent?: NormalizedRegexNode;
}
interface NodeSummary {
readonly containsRepeatableQuantifier: boolean;
readonly firstRepeatableQuantifier?: NormalizedRegexNode;
readonly firstBackreference?: NormalizedRegexNode;
readonly firstWildcard?: NormalizedRegexNode;
readonly firstAmbiguousAlternation?: NormalizedRegexNode;
readonly firstSignatures: readonly FirstSignature[];
}
interface FindingDetails {
readonly node: NormalizedRegexNode;
readonly rule: RegexRiskRule;
readonly title: string;
readonly explanation: string;
readonly exampleRisk: string;
readonly confidence: RegexRiskConfidence;
readonly severity: RegexRiskSeverity;
readonly limitations: string;
readonly suggestedInvestigation: string;
}
function isRepeatableQuantifier(node: NormalizedRegexNode): boolean {
return (
node.kind === "quantifier" &&
(node.quantifier?.maximum === null || (node.quantifier?.maximum ?? 0) > 1)
);
}
function isLookaround(node: NormalizedRegexNode): boolean {
return (
node.kind === "lookahead" ||
node.kind === "negative-lookahead" ||
node.kind === "lookbehind" ||
node.kind === "negative-lookbehind"
);
}
function limitedUnique<T>(values: readonly T[], maximum: number): readonly T[] {
return [...new Set(values)].slice(0, maximum);
}
function ownFirstSignature(node: NormalizedRegexNode): FirstSignature[] {
if (node.kind === "dot") return ["any"];
if (node.kind === "literal") {
return [`literal:${node.raw.slice(0, 2)}`];
}
if (node.kind === "escaped-literal") {
if (/^\\[dDsSwWpP]/u.test(node.raw)) {
return [`class:${node.raw.slice(0, 2)}`];
}
return [`literal:${node.raw}`];
}
if (
node.kind === "character-class" ||
node.kind === "character-class-range" ||
node.kind === "unicode-property"
) {
return [`class:${node.raw}`];
}
if (node.kind === "backreference") return ["unknown"];
return [];
}
function signaturesOverlap(
left: readonly FirstSignature[],
right: readonly FirstSignature[],
): boolean {
for (const leftSignature of left) {
for (const rightSignature of right) {
if (leftSignature === "unknown" || rightSignature === "unknown") {
continue;
}
if (leftSignature === "any" || rightSignature === "any") return true;
if (leftSignature === rightSignature) return true;
}
}
return false;
}
function hasOverlappingChildren(
node: NormalizedRegexNode,
summaries: ReadonlyMap<NormalizedRegexNode, NodeSummary>,
): boolean {
const alternativesContainer =
node.kind === "disjunction" ||
node.kind === "capture-group" ||
node.kind === "named-capture-group" ||
node.kind === "noncapture-group" ||
node.kind === "inline-flags" ||
isLookaround(node);
if (!alternativesContainer || node.children.length < 2) return false;
for (let leftIndex = 0; leftIndex < node.children.length; leftIndex += 1) {
const left =
summaries.get(node.children[leftIndex]!)?.firstSignatures ?? [];
for (
let rightIndex = leftIndex + 1;
rightIndex < node.children.length;
rightIndex += 1
) {
const right =
summaries.get(node.children[rightIndex]!)?.firstSignatures ?? [];
if (signaturesOverlap(left, right)) return true;
}
}
return false;
}
function firstSignaturesFor(
node: NormalizedRegexNode,
summaries: ReadonlyMap<NormalizedRegexNode, NodeSummary>,
): readonly FirstSignature[] {
const own = ownFirstSignature(node);
if (own.length > 0) return own;
if (node.children.length === 0) return [];
const isSequence = node.kind === "sequence" || node.kind === "alternative";
if (isSequence) {
const signatures: FirstSignature[] = [];
for (const child of node.children) {
signatures.push(...(summaries.get(child)?.firstSignatures ?? []));
if (child.properties.nullable !== true) break;
}
return limitedUnique(signatures, MAXIMUM_FIRST_SIGNATURES);
}
return limitedUnique(
node.children.flatMap(
(child) => summaries.get(child)?.firstSignatures ?? [],
),
MAXIMUM_FIRST_SIGNATURES,
);
}
function collectRecords(root: NormalizedRegexNode): {
readonly records: readonly NodeRecord[];
readonly truncated: boolean;
} {
const records: NodeRecord[] = [];
const stack: NodeRecord[] = [{ node: root, depth: 0 }];
while (stack.length > 0 && records.length < MAXIMUM_STATIC_ANALYSIS_NODES) {
const record = stack.pop();
if (!record) break;
records.push(record);
for (let index = record.node.children.length - 1; index >= 0; index -= 1) {
const child = record.node.children[index];
if (!child) continue;
stack.push({
node: child,
depth: record.depth + 1,
parent: record.node,
});
}
}
return { records, truncated: stack.length > 0 };
}
function buildSummaries(
records: readonly NodeRecord[],
): ReadonlyMap<NormalizedRegexNode, NodeSummary> {
const summaries = new Map<NormalizedRegexNode, NodeSummary>();
for (let index = records.length - 1; index >= 0; index -= 1) {
const node = records[index]?.node;
if (!node) continue;
const childSummaries = node.children
.map((child) => summaries.get(child))
.filter((summary): summary is NodeSummary => summary !== undefined);
const overlapping = hasOverlappingChildren(node, summaries);
summaries.set(node, {
containsRepeatableQuantifier:
isRepeatableQuantifier(node) ||
childSummaries.some((summary) => summary.containsRepeatableQuantifier),
firstRepeatableQuantifier:
(isRepeatableQuantifier(node) ? node : undefined) ??
childSummaries.find((summary) => summary.firstRepeatableQuantifier)
?.firstRepeatableQuantifier,
firstBackreference:
(node.kind === "backreference" ? node : undefined) ??
childSummaries.find((summary) => summary.firstBackreference)
?.firstBackreference,
firstWildcard:
(node.kind === "dot" ? node : undefined) ??
childSummaries.find((summary) => summary.firstWildcard)?.firstWildcard,
firstAmbiguousAlternation:
(overlapping ? node : undefined) ??
childSummaries.find((summary) => summary.firstAmbiguousAlternation)
?.firstAmbiguousAlternation,
firstSignatures: firstSignaturesFor(node, summaries),
});
}
return summaries;
}
function finding(
flavour: RegexFlavourId,
sequence: number,
details: FindingDetails,
): RegexRiskFinding {
return {
id: `risk-${details.rule}-${details.node.range.startUtf16}-${details.node.range.endUtf16}-${sequence}`,
flavour,
range: details.node.range,
rule: details.rule,
title: details.title,
explanation: details.explanation,
exampleRisk: details.exampleRisk,
confidence: details.confidence,
severity: details.severity,
limitations: details.limitations,
suggestedInvestigation: details.suggestedInvestigation,
evidence: "static",
};
}
function startsAtInput(root: NormalizedRegexNode): boolean {
return root.raw.startsWith("^");
}
function severityOrder(severity: RegexRiskSeverity): number {
if (severity === "high") return 0;
if (severity === "warning") return 1;
return 2;
}
function unsupportedReport(request: StaticRiskRequest): StaticRiskReport {
return {
flavour: request.flavour,
analyser: {
id: "regex-tools-ecmascript-heuristics",
version: "1",
support: "unsupported-flavour",
},
findings: [],
analysedNodes: 0,
findingsTruncated: false,
traversalTruncated: false,
summary: `Static risk analysis is unavailable for ${request.flavour}.`,
limitations: [
"The current analyser is scoped only to the normalized ECMAScript 2025 grammar.",
"No ECMAScript heuristic is applied to another flavour.",
],
};
}
export function analyseStaticRisk(
request: StaticRiskRequest,
): StaticRiskReport {
if (
request.flavour !== "ecmascript" ||
request.root.support.flavour !== "ecmascript"
) {
return unsupportedReport(request);
}
const { records, truncated: traversalTruncated } = collectRecords(
request.root,
);
const summaries = buildSummaries(records);
const details: FindingDetails[] = [];
let captureCount = 0;
let deepest = records[0];
const expensivePrefixCandidates: NormalizedRegexNode[] = [];
for (const record of records) {
const { node, depth } = record;
if (node.capture) captureCount += 1;
if (!deepest || depth > deepest.depth) deepest = record;
const summary = summaries.get(node);
if (!summary) continue;
if (isRepeatableQuantifier(node)) {
const body = node.children[0];
const bodySummary = body ? summaries.get(body) : undefined;
const innerQuantifier =
bodySummary?.firstRepeatableQuantifier === node
? undefined
: bodySummary?.firstRepeatableQuantifier;
if (innerQuantifier) {
details.push({
node,
rule: "nested-quantifier",
title: "Potential nested-quantifier risk",
explanation:
"A repeated expression contains another repeatable expression. Some failing inputs can make a backtracking engine revisit the same partitions.",
exampleRisk:
"Long near-misses may take disproportionately longer than matching inputs.",
confidence: "high",
severity: "high",
limitations:
"This structural heuristic does not model engine optimizations, atomicity or every surrounding constraint.",
suggestedInvestigation:
"Run bounded growth analysis with a representative near-miss and consider removing one repetition layer.",
});
expensivePrefixCandidates.push(node);
}
if (body?.properties.nullable === true) {
details.push({
node,
rule: "nullable-repeated-expression",
title: "Potential nullable-repetition risk",
explanation:
"The repeated body can match an empty string, so many repetition paths can describe the same input position.",
exampleRisk:
"Backtracking work or zero-length iteration can grow without consuming input.",
confidence: "high",
severity: "high",
limitations:
"Native engines can simplify some nullable repetitions; this analyser does not inspect the compiled program.",
suggestedInvestigation:
"Require the body to consume input, or test the exact pattern under strict timeout limits.",
});
expensivePrefixCandidates.push(node);
}
if (bodySummary?.firstAmbiguousAlternation) {
details.push({
node,
rule: "ambiguous-repeated-alternatives",
title: "Potential ambiguous repeated alternatives",
explanation:
"Alternatives under this repetition can begin with overlapping input, leaving multiple possible partitions.",
exampleRisk:
"A long suffix failure can force the engine to reconsider many alternative boundaries.",
confidence: "medium",
severity: "high",
limitations:
"Overlap is approximated from first consuming constructs; later tokens and engine optimizations are not proven.",
suggestedInvestigation:
"Make alternatives prefix-distinct, factor shared prefixes, and probe representative near-misses.",
});
expensivePrefixCandidates.push(node);
}
if (bodySummary?.firstWildcard) {
details.push({
node: bodySummary.firstWildcard,
rule: "repeated-wildcard",
title: "Potential repeated-wildcard risk",
explanation:
"A wildcard occurs inside a repeated region and may consume the same text using multiple boundaries.",
exampleRisk:
"Later required text can trigger broad backtracking across the wildcard.",
confidence: "medium",
severity: "warning",
limitations:
"Anchors, following literals and engine search optimizations can substantially change the observed cost.",
suggestedInvestigation:
"Use a more specific character class or a bounded repetition, then compare bounded growth results.",
});
expensivePrefixCandidates.push(node);
}
if (bodySummary?.firstBackreference) {
details.push({
node: bodySummary.firstBackreference,
rule: "backreference-in-repetition",
title: "Potential repeated-backreference risk",
explanation:
"A backreference is evaluated inside repetition, coupling later work to previously captured text.",
exampleRisk:
"Multiple capture and repetition choices can amplify failing-input work.",
confidence: "medium",
severity: "warning",
limitations:
"The analyser does not resolve every capture path or prove that competing paths are reachable.",
suggestedInvestigation:
"Test realistic capture lengths and near-misses with the bounded growth runner.",
});
expensivePrefixCandidates.push(node);
}
}
if (
node.children.length > 1 &&
summary.firstAmbiguousAlternation === node
) {
details.push({
node,
rule: "overlapping-alternatives",
title: "Possibly overlapping alternatives",
explanation:
"At least two alternatives can begin with the same approximated input class.",
exampleRisk:
"The engine may need to try more than one branch when later tokens fail.",
confidence: "low",
severity: "notice",
limitations:
"Only first-token overlap is checked. This is neither an equivalence test nor proof that costly backtracking occurs.",
suggestedInvestigation:
"Review whether the alternatives can be made prefix-distinct or whether their shared prefix can be factored out.",
});
}
if (isLookaround(node) && summary.containsRepeatableQuantifier) {
details.push({
node,
rule: "repeated-lookaround-body",
title: "Potential expensive lookaround",
explanation:
"This lookaround evaluates a body containing repetition without consuming input at the assertion site.",
exampleRisk:
"An unanchored search may re-evaluate the repeated assertion at many candidate positions.",
confidence: "medium",
severity: "warning",
limitations:
"The analyser does not know which assertion results the runtime caches or optimizes.",
suggestedInvestigation:
"Anchor or narrow the assertion where possible and test a representative non-match.",
});
expensivePrefixCandidates.push(node);
}
}
if (captureCount > EXCESSIVE_CAPTURE_THRESHOLD) {
details.push({
node: request.root,
rule: "excessive-captures",
title: "Large capture set",
explanation: `The pattern contains ${captureCount.toLocaleString()} capturing groups, above this analysers ${EXCESSIVE_CAPTURE_THRESHOLD.toLocaleString()}-group review threshold.`,
exampleRisk:
"Capturing can increase result materialization, offset conversion and replacement work.",
confidence: "high",
severity: "notice",
limitations:
"The threshold is an application review limit, not an engine vulnerability boundary.",
suggestedInvestigation:
"Convert groups that are not consumed by results or backreferences to non-capturing groups.",
});
}
if (deepest && deepest.depth > EXCESSIVE_NESTING_THRESHOLD) {
details.push({
node: deepest.node,
rule: "excessive-nesting",
title: "Deeply nested pattern",
explanation: `The normalized tree reaches depth ${deepest.depth.toLocaleString()}, above this analysers ${EXCESSIVE_NESTING_THRESHOLD.toLocaleString()}-level review threshold.`,
exampleRisk:
"Deep nesting can increase compiler, stack or explanation work even when matching remains quick.",
confidence: "high",
severity: "notice",
limitations:
"Normalized AST depth is not identical to the native engines internal program depth.",
suggestedInvestigation:
"Simplify unnecessary grouping and retain strict compile and execution limits.",
});
}
if (!startsAtInput(request.root) && expensivePrefixCandidates[0]) {
details.push({
node: expensivePrefixCandidates[0],
rule: "unanchored-expensive-prefix",
title: "Potential unanchored search amplification",
explanation:
"The pattern is not start-anchored and contains a structurally expensive region, so the engine may retry it at many subject positions.",
exampleRisk:
"A long non-match can multiply per-position backtracking by the subject length.",
confidence: request.flags.includes("y") ? "low" : "medium",
severity: "warning",
limitations:
"Sticky execution, runtime prefix scans and calling-code start offsets can avoid retries that are possible from the pattern alone.",
suggestedInvestigation:
"Use an appropriate start anchor or sticky execution when the intended match must begin at a known position.",
});
}
const replacement = request.replacement ?? "";
const repeatedExecution = request.scanAll || request.flags.includes("g");
if (
replacement.length > 0 &&
repeatedExecution &&
(replacement.includes("$`") ||
replacement.includes("$'") ||
replacement.length > 4_096)
) {
details.push({
node: request.root,
rule: "potential-replacement-expansion",
title: "Potentially large replacement output",
explanation:
replacement.includes("$`") || replacement.includes("$'")
? "The replacement refers to an entire subject prefix or suffix and can do so for every selected match."
: "A long replacement template can be emitted once for every selected match.",
exampleRisk:
"Output size and replacement time can grow much faster than the input preview suggests.",
confidence: "medium",
severity: "warning",
limitations:
"The finding uses pattern and template structure; it does not predict the actual match count for every subject.",
suggestedInvestigation:
"Use bounded replacement previews and test output size on representative maximum inputs.",
});
}
const seen = new Set<string>();
const unique = details.filter((item) => {
const key = `${item.rule}:${item.node.range.startUtf16}:${item.node.range.endUtf16}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
unique.sort(
(left, right) =>
severityOrder(left.severity) - severityOrder(right.severity) ||
left.node.range.startUtf16 - right.node.range.startUtf16 ||
left.rule.localeCompare(right.rule),
);
const findingsTruncated = unique.length > MAXIMUM_STATIC_RISK_FINDINGS;
const findings = unique
.slice(0, MAXIMUM_STATIC_RISK_FINDINGS)
.map((item, index) => finding(request.flavour, index + 1, item))
.sort(
(left, right) =>
severityOrder(left.severity) - severityOrder(right.severity) ||
left.range.startUtf16 - right.range.startUtf16 ||
left.rule.localeCompare(right.rule),
);
return {
flavour: request.flavour,
analyser: {
id: "regex-tools-ecmascript-heuristics",
version: "1",
support: "experimental-ecmascript-2025",
},
findings,
analysedNodes: records.length,
findingsTruncated,
traversalTruncated,
summary:
findings.length === 0
? "No issue found by this analyser. This is not a safety guarantee."
: `${findings.length.toLocaleString()} potential ${
findings.length === 1 ? "risk" : "risks"
} found by ECMAScript-specific structural heuristics.`,
limitations: [
"Findings are advisory and do not model the native engines complete compiled program or optimizations.",
"Absence of a finding does not mean that a pattern is safe or linear.",
"Dynamic probes characterize only the generated inputs and limits selected by the user.",
...(traversalTruncated
? [
`Traversal stopped at ${MAXIMUM_STATIC_ANALYSIS_NODES.toLocaleString()} normalized nodes.`,
]
: []),
...(findingsTruncated
? [
`Findings stopped at ${MAXIMUM_STATIC_RISK_FINDINGS.toLocaleString()} entries.`,
]
: []),
],
};
}

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { metricStatistics, summarizeBenchmarkSamples } from "./statistics";
import type { BenchmarkWorkerSample } from "./analysis.types";
function sample(
compileMs: number,
overrides: Partial<BenchmarkWorkerSample> = {},
): BenchmarkWorkerSample {
return {
accepted: true,
effectiveFlags: "g",
subjectBytes: 10,
subjectUtf16: 10,
compileMs,
firstMatchMs: compileMs + 1,
allMatchesMs: compileMs + 2,
replacementMs: compileMs + 3,
throughputBytesPerSecond: 100 / (compileMs + 1),
matchCount: 1,
matched: true,
matchCollectionTruncated: false,
replacementOutputUtf16: 10,
...overrides,
};
}
describe("analysis statistics", () => {
it("computes a conventional median and nearest-rank p95", () => {
expect(metricStatistics([4, 1, 3, 2])).toEqual({
count: 4,
minimum: 1,
median: 2.5,
p95: 4,
maximum: 4,
});
expect(
metricStatistics(Array.from({ length: 100 }, (_, index) => index + 1))
?.p95,
).toBe(95);
});
it("ignores absent, negative and non-finite measurements", () => {
expect(
metricStatistics([
undefined,
Number.NaN,
-1,
0,
Number.POSITIVE_INFINITY,
]),
).toEqual({
count: 1,
minimum: 0,
median: 0,
p95: 0,
maximum: 0,
});
expect(metricStatistics([])).toBeUndefined();
});
it("summarizes only measurements actually produced by the worker", () => {
const summary = summarizeBenchmarkSamples([
sample(1),
sample(3, {
replacementMs: undefined,
replacementSkippedReason: "bounded output estimate",
}),
]);
expect(summary.compileMs?.median).toBe(2);
expect(summary.replacementMs).toEqual(
expect.objectContaining({ count: 1, median: 4 }),
);
});
});

View File

@@ -0,0 +1,61 @@
import type {
BenchmarkMetricSummary,
BenchmarkWorkerSample,
MetricStatistics,
} from "./analysis.types";
function finite(values: readonly (number | undefined)[]): number[] {
return values.filter(
(value): value is number =>
value !== undefined && Number.isFinite(value) && value >= 0,
);
}
export function metricStatistics(
values: readonly (number | undefined)[],
): MetricStatistics | undefined {
const ordered = finite(values).sort((left, right) => left - right);
if (ordered.length === 0) return undefined;
const middle = Math.floor(ordered.length / 2);
const median =
ordered.length % 2 === 0
? ((ordered[middle - 1] ?? 0) + (ordered[middle] ?? 0)) / 2
: (ordered[middle] ?? 0);
const p95Index = Math.max(0, Math.ceil(ordered.length * 0.95) - 1);
return {
count: ordered.length,
minimum: ordered[0] ?? 0,
median,
p95: ordered[p95Index] ?? ordered.at(-1) ?? 0,
maximum: ordered.at(-1) ?? 0,
};
}
export function summarizeBenchmarkSamples(
samples: readonly BenchmarkWorkerSample[],
): BenchmarkMetricSummary {
const metric = (
select: (sample: BenchmarkWorkerSample) => number | undefined,
) => metricStatistics(samples.map(select));
return {
...(metric((sample) => sample.compileMs)
? { compileMs: metric((sample) => sample.compileMs) }
: {}),
...(metric((sample) => sample.firstMatchMs)
? { firstMatchMs: metric((sample) => sample.firstMatchMs) }
: {}),
...(metric((sample) => sample.allMatchesMs)
? { allMatchesMs: metric((sample) => sample.allMatchesMs) }
: {}),
...(metric((sample) => sample.replacementMs)
? { replacementMs: metric((sample) => sample.replacementMs) }
: {}),
...(metric((sample) => sample.throughputBytesPerSecond)
? {
throughputBytesPerSecond: metric(
(sample) => sample.throughputBytesPerSecond,
),
}
: {}),
};
}

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import { generatePcre2C, type Pcre2CGenerationRequest } from "./pcre2-c";
function request(
values: Partial<Pcre2CGenerationRequest> = {},
): Pcre2CGenerationRequest {
return {
flavour: "pcre2",
flavourVersion: "PCRE2 10.47 8-bit WebAssembly",
operation: "match",
pattern: "(?<value>.+)",
flags: ["g", "i", "m", "s", "x", "U", "J"],
options: {
matchLimit: 1_000_000,
depthLimit: 1_000,
heapLimitKib: 32_768,
},
subject: "value",
scanAll: true,
maximumMatches: 1_000,
maximumCaptureRows: 10_000,
maximumOutputBytes: 1024 * 1024,
...values,
};
}
describe("PCRE2 10.47 C generator", () => {
it("emits exact UTF-8 byte arrays instead of interpolating host literals", () => {
const pattern = '(?<x>["\\\\${}]+\\R)';
const subject = "“quote” \\\nGrüße ${value}\0";
const replacement = '${x} "$" \\\\';
const generated = generatePcre2C(
request({
operation: "replace",
pattern,
subject,
replacement,
}),
);
expect(generated).toMatchObject({
target: "c17-pcre2-8",
fileName: "regex-tools-pcre2-replace.c",
engineIdentity: "PCRE2 10.47 8-bit",
represents: "replacement",
});
expect(generated.source).toContain(
'#error "This generated program requires PCRE2 10.47 exactly."',
);
expect(generated.source).toContain(
"PCRE2_UTF | PCRE2_UCP | PCRE2_CASELESS | PCRE2_MULTILINE | PCRE2_DOTALL | PCRE2_EXTENDED | PCRE2_UNGREEDY | PCRE2_DUPNAMES",
);
expect(generated.source).toContain(
"static const PCRE2_UCHAR replacement[] = {",
);
expect(generated.source).toContain(
"0x47, 0x72, 0xc3, 0xbc, 0xc3, 0x9f, 0x65",
);
expect(generated.source).not.toContain(pattern);
expect(generated.source).not.toContain(subject);
expect(generated.source).not.toContain(replacement);
expect(generated.caveats.join(" ")).toMatch(
/checked only after substitution/u,
);
expect(generated.caveats.join(" ")).toMatch(/wall-clock timeout/u);
});
it("states and implements application-level all-match iteration", () => {
const generated = generatePcre2C(
request({
flags: [],
scanAll: true,
}),
);
expect(generated.represents).toBe("all-matches");
expect(generated.source).toContain("static PCRE2_SIZE advance_utf8");
expect(generated.source).toContain(
"PCRE2_NOTEMPTY_ATSTART | PCRE2_ANCHORED",
);
expect(generated.source).toContain(
"Regex Tools host-side match/capture result cap reached.",
);
expect(generated.source).toContain(
"pcre2_set_match_limit(context, match_limit)",
);
expect(generated.source).toContain(
"pcre2_set_depth_limit(context, depth_limit)",
);
expect(generated.source).toContain(
"pcre2_set_heap_limit(context, heap_limit_kib)",
);
});
it("generates a first-match program only when neither g nor scan-all is selected", () => {
const generated = generatePcre2C(request({ flags: ["i"], scanAll: false }));
expect(generated.represents).toBe("first-match");
expect(generated.source).toContain("if (!false)");
expect(generated.caveats).toContain(
"The program requests only the first match.",
);
});
it("fails closed on unsupported mappings and lossy browser Unicode", () => {
expect(() => generatePcre2C(request({ flags: ["u"] }))).toThrow(
/unsupported/u,
);
expect(() => generatePcre2C(request({ pattern: "\ud800" }))).toThrow(
/unpaired UTF-16 surrogate/u,
);
expect(() =>
generatePcre2C(request({ operation: "replace", replacement: undefined })),
).toThrow(/requires an exact replacement/u);
});
});

View File

@@ -0,0 +1,591 @@
import {
AVAILABLE_REGEX_FLAVOURS,
parseRegexFlags,
parseRegexOptions,
resolveRegexFlavourVersion,
} from "../flavours/flavour-registry";
import type { RegexEngineOptions } from "../model/flavour";
import {
DEFAULT_REGEX_LIMITS,
utf8ByteLength,
} from "../execution/request-limits";
export type Pcre2COperation = "match" | "replace";
export interface Pcre2CGenerationRequest {
readonly flavour: "pcre2";
readonly flavourVersion: string;
readonly operation: Pcre2COperation;
readonly pattern: string;
readonly flags: readonly string[];
readonly options: RegexEngineOptions;
readonly subject: string;
readonly scanAll: boolean;
readonly replacement?: string;
readonly maximumMatches: number;
readonly maximumCaptureRows: number;
readonly maximumOutputBytes: number;
}
export interface GeneratedPcre2CProgram {
readonly target: "c17-pcre2-8";
readonly fileName: string;
readonly source: string;
readonly compileCommand: string;
readonly engineIdentity: string;
readonly represents: "first-match" | "all-matches" | "replacement";
readonly caveats: readonly string[];
}
const TEXT_ENCODER = new TextEncoder();
const MAXIMUM_GENERATED_TEXT_BYTES = 1024 * 1024;
const PCRE2_VERSION = "PCRE2 10.47 8-bit";
function integer(
value: number,
label: string,
minimum: number,
maximum: number,
): number {
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
throw new RangeError(
`${label} must be an integer from ${minimum.toLocaleString()} to ${maximum.toLocaleString()}.`,
);
}
return value;
}
function assertUnicodeScalarText(value: string, label: string): void {
for (let index = 0; index < value.length; index += 1) {
const first = value.charCodeAt(index);
if (first >= 0xd800 && first <= 0xdbff) {
const second = value.charCodeAt(index + 1);
if (second >= 0xdc00 && second <= 0xdfff) {
index += 1;
continue;
}
throw new Error(
`${label} contains an unpaired UTF-16 surrogate and cannot be emitted as exact UTF-8.`,
);
}
if (first >= 0xdc00 && first <= 0xdfff) {
throw new Error(
`${label} contains an unpaired UTF-16 surrogate and cannot be emitted as exact UTF-8.`,
);
}
}
}
function byteArray(name: string, value: string): string {
const bytes = [...TEXT_ENCODER.encode(value), 0];
const rows: string[] = [];
for (let index = 0; index < bytes.length; index += 12) {
rows.push(
` ${bytes
.slice(index, index + 12)
.map((byte) => `0x${byte.toString(16).padStart(2, "0")}`)
.join(", ")},`,
);
}
return [
`static const PCRE2_UCHAR ${name}[] = {`,
...rows,
"};",
`static const PCRE2_SIZE ${name}_length = sizeof(${name}) - 1;`,
].join("\n");
}
function compileOptions(flags: readonly string[]): string {
const options = [
"PCRE2_UTF",
"PCRE2_UCP",
...(flags.includes("i") ? ["PCRE2_CASELESS"] : []),
...(flags.includes("m") ? ["PCRE2_MULTILINE"] : []),
...(flags.includes("s") ? ["PCRE2_DOTALL"] : []),
...(flags.includes("x") ? ["PCRE2_EXTENDED"] : []),
...(flags.includes("U") ? ["PCRE2_UNGREEDY"] : []),
...(flags.includes("J") ? ["PCRE2_DUPNAMES"] : []),
];
return options.join(" | ");
}
function commonSource(
request: Pcre2CGenerationRequest,
options: Readonly<{
matchLimit: number;
depthLimit: number;
heapLimitKib: number;
}>,
): string {
return `/*
* Generated by add·ideas Regex Tools for the PCRE2 10.47 8-bit C API.
* Pattern, subject and replacement are independent exact UTF-8 byte arrays.
* PCRE2_UTF and PCRE2_UCP are always enabled to match the browser runtime.
*
* Build:
* cc -std=c17 -Wall -Wextra -Werror regex-tools-pcre2.c \\
* $(pkg-config --cflags --libs libpcre2-8) -o regex-tools-pcre2
*
* There is no native PCRE2 wall-clock timeout. Run this program in a process
* that your application can terminate if a hard elapsed-time limit is needed.
*/
#define PCRE2_CODE_UNIT_WIDTH 8
#include <pcre2.h>
#include <inttypes.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#if PCRE2_MAJOR != 10 || PCRE2_MINOR != 47
#error "This generated program requires PCRE2 10.47 exactly."
#endif
${byteArray("pattern", request.pattern)}
${byteArray("subject", request.subject)}
static const uint32_t compile_options = ${compileOptions(request.flags)};
static const uint32_t match_limit = ${options.matchLimit}u;
static const uint32_t depth_limit = ${options.depthLimit}u;
static const uint32_t heap_limit_kib = ${options.heapLimitKib}u;
static const size_t maximum_matches = ${request.maximumMatches}u;
static const size_t maximum_capture_rows = ${request.maximumCaptureRows}u;
static void print_pcre2_error(const char *phase, int code) {
PCRE2_UCHAR message[256];
int length = pcre2_get_error_message(code, message, sizeof(message));
if (length < 0) {
fprintf(stderr, "%s failed with PCRE2 error %d.\\n", phase, code);
return;
}
fprintf(stderr, "%s failed: %.*s (PCRE2 error %d).\\n",
phase, length, (const char *)message, code);
}
static bool verify_runtime_version(void) {
char version[64] = {0};
uint32_t unicode = 0;
int status = pcre2_config(PCRE2_CONFIG_VERSION, version);
if (status < 0) {
print_pcre2_error("pcre2_config(PCRE2_CONFIG_VERSION)", status);
return false;
}
if (strncmp(version, "10.47 ", 6) != 0) {
fprintf(stderr, "Expected PCRE2 10.47, loaded %s.\\n", version);
return false;
}
status = pcre2_config(PCRE2_CONFIG_UNICODE, &unicode);
if (status != 0 || unicode != 1) {
fputs("The loaded PCRE2 10.47 library has no Unicode support.\\n", stderr);
return false;
}
return true;
}
static bool configure_limits(pcre2_match_context *context) {
int status = pcre2_set_match_limit(context, match_limit);
if (status != 0) {
print_pcre2_error("pcre2_set_match_limit", status);
return false;
}
status = pcre2_set_depth_limit(context, depth_limit);
if (status != 0) {
print_pcre2_error("pcre2_set_depth_limit", status);
return false;
}
status = pcre2_set_heap_limit(context, heap_limit_kib);
if (status != 0) {
print_pcre2_error("pcre2_set_heap_limit", status);
return false;
}
return true;
}`;
}
function matchSource(
request: Pcre2CGenerationRequest,
global: boolean,
): string {
return `${commonSource(request, {
matchLimit: Number(request.options.matchLimit),
depthLimit: Number(request.options.depthLimit),
heapLimitKib: Number(request.options.heapLimitKib),
})}
/*
* PCRE2's "global" flag is an application loop, not a compile option.
* This generated loop implements the empty-match anchored retry described by
* the PCRE2 API and advances by one UTF-8 character only after that retry
* reports no match.
*/
static PCRE2_SIZE advance_utf8(PCRE2_SIZE offset, bool crlf_is_newline) {
if (offset >= subject_length) {
return subject_length + 1;
}
if (crlf_is_newline && subject[offset] == 0x0d &&
offset + 1 < subject_length &&
subject[offset + 1] == 0x0a) {
return offset + 2;
}
PCRE2_SIZE width =
(subject[offset] & 0x80u) == 0 ? 1 :
(subject[offset] & 0xe0u) == 0xc0u ? 2 :
(subject[offset] & 0xf0u) == 0xe0u ? 3 : 4;
return offset + width <= subject_length ? offset + width : subject_length + 1;
}
static const char *capture_name(pcre2_code *code, uint32_t group) {
uint32_t name_count = 0;
uint32_t entry_size = 0;
PCRE2_SPTR table = NULL;
if (pcre2_pattern_info(code, PCRE2_INFO_NAMECOUNT, &name_count) != 0 ||
name_count == 0 ||
pcre2_pattern_info(code, PCRE2_INFO_NAMEENTRYSIZE, &entry_size) != 0 ||
pcre2_pattern_info(code, PCRE2_INFO_NAMETABLE, &table) != 0) {
return NULL;
}
for (uint32_t index = 0; index < name_count; index += 1) {
PCRE2_SPTR entry = table + index * entry_size;
uint32_t number = ((uint32_t)entry[0] << 8) | entry[1];
if (number == group) {
return (const char *)(entry + 2);
}
}
return NULL;
}
int main(void) {
int exit_code = 1;
int error_code = 0;
PCRE2_SIZE error_offset = 0;
pcre2_code *code = NULL;
pcre2_match_data *match_data = NULL;
pcre2_match_context *match_context = NULL;
size_t match_count = 0;
size_t capture_rows = 0;
PCRE2_SIZE search_offset = 0;
uint32_t match_options = 0;
bool crlf_is_newline = false;
if (!verify_runtime_version()) {
return 1;
}
code = pcre2_compile(pattern, pattern_length, compile_options,
&error_code, &error_offset, NULL);
if (code == NULL) {
fprintf(stderr, "Compile error at pattern UTF-8 byte %" PRIuPTR ": ",
(uintptr_t)error_offset);
print_pcre2_error("pcre2_compile", error_code);
goto cleanup;
}
match_data = pcre2_match_data_create_from_pattern(code, NULL);
match_context = pcre2_match_context_create(NULL);
if (match_data == NULL || match_context == NULL) {
fputs("Could not allocate PCRE2 match state.\\n", stderr);
goto cleanup;
}
if (!configure_limits(match_context)) {
goto cleanup;
}
uint32_t newline = 0;
if (pcre2_pattern_info(code, PCRE2_INFO_NEWLINE, &newline) != 0) {
fputs("Could not read the PCRE2 newline convention.\\n", stderr);
goto cleanup;
}
crlf_is_newline =
newline == PCRE2_NEWLINE_CRLF ||
newline == PCRE2_NEWLINE_ANY ||
newline == PCRE2_NEWLINE_ANYCRLF;
while (search_offset <= subject_length) {
int status = pcre2_match(code, subject, subject_length, search_offset,
match_options, match_data, match_context);
if (status == PCRE2_ERROR_NOMATCH && match_options != 0) {
search_offset = advance_utf8(search_offset, crlf_is_newline);
match_options = 0;
continue;
}
if (status == PCRE2_ERROR_NOMATCH) {
break;
}
if (status < 0) {
print_pcre2_error("pcre2_match", status);
goto cleanup;
}
if (status == 0) {
fputs("The match vector was unexpectedly too small.\\n", stderr);
goto cleanup;
}
uint32_t capture_count = 0;
if (pcre2_pattern_info(code, PCRE2_INFO_CAPTURECOUNT, &capture_count) != 0) {
fputs("Could not read the PCRE2 capture count.\\n", stderr);
goto cleanup;
}
size_t required_rows = capture_count == 0 ? 1u : capture_count;
if (match_count >= maximum_matches ||
required_rows > maximum_capture_rows - capture_rows) {
fputs("Regex Tools host-side match/capture result cap reached.\\n", stderr);
exit_code = 3;
goto cleanup;
}
PCRE2_SIZE *ovector = pcre2_get_ovector_pointer(match_data);
printf("match %zu: UTF-8 bytes %" PRIuPTR "..%" PRIuPTR "\\n",
match_count + 1, (uintptr_t)ovector[0], (uintptr_t)ovector[1]);
for (uint32_t group = 0; group <= capture_count; group += 1) {
PCRE2_SIZE start = ovector[group * 2];
PCRE2_SIZE end = ovector[group * 2 + 1];
const char *name = capture_name(code, group);
if (start == PCRE2_UNSET || end == PCRE2_UNSET) {
printf(" group %u%s%s: did not participate\\n",
group, name == NULL ? "" : " / ", name == NULL ? "" : name);
continue;
}
printf(" group %u%s%s: UTF-8 bytes %" PRIuPTR "..%" PRIuPTR " = ",
group, name == NULL ? "" : " / ", name == NULL ? "" : name,
(uintptr_t)start, (uintptr_t)end);
(void)fwrite(subject + start, 1, (size_t)(end - start), stdout);
fputc('\\n', stdout);
}
match_count += 1;
capture_rows += required_rows;
if (!${global ? "true" : "false"}) {
break;
}
search_offset = ovector[1];
match_options =
ovector[0] == ovector[1] ? PCRE2_NOTEMPTY_ATSTART | PCRE2_ANCHORED : 0;
}
printf("completed: %zu match(es), %zu capture row(s)\\n",
match_count, capture_rows);
exit_code = 0;
cleanup:
pcre2_match_context_free(match_context);
pcre2_match_data_free(match_data);
pcre2_code_free(code);
return exit_code;
}
`;
}
function replacementSource(
request: Pcre2CGenerationRequest,
global: boolean,
): string {
const replacement = request.replacement;
if (replacement === undefined) {
throw new Error("PCRE2 replacement generation requires a replacement.");
}
return `${commonSource(request, {
matchLimit: Number(request.options.matchLimit),
depthLimit: Number(request.options.depthLimit),
heapLimitKib: Number(request.options.heapLimitKib),
})}
${byteArray("replacement", replacement)}
static const size_t maximum_output_bytes = ${request.maximumOutputBytes}u;
int main(void) {
int exit_code = 1;
int error_code = 0;
PCRE2_SIZE error_offset = 0;
pcre2_code *code = NULL;
pcre2_match_data *match_data = NULL;
pcre2_match_context *match_context = NULL;
PCRE2_UCHAR *output = NULL;
if (!verify_runtime_version()) {
return 1;
}
code = pcre2_compile(pattern, pattern_length, compile_options,
&error_code, &error_offset, NULL);
if (code == NULL) {
fprintf(stderr, "Compile error at pattern UTF-8 byte %" PRIuPTR ": ",
(uintptr_t)error_offset);
print_pcre2_error("pcre2_compile", error_code);
goto cleanup;
}
match_data = pcre2_match_data_create_from_pattern(code, NULL);
match_context = pcre2_match_context_create(NULL);
output = malloc(maximum_output_bytes + 1);
if (match_data == NULL || match_context == NULL || output == NULL) {
fputs("Could not allocate PCRE2 replacement state.\\n", stderr);
goto cleanup;
}
if (!configure_limits(match_context)) {
goto cleanup;
}
PCRE2_SIZE output_length = maximum_output_bytes;
uint32_t substitute_options =
PCRE2_SUBSTITUTE_UNSET_EMPTY${global ? " | PCRE2_SUBSTITUTE_GLOBAL" : ""};
int substitutions = pcre2_substitute(
code, subject, subject_length, 0, substitute_options,
match_data, match_context, replacement, replacement_length,
output, &output_length);
if (substitutions == PCRE2_ERROR_NOMEMORY) {
fprintf(stderr,
"Replacement exceeds the %zu-byte Regex Tools output cap.\\n",
maximum_output_bytes);
exit_code = 3;
goto cleanup;
}
if (substitutions < 0) {
print_pcre2_error("pcre2_substitute", substitutions);
goto cleanup;
}
uint32_t capture_count = 0;
if (pcre2_pattern_info(code, PCRE2_INFO_CAPTURECOUNT, &capture_count) != 0) {
fputs("Could not read the PCRE2 capture count.\\n", stderr);
goto cleanup;
}
size_t rows_per_match = capture_count == 0 ? 1u : capture_count;
if ((size_t)substitutions > maximum_matches ||
((size_t)substitutions != 0 &&
rows_per_match > maximum_capture_rows / (size_t)substitutions)) {
fputs("Replacement completed but exceeded the Regex Tools result cap; "
"output is withheld.\\n", stderr);
exit_code = 3;
goto cleanup;
}
(void)fwrite(output, 1, (size_t)output_length, stdout);
fprintf(stderr, "\\ncompleted: %d substitution(s), %zu output bytes\\n",
substitutions, (size_t)output_length);
exit_code = 0;
cleanup:
free(output);
pcre2_match_context_free(match_context);
pcre2_match_data_free(match_data);
pcre2_code_free(code);
return exit_code;
}
`;
}
function validate(request: Pcre2CGenerationRequest): Pcre2CGenerationRequest {
if (request.flavour !== "pcre2") {
throw new Error("The PCRE2 C generator accepts only PCRE2 snapshots.");
}
const definition = AVAILABLE_REGEX_FLAVOURS.require("pcre2");
const version = resolveRegexFlavourVersion(
request.flavourVersion,
definition,
"PCRE2 code-generation version",
).definition;
if (version.value !== "PCRE2 10.47 8-bit WebAssembly") {
throw new Error("The C generator is pinned to PCRE2 10.47 8-bit.");
}
const flags = parseRegexFlags(
request.flags,
definition,
"PCRE2 code-generation flags",
);
const options = parseRegexOptions(
request.options,
definition,
"PCRE2 code-generation options",
);
assertUnicodeScalarText(request.pattern, "Pattern");
assertUnicodeScalarText(request.subject, "Subject");
if (request.replacement !== undefined) {
assertUnicodeScalarText(request.replacement, "Replacement");
}
if (utf8ByteLength(request.pattern) > 1024 * 1024) {
throw new RangeError("PCRE2 generated patterns are limited to 1 MiB.");
}
if (utf8ByteLength(request.subject) > MAXIMUM_GENERATED_TEXT_BYTES) {
throw new RangeError(
"Generated C embeds at most 1 MiB of subject text; reduce the fixture without changing the pattern.",
);
}
if (
request.replacement !== undefined &&
utf8ByteLength(request.replacement) > 256 * 1024
) {
throw new RangeError(
"PCRE2 generated replacements are limited to 256 KiB.",
);
}
if (request.operation !== "match" && request.operation !== "replace") {
throw new Error("Unsupported PCRE2 C generation operation.");
}
if (request.operation === "replace" && request.replacement === undefined) {
throw new Error("Replacement generation requires an exact replacement.");
}
return {
...request,
flavourVersion: version.value,
flags,
options,
scanAll: request.scanAll === true,
maximumMatches: integer(
request.maximumMatches,
"Generated match limit",
1,
DEFAULT_REGEX_LIMITS.maximumMatches,
),
maximumCaptureRows: integer(
request.maximumCaptureRows,
"Generated capture-row limit",
1,
DEFAULT_REGEX_LIMITS.maximumCaptureRows,
),
maximumOutputBytes: integer(
request.maximumOutputBytes,
"Generated output limit",
1,
DEFAULT_REGEX_LIMITS.maximumReplacementOutputBytes,
),
};
}
export function generatePcre2C(
input: Pcre2CGenerationRequest,
): GeneratedPcre2CProgram {
const request = validate(input);
const global = request.scanAll || request.flags.includes("g");
const represents =
request.operation === "replace"
? "replacement"
: global
? "all-matches"
: "first-match";
const source =
request.operation === "replace"
? replacementSource(request, global)
: matchSource(request, global);
return {
target: "c17-pcre2-8",
fileName:
request.operation === "replace"
? "regex-tools-pcre2-replace.c"
: "regex-tools-pcre2-match.c",
source,
compileCommand:
"cc -std=c17 -Wall -Wextra -Werror regex-tools-pcre2.c $(pkg-config --cflags --libs libpcre2-8) -o regex-tools-pcre2",
engineIdentity: PCRE2_VERSION,
represents,
caveats: [
"PCRE2_UTF and PCRE2_UCP are mandatory, matching the Regex Tools WebAssembly runtime.",
global
? "The selected g/scan-all behavior is implemented by host iteration (or PCRE2_SUBSTITUTE_GLOBAL), not by a PCRE2 compile flag."
: "The program requests only the first match.",
request.operation === "replace"
? "The output byte cap is a host buffer; PCRE2 has no Regex Tools result-row cap, so replacement row limits are checked only after substitution."
: "Match and capture-row caps are host-loop limits, not native PCRE2 options.",
"PCRE2 match, depth and heap limits are configured; PCRE2 exposes no native wall-clock timeout, so hard cancellation belongs to the containing process.",
"Native ranges printed by the C API are UTF-8 byte offsets; browser editor ranges are normalized separately to UTF-16.",
],
};
}

View File

@@ -0,0 +1,335 @@
import { describe, expect, it, vi } from "vitest";
import type { RegexEngineInfo } from "../model/flavour";
import type {
RegexExecutionRequest,
RegexExecutionResult,
RegexReplacementRequest,
RegexReplacementResult,
} from "../model/match";
import type {
RegexSyntaxRequest,
RegexSyntaxResult,
ReplacementSyntaxResult,
} from "../model/syntax";
import { WorkerRequestError } from "../execution/WorkerSupervisor";
import {
ComparisonOrchestrator,
type ComparisonEngineRunner,
type ComparisonSyntaxRunner,
} from "./ComparisonOrchestrator";
import type {
ComparisonFlavour,
RegexComparisonRequest,
} from "./comparison.types";
function syntax(request: RegexSyntaxRequest): RegexSyntaxResult {
return {
accepted: true,
root: {
id: `${request.flavour}-root`,
kind: "pattern",
range: { startUtf16: 0, endUtf16: request.pattern.length },
raw: request.pattern,
explanation: "Fixture syntax",
children: [],
properties: {
zeroWidth: false,
nullable: "unknown",
consumesInput: "conditional",
},
support: {
flavour: request.flavour,
status: "partial",
notes: [],
},
provenance: {
provider: `${request.flavour}-fixture`,
providerVersion: "1",
source: "parsed",
},
},
tokens: [],
captures: [
{
number: 1,
name: "word",
range: { startUtf16: 0, endUtf16: request.pattern.length },
repeated: false,
},
],
diagnostics: [],
provider: { id: `${request.flavour}-fixture`, version: "1" },
coverage: {
status: "partial",
summary: "Fixture",
unsupportedConstructs: [],
},
};
}
function engineInfo(flavour: ComparisonFlavour): RegexEngineInfo {
return {
flavour,
adapterVersion: "fixture",
engineName:
flavour === "ecmascript" ? "Native ECMAScript RegExp" : "PCRE2 WASM",
engineVersion: flavour === "ecmascript" ? "fixture-browser" : "10.47",
offsetUnit: flavour === "ecmascript" ? "utf16" : "utf8-byte",
capabilities: {
compilation: true,
matching: true,
replacement: true,
namedCaptures: true,
captureHistory: false,
actualTrace: false,
benchmark: false,
},
};
}
function execution(request: RegexExecutionRequest): RegexExecutionResult {
const nativeEnd = request.flavour === "pcre2" ? 5 : 4;
return {
accepted: true,
engine: engineInfo(request.flavour as ComparisonFlavour),
flags: {
userFlags: request.flags.join(""),
effectiveFlags: request.flags.join(""),
internallyAddedIndicesFlag: request.flavour === "ecmascript",
internallyAddedGlobalFlag: false,
},
matches: [
{
matchNumber: 1,
value: "café",
valueStatus: "complete",
range: { startUtf16: 0, endUtf16: 4 },
nativeRange: {
start: 0,
end: nativeEnd,
unit: request.flavour === "pcre2" ? "utf8-byte" : "utf16",
},
captures: [
{
groupNumber: 1,
groupName: "word",
value: "café",
status: "participated",
range: { startUtf16: 0, endUtf16: 4 },
nativeRange: {
start: 0,
end: nativeEnd,
unit: request.flavour === "pcre2" ? "utf8-byte" : "utf16",
},
},
],
},
],
diagnostics: [],
elapsedMs: 1,
truncated: false,
};
}
function request(
operation: "match" | "replace" = "match",
): RegexComparisonRequest {
return {
schemaVersion: 1,
patternModel: "shared",
operation,
subject: "café",
scanAll: true,
maximumMatches: 100,
maximumCaptureRows: 1_000,
maximumOutputBytes: 1_024,
timeoutMs: 500,
sides: [
{
flavour: "ecmascript",
flavourVersion: "ECMAScript 2025 syntax / current browser runtime",
pattern: "(?<word>.+)",
flags: ["g", "u"],
options: {},
...(operation === "replace" ? { replacement: "$<word>!" } : {}),
},
{
flavour: "pcre2",
flavourVersion: "PCRE2 10.47 8-bit WebAssembly",
pattern: "(?<word>.+)",
flags: ["g"],
options: {
matchLimit: 1_000_000,
depthLimit: 1_000,
heapLimitKib: 32_768,
},
...(operation === "replace" ? { replacement: "${word}!" } : {}),
},
],
};
}
function syntaxRunner(
parsePattern = async (value: RegexSyntaxRequest) => syntax(value),
): ComparisonSyntaxRunner {
return {
parsePattern,
parseReplacement: async (): Promise<ReplacementSyntaxResult> => ({
accepted: true,
tokens: [],
diagnostics: [],
}),
cancel: vi.fn(),
dispose: vi.fn(),
};
}
function engineRunner(
execute = async (value: RegexExecutionRequest) => execution(value),
): ComparisonEngineRunner {
return {
execute,
replace: async (
value: RegexReplacementRequest,
): Promise<RegexReplacementResult> => ({
execution: await execute(value),
output: `${value.subject}!`,
outputBytes: value.subject.length + 1,
outputTruncated: false,
truncated: false,
}),
cancel: vi.fn(),
dispose: vi.fn(),
};
}
describe("ComparisonOrchestrator", () => {
it("retains exact requests and aligns UTF-16 results while preserving native offsets", async () => {
let now = 0;
const runner = engineRunner();
const orchestrator = new ComparisonOrchestrator({
createSyntaxRunner: () => syntaxRunner(),
createEngineRunner: () => runner,
now: () => (now += 1),
isoNow: () => "2026-07-26T12:00:00.000Z",
});
const result = await orchestrator.compare(request());
expect(result.status).toBe("same-for-current-input");
expect(result.notComparable).toEqual([]);
expect(result.matchAlignments).toHaveLength(1);
expect(result.matchAlignments[0]).toMatchObject({
alignment: "normalized-range",
equal: true,
left: {
nativeRange: { end: 4, unit: "utf16" },
},
right: {
nativeRange: { end: 5, unit: "utf8-byte" },
},
});
expect(result.differences.map((difference) => difference.kind)).toEqual([
"effective-flags",
"offset-model",
]);
expect(result.sides[0].executionRequest).toMatchObject({
flavour: "ecmascript",
pattern: "(?<word>.+)",
subject: "café",
captureMetadata: [{ number: 1, name: "word" }],
});
expect(result.sides[1].requestIdentity).toMatch(/^request-[0-9a-f]{8}$/u);
expect(result.notices[0]?.message).toMatch(/not a proof/u);
orchestrator.dispose();
});
it("records an independent timeout instead of interpreting it as no match", async () => {
const runner = engineRunner(async (value) => {
if (value.flavour === "pcre2") {
throw new WorkerRequestError("timeout", "PCRE2 exceeded 500 ms");
}
return execution(value);
});
const orchestrator = new ComparisonOrchestrator({
createSyntaxRunner: () => syntaxRunner(),
createEngineRunner: () => runner,
now: () => 1,
isoNow: () => "2026-07-26T12:00:00.000Z",
});
const result = await orchestrator.compare(request());
expect(result.status).toBe("not-comparable");
expect(result.sides[0].runtime.status).toBe("complete");
expect(result.sides[1].runtime.status).toBe("timeout");
expect(result.notComparable).toContainEqual(
expect.objectContaining({
code: "execution-timeout",
flavour: "pcre2",
}),
);
expect(result.notComparable[0]?.message).toMatch(/not a no-match/u);
orchestrator.dispose();
});
it("keeps per-flavour replacement templates exact", async () => {
const seen: RegexReplacementRequest[] = [];
const runner = engineRunner();
runner.replace = async (value) => {
seen.push(value);
return {
execution: execution(value),
output:
value.flavour === "ecmascript"
? value.replacement
: value.replacement,
outputBytes: value.replacement.length,
outputTruncated: false,
truncated: false,
};
};
const orchestrator = new ComparisonOrchestrator({
createSyntaxRunner: () => syntaxRunner(),
createEngineRunner: () => runner,
now: () => 1,
isoNow: () => "2026-07-26T12:00:00.000Z",
});
const result = await orchestrator.compare(request("replace"));
expect(seen.map((value) => value.replacement)).toEqual([
"$<word>!",
"${word}!",
]);
expect(result.status).toBe("different-for-current-input");
expect(
result.differences.find(
(difference) => difference.kind === "replacement-output",
),
).toMatchObject({
left: "$<word>!",
right: "${word}!",
});
orchestrator.dispose();
});
it("refuses mismatched shared patterns before constructing a semantic result", async () => {
const input = request();
const invalid: RegexComparisonRequest = {
...input,
sides: [input.sides[0], { ...input.sides[1], pattern: "(?<word>\\w+)" }],
};
const orchestrator = new ComparisonOrchestrator({
createSyntaxRunner: () => syntaxRunner(),
createEngineRunner: () => engineRunner(),
now: () => 1,
isoNow: () => "2026-07-26T12:00:00.000Z",
});
await expect(orchestrator.compare(invalid)).rejects.toThrow(
/exact same pattern/u,
);
orchestrator.dispose();
});
});

View File

@@ -0,0 +1,536 @@
import {
AVAILABLE_REGEX_FLAVOURS,
parseRegexFlags,
parseRegexOptions,
resolveRegexFlavourVersion,
} from "../flavours/flavour-registry";
import type {
RegexExecutionRequest,
RegexReplacementRequest,
} from "../model/match";
import type {
RegexSyntaxRequest,
RegexSyntaxResult,
ReplacementSyntaxResult,
} from "../model/syntax";
import {
DEFAULT_REGEX_LIMITS,
utf8ByteLength,
} from "../execution/request-limits";
import { EngineSupervisor } from "../execution/EngineSupervisor";
import { SyntaxSupervisor } from "../execution/SyntaxSupervisor";
import { WorkerRequestError } from "../execution/WorkerSupervisor";
import { compareCompletedResults } from "./compare-results";
import type {
ComparisonExecutionOutcome,
ComparisonFlavour,
ComparisonSideInput,
ComparisonSideOutcome,
ComparisonSyntaxOutcome,
ComparisonTaskFailure,
RegexComparisonRequest,
RegexComparisonResult,
} from "./comparison.types";
export interface ComparisonSyntaxRunner {
parsePattern(
request: RegexSyntaxRequest,
timeoutMs?: number,
): Promise<RegexSyntaxResult>;
parseReplacement(
request: {
readonly flavour: ComparisonFlavour;
readonly replacement: string;
readonly captureMetadata: RegexSyntaxResult["captures"];
},
timeoutMs?: number,
): Promise<ReplacementSyntaxResult>;
cancel(): void;
dispose(): void;
}
export interface ComparisonEngineRunner {
execute(
request: RegexExecutionRequest,
timeoutMs: number,
): ReturnType<EngineSupervisor["execute"]>;
replace(
request: RegexReplacementRequest,
timeoutMs: number,
): ReturnType<EngineSupervisor["replace"]>;
cancel(): void;
dispose(): void;
}
export interface ComparisonOrchestratorDependencies {
readonly createSyntaxRunner: (
flavour: ComparisonFlavour,
) => ComparisonSyntaxRunner;
readonly createEngineRunner: () => ComparisonEngineRunner;
readonly now: () => number;
readonly isoNow: () => string;
}
const DEFAULT_DEPENDENCIES: ComparisonOrchestratorDependencies = {
createSyntaxRunner: (flavour) =>
new SyntaxSupervisor(
`${flavour} comparison syntax`,
`regex-tools-comparison-${flavour}-syntax`,
),
createEngineRunner: () => new EngineSupervisor(),
now: () => performance.now(),
isoNow: () => new Date().toISOString(),
};
interface PreparedSide {
readonly input: ComparisonSideInput;
readonly syntaxRequest: RegexSyntaxRequest;
}
function requireBoundedInteger(
value: number,
label: string,
minimum: number,
maximum: number,
): number {
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
throw new RangeError(
`${label} must be an integer from ${minimum.toLocaleString()} to ${maximum.toLocaleString()}.`,
);
}
return value;
}
function validateSide(input: ComparisonSideInput): PreparedSide {
const definition = AVAILABLE_REGEX_FLAVOURS.require(input.flavour);
const version = resolveRegexFlavourVersion(
input.flavourVersion,
definition,
`${definition.label} comparison version`,
).definition;
const flags = parseRegexFlags(
input.flags,
definition,
`${definition.label} comparison flags`,
);
const options = parseRegexOptions(
input.options,
definition,
`${definition.label} comparison options`,
);
if (input.pattern.length > DEFAULT_REGEX_LIMITS.patternHardLengthUtf16) {
throw new RangeError(
`${definition.label} comparison pattern exceeds the ${DEFAULT_REGEX_LIMITS.patternHardLengthUtf16.toLocaleString()} UTF-16 unit limit.`,
);
}
if (
input.replacement !== undefined &&
input.replacement.length >
DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16
) {
throw new RangeError(
`${definition.label} comparison replacement exceeds the ${DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16.toLocaleString()} UTF-16 unit limit.`,
);
}
const normalized: ComparisonSideInput = {
flavour: input.flavour,
flavourVersion: version.value,
pattern: input.pattern,
flags,
options,
...(input.replacement === undefined
? {}
: { replacement: input.replacement }),
};
return {
input: normalized,
syntaxRequest: {
flavour: normalized.flavour,
flavourVersion: version.syntaxVersion,
pattern: normalized.pattern,
flags: normalized.flags,
options: normalized.options,
},
};
}
export function validateRegexComparisonRequest(
request: RegexComparisonRequest,
): {
readonly request: RegexComparisonRequest;
readonly sides: readonly [PreparedSide, PreparedSide];
} {
if (request.schemaVersion !== 1) {
throw new Error("Unsupported comparison request schema.");
}
if (
request.patternModel !== "shared" &&
request.patternModel !== "variants"
) {
throw new Error("Comparison pattern model is unsupported.");
}
if (request.operation !== "match" && request.operation !== "replace") {
throw new Error("Comparison operation is unsupported.");
}
const flavours = request.sides.map((side) => side.flavour);
if (
new Set(flavours).size !== 2 ||
!flavours.includes("ecmascript") ||
!flavours.includes("pcre2")
) {
throw new Error(
"This comparison vertical requires one ECMAScript and one PCRE2 side.",
);
}
const preparedInput = request.sides.map(validateSide);
const ecmascript = preparedInput.find(
(side) => side.input.flavour === "ecmascript",
);
const pcre2 = preparedInput.find((side) => side.input.flavour === "pcre2");
if (!ecmascript || !pcre2) {
throw new Error("Comparison sides could not be normalized.");
}
const prepared = [ecmascript, pcre2] as const;
if (
request.patternModel === "shared" &&
prepared[0].input.pattern !== prepared[1].input.pattern
) {
throw new Error(
"Shared-pattern comparison must send the exact same pattern to both engines.",
);
}
if (
request.operation === "replace" &&
prepared.some((side) => side.input.replacement === undefined)
) {
throw new Error(
"Replacement comparison requires an explicit replacement for each flavour.",
);
}
if (
utf8ByteLength(request.subject) >
DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes
) {
throw new RangeError(
`Comparison subject exceeds the ${DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes.toLocaleString()} UTF-8 byte limit.`,
);
}
const maximumMatches = requireBoundedInteger(
request.maximumMatches,
"Comparison match limit",
1,
DEFAULT_REGEX_LIMITS.maximumMatches,
);
const maximumCaptureRows = requireBoundedInteger(
request.maximumCaptureRows,
"Comparison capture-row limit",
1,
DEFAULT_REGEX_LIMITS.maximumCaptureRows,
);
const maximumOutputBytes = requireBoundedInteger(
request.maximumOutputBytes,
"Comparison replacement-output limit",
1,
DEFAULT_REGEX_LIMITS.maximumReplacementOutputBytes,
);
const timeoutMs = requireBoundedInteger(
request.timeoutMs,
"Comparison side timeout",
1,
DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs,
);
const normalized: RegexComparisonRequest = {
...request,
scanAll: request.scanAll === true,
maximumMatches,
maximumCaptureRows,
maximumOutputBytes,
timeoutMs,
sides: [prepared[0].input, prepared[1].input],
};
return { request: normalized, sides: prepared };
}
function classifyFailure(error: unknown): ComparisonTaskFailure {
if (error instanceof WorkerRequestError) {
if (error.kind === "timeout") {
return { status: "timeout", message: error.message };
}
if (error.kind === "cancelled") {
return { status: "cancelled", message: error.message };
}
}
return {
status: "worker-failed",
message: error instanceof Error ? error.message : String(error),
};
}
function stableOptions(options: ComparisonSideInput["options"]): string {
return JSON.stringify(
Object.fromEntries(
Object.entries(options).sort(([left], [right]) =>
left.localeCompare(right),
),
),
);
}
function requestIdentity(
request: RegexExecutionRequest | RegexReplacementRequest,
): string {
const replacement =
"replacement" in request ? `\u0000${request.replacement}` : "";
const value = [
request.flavour,
request.flavourVersion ?? "",
request.pattern,
request.flags.join(""),
stableOptions(request.options ?? {}),
request.subject,
String(request.scanAll),
String(request.maximumMatches),
String(request.maximumCaptureRows),
JSON.stringify(request.captureMetadata),
"maximumOutputBytes" in request ? String(request.maximumOutputBytes) : "",
replacement,
].join("\u0001");
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return `request-${(hash >>> 0).toString(16).padStart(8, "0")}`;
}
function executionRequest(
request: RegexComparisonRequest,
prepared: PreparedSide,
captures: RegexSyntaxResult["captures"],
): RegexExecutionRequest {
return {
flavour: prepared.input.flavour,
flavourVersion: prepared.input.flavourVersion,
pattern: prepared.input.pattern,
flags: prepared.input.flags,
options: prepared.input.options,
subject: request.subject,
captureMetadata: captures,
scanAll: request.scanAll,
maximumMatches: request.maximumMatches,
maximumCaptureRows: request.maximumCaptureRows,
};
}
export class ComparisonOrchestrator {
readonly #dependencies: ComparisonOrchestratorDependencies;
readonly #syntax: ReadonlyMap<ComparisonFlavour, ComparisonSyntaxRunner>;
readonly #engine: ComparisonEngineRunner;
#generation = 0;
#disposed = false;
constructor(
dependencies: ComparisonOrchestratorDependencies = DEFAULT_DEPENDENCIES,
) {
this.#dependencies = dependencies;
this.#syntax = new Map(
(["ecmascript", "pcre2"] as const).map((flavour) => [
flavour,
dependencies.createSyntaxRunner(flavour),
]),
);
this.#engine = dependencies.createEngineRunner();
}
async compare(input: RegexComparisonRequest): Promise<RegexComparisonResult> {
if (this.#disposed) {
throw new Error("Comparison orchestrator has been disposed.");
}
this.cancel();
const generation = this.#generation;
const validated = validateRegexComparisonRequest(input);
const startedAt = this.#dependencies.isoNow();
const start = this.#dependencies.now();
const settled = await Promise.allSettled(
validated.sides.map((side) =>
this.#runSide(validated.request, side, generation),
),
);
const outcomes = settled.map((entry, index) => {
if (entry.status === "fulfilled") return entry.value;
const prepared = validated.sides[index];
if (!prepared) throw entry.reason;
return this.#failedSide(
validated.request,
prepared,
classifyFailure(entry.reason),
);
}) as unknown as readonly [ComparisonSideOutcome, ComparisonSideOutcome];
return compareCompletedResults(
validated.request,
startedAt,
this.#dependencies.now() - start,
outcomes,
);
}
cancel(): void {
this.#generation += 1;
for (const syntax of this.#syntax.values()) syntax.cancel();
this.#engine.cancel();
}
dispose(): void {
if (this.#disposed) return;
this.cancel();
this.#disposed = true;
for (const syntax of this.#syntax.values()) syntax.dispose();
this.#engine.dispose();
}
async #runSide(
request: RegexComparisonRequest,
prepared: PreparedSide,
generation: number,
): Promise<ComparisonSideOutcome> {
const syntaxRunner = this.#syntax.get(prepared.input.flavour);
if (!syntaxRunner) {
throw new Error(`Missing ${prepared.input.flavour} syntax runner.`);
}
const syntaxStart = this.#dependencies.now();
let patternSyntax: RegexSyntaxResult | undefined;
let replacementSyntax: ReplacementSyntaxResult | undefined;
let syntaxFailure: ComparisonTaskFailure | undefined;
try {
patternSyntax = await syntaxRunner.parsePattern(
prepared.syntaxRequest,
Math.min(1_000, request.timeoutMs),
);
if (request.operation === "replace") {
replacementSyntax = await syntaxRunner.parseReplacement(
{
flavour: prepared.input.flavour,
replacement: prepared.input.replacement ?? "",
captureMetadata: patternSyntax.captures,
},
Math.min(1_000, request.timeoutMs),
);
}
} catch (error) {
syntaxFailure = classifyFailure(error);
}
const syntax: ComparisonSyntaxOutcome = {
status: syntaxFailure?.status ?? "complete",
elapsedMs: this.#dependencies.now() - syntaxStart,
...(patternSyntax ? { pattern: patternSyntax } : {}),
...(replacementSyntax ? { replacement: replacementSyntax } : {}),
...(syntaxFailure ? { error: syntaxFailure.message } : {}),
};
const exactExecution = executionRequest(
request,
prepared,
patternSyntax?.captures ?? [],
);
const exactReplacement: RegexReplacementRequest | undefined =
request.operation === "replace"
? {
...exactExecution,
replacement: prepared.input.replacement ?? "",
maximumOutputBytes: request.maximumOutputBytes,
}
: undefined;
if (generation !== this.#generation) {
const runtime: ComparisonExecutionOutcome = {
status: "cancelled",
elapsedMs: 0,
error: "Comparison was cancelled before engine execution.",
};
return {
requestIdentity: requestIdentity(exactReplacement ?? exactExecution),
input: prepared.input,
syntaxRequest: prepared.syntaxRequest,
executionRequest: exactExecution,
...(exactReplacement ? { replacementRequest: exactReplacement } : {}),
syntax,
runtime,
};
}
const runtimeStart = this.#dependencies.now();
let runtime: ComparisonExecutionOutcome;
try {
if (exactReplacement) {
const replacement = await this.#engine.replace(
exactReplacement,
request.timeoutMs,
);
runtime = {
status: "complete",
elapsedMs: this.#dependencies.now() - runtimeStart,
replacement,
};
} else {
const execution = await this.#engine.execute(
exactExecution,
request.timeoutMs,
);
runtime = {
status: "complete",
elapsedMs: this.#dependencies.now() - runtimeStart,
execution,
};
}
} catch (error) {
const failure = classifyFailure(error);
runtime = {
status: failure.status,
elapsedMs: this.#dependencies.now() - runtimeStart,
error: failure.message,
};
}
const execution = runtime.replacement?.execution ?? runtime.execution;
return {
requestIdentity: requestIdentity(exactReplacement ?? exactExecution),
input: prepared.input,
syntaxRequest: prepared.syntaxRequest,
executionRequest: exactExecution,
...(exactReplacement ? { replacementRequest: exactReplacement } : {}),
syntax,
runtime,
...(execution ? { engine: execution.engine } : {}),
};
}
#failedSide(
request: RegexComparisonRequest,
prepared: PreparedSide,
failure: ComparisonTaskFailure,
): ComparisonSideOutcome {
const exactExecution = executionRequest(request, prepared, []);
const exactReplacement =
request.operation === "replace"
? {
...exactExecution,
replacement: prepared.input.replacement ?? "",
maximumOutputBytes: request.maximumOutputBytes,
}
: undefined;
return {
requestIdentity: requestIdentity(exactReplacement ?? exactExecution),
input: prepared.input,
syntaxRequest: prepared.syntaxRequest,
executionRequest: exactExecution,
...(exactReplacement ? { replacementRequest: exactReplacement } : {}),
syntax: {
status: "worker-failed",
elapsedMs: 0,
error: "Comparison side failed before syntax completion.",
},
runtime: {
status: failure.status,
elapsedMs: 0,
error: failure.message,
},
};
}
}

View File

@@ -0,0 +1,666 @@
import type {
CaptureResult,
RegexExecutionResult,
RegexMatchResult,
} from "../model/match";
import type {
CaptureAlignment,
ComparisonDifference,
ComparisonDifferenceKind,
ComparisonFlavour,
ComparisonNotComparableReason,
ComparisonSideOutcome,
MatchAlignment,
RegexComparisonRequest,
RegexComparisonResult,
} from "./comparison.types";
const MAXIMUM_RETAINED_DIFFERENCES = 5_000;
const MAXIMUM_RETAINED_MATCH_ALIGNMENTS = 2_000;
const MAXIMUM_DIFFERENCE_VALUE_UTF16 = 1_024;
function differenceValue(value: string | undefined): string | undefined {
if (value === undefined || value.length <= MAXIMUM_DIFFERENCE_VALUE_UTF16) {
return value;
}
return `${value.slice(0, MAXIMUM_DIFFERENCE_VALUE_UTF16)}… (${value.length.toLocaleString()} UTF-16 units; complete value retained in its side result)`;
}
class DifferenceCollector {
readonly retained: ComparisonDifference[] = [];
total = 0;
semanticTotal = 0;
add(
kind: ComparisonDifferenceKind,
summary: string,
left?: string,
right?: string,
): void {
this.total += 1;
if (kind !== "effective-flags" && kind !== "offset-model") {
this.semanticTotal += 1;
}
if (this.retained.length >= MAXIMUM_RETAINED_DIFFERENCES) return;
this.retained.push({
kind,
summary,
...(left === undefined ? {} : { left }),
...(right === undefined ? {} : { right }),
});
}
}
function rangeKey(
value:
| {
readonly range?: {
readonly startUtf16: number;
readonly endUtf16: number;
};
}
| undefined,
): string | undefined {
const range = value?.range;
return range ? `${range.startUtf16}:${range.endUtf16}` : undefined;
}
function displayRange(
value:
| {
readonly range?: {
readonly startUtf16: number;
readonly endUtf16: number;
};
}
| undefined,
): string {
const range = value?.range;
return range ? `${range.startUtf16}${range.endUtf16}` : "unavailable";
}
function captureIdentity(capture: CaptureResult): string {
return capture.groupName
? `name:${capture.groupName}`
: `number:${capture.groupNumber}`;
}
function takeMatching<T>(
values: readonly T[],
used: Set<number>,
predicate: (value: T) => boolean,
): { readonly value: T; readonly index: number } | undefined {
for (let index = 0; index < values.length; index += 1) {
if (used.has(index)) continue;
const value = values[index];
if (value !== undefined && predicate(value)) return { value, index };
}
return undefined;
}
function captureEqual(left: CaptureResult, right: CaptureResult): boolean {
return (
left.groupNumber === right.groupNumber &&
left.groupName === right.groupName &&
left.status === right.status &&
rangeKey(left) === rangeKey(right) &&
left.value === right.value
);
}
function compareCapture(
left: CaptureResult | undefined,
right: CaptureResult | undefined,
alignment: CaptureAlignment["alignment"],
differences: DifferenceCollector,
): CaptureAlignment {
if (!left || !right) {
differences.add(
"capture-presence",
"A capture is present on only one side.",
left ? captureIdentity(left) : "absent",
right ? captureIdentity(right) : "absent",
);
return {
alignment,
...(left ? { left } : {}),
...(right ? { right } : {}),
equal: false,
};
}
if (
left.groupNumber !== right.groupNumber ||
left.groupName !== right.groupName
) {
differences.add(
"capture-identity",
"Aligned captures have different group identities.",
captureIdentity(left),
captureIdentity(right),
);
}
if (left.status !== right.status) {
differences.add(
"capture-status",
"Aligned captures have different participation states.",
left.status,
right.status,
);
}
if (rangeKey(left) !== rangeKey(right)) {
differences.add(
"capture-range",
"Aligned captures have different normalized UTF-16 ranges.",
displayRange(left),
displayRange(right),
);
}
if (left.value !== right.value) {
differences.add(
"capture-value",
"Aligned captures have different retained values.",
differenceValue(left.value) ?? "unavailable",
differenceValue(right.value) ?? "unavailable",
);
}
return {
alignment,
left,
right,
equal: captureEqual(left, right),
};
}
function alignCaptures(
left: readonly CaptureResult[],
right: readonly CaptureResult[],
differences: DifferenceCollector,
): readonly CaptureAlignment[] {
const alignments: CaptureAlignment[] = [];
const usedRight = new Set<number>();
const deferredLeft: CaptureResult[] = [];
for (const capture of left) {
const key = rangeKey(capture);
const sameRange = key
? (takeMatching(
right,
usedRight,
(candidate) =>
rangeKey(candidate) === key &&
captureIdentity(candidate) === captureIdentity(capture),
) ??
takeMatching(
right,
usedRight,
(candidate) => rangeKey(candidate) === key,
))
: undefined;
if (!sameRange) {
deferredLeft.push(capture);
continue;
}
usedRight.add(sameRange.index);
alignments.push(
compareCapture(capture, sameRange.value, "normalized-range", differences),
);
}
const stillDeferred: CaptureResult[] = [];
for (const capture of deferredLeft) {
const sameIdentity = takeMatching(
right,
usedRight,
(candidate) => captureIdentity(candidate) === captureIdentity(capture),
);
if (!sameIdentity) {
stillDeferred.push(capture);
continue;
}
usedRight.add(sameIdentity.index);
alignments.push(
compareCapture(
capture,
sameIdentity.value,
"capture-identity",
differences,
),
);
}
const remainingRight = right
.map((value, index) => ({ value, index }))
.filter(({ index }) => !usedRight.has(index));
const paired = Math.min(stillDeferred.length, remainingRight.length);
for (let index = 0; index < paired; index += 1) {
const leftCapture = stillDeferred[index];
const rightCapture = remainingRight[index];
if (!leftCapture || !rightCapture) continue;
usedRight.add(rightCapture.index);
alignments.push(
compareCapture(
leftCapture,
rightCapture.value,
"ordinal-fallback",
differences,
),
);
}
for (const capture of stillDeferred.slice(paired)) {
alignments.push(
compareCapture(capture, undefined, "left-only", differences),
);
}
for (const { value, index } of remainingRight.slice(paired)) {
usedRight.add(index);
alignments.push(
compareCapture(undefined, value, "right-only", differences),
);
}
return alignments;
}
function matchEqual(
left: RegexMatchResult,
right: RegexMatchResult,
captures: readonly CaptureAlignment[],
): boolean {
return (
rangeKey(left) === rangeKey(right) &&
left.value === right.value &&
left.valueStatus === right.valueStatus &&
captures.every((capture) => capture.equal)
);
}
function compareMatch(
left: RegexMatchResult | undefined,
right: RegexMatchResult | undefined,
alignment: MatchAlignment["alignment"],
differences: DifferenceCollector,
): MatchAlignment {
if (!left || !right) {
differences.add(
"match-presence",
"A match is present on only one side.",
left ? `match ${left.matchNumber} at ${displayRange(left)}` : "absent",
right ? `match ${right.matchNumber} at ${displayRange(right)}` : "absent",
);
return {
alignment,
...(left ? { left } : {}),
...(right ? { right } : {}),
captures: [],
equal: false,
};
}
if (rangeKey(left) !== rangeKey(right)) {
differences.add(
"match-range",
"Aligned matches have different normalized UTF-16 ranges.",
displayRange(left),
displayRange(right),
);
}
if (left.value !== right.value || left.valueStatus !== right.valueStatus) {
differences.add(
"match-value",
"Aligned matches have different retained values.",
differenceValue(left.value),
differenceValue(right.value),
);
}
const captures = alignCaptures(left.captures, right.captures, differences);
return {
alignment,
left,
right,
captures,
equal: matchEqual(left, right, captures),
};
}
function alignMatches(
left: readonly RegexMatchResult[],
right: readonly RegexMatchResult[],
differences: DifferenceCollector,
): readonly MatchAlignment[] {
const alignments: MatchAlignment[] = [];
const usedRight = new Set<number>();
const deferredLeft: RegexMatchResult[] = [];
for (const match of left) {
const key = rangeKey(match);
const sameRange = takeMatching(
right,
usedRight,
(candidate) => rangeKey(candidate) === key,
);
if (!sameRange) {
deferredLeft.push(match);
continue;
}
usedRight.add(sameRange.index);
alignments.push(
compareMatch(match, sameRange.value, "normalized-range", differences),
);
}
const remainingRight = right
.map((value, index) => ({ value, index }))
.filter(({ index }) => !usedRight.has(index));
const paired = Math.min(deferredLeft.length, remainingRight.length);
for (let index = 0; index < paired; index += 1) {
const leftMatch = deferredLeft[index];
const rightMatch = remainingRight[index];
if (!leftMatch || !rightMatch) continue;
usedRight.add(rightMatch.index);
alignments.push(
compareMatch(
leftMatch,
rightMatch.value,
"ordinal-fallback",
differences,
),
);
}
for (const match of deferredLeft.slice(paired)) {
alignments.push(compareMatch(match, undefined, "left-only", differences));
}
for (const { value, index } of remainingRight.slice(paired)) {
usedRight.add(index);
alignments.push(compareMatch(undefined, value, "right-only", differences));
}
return alignments;
}
function executionFor(
side: ComparisonSideOutcome,
): RegexExecutionResult | undefined {
return side.runtime.replacement?.execution ?? side.runtime.execution;
}
function addReason(
values: ComparisonNotComparableReason[],
reason: ComparisonNotComparableReason,
): void {
if (
values.some(
(candidate) =>
candidate.code === reason.code &&
candidate.flavour === reason.flavour &&
candidate.message === reason.message,
)
) {
return;
}
values.push(reason);
}
function taskReasons(
side: ComparisonSideOutcome,
reasons: ComparisonNotComparableReason[],
): void {
const flavour = side.input.flavour;
if (side.syntax.status === "timeout") {
addReason(reasons, {
code: "syntax-timeout",
flavour,
message: `${flavour} syntax parsing timed out.`,
});
} else if (side.syntax.status !== "complete") {
addReason(reasons, {
code: "syntax-worker-failed",
flavour,
message: `${flavour} syntax parsing did not complete: ${side.syntax.error ?? side.syntax.status}.`,
});
} else if (
side.syntax.pattern?.accepted === false ||
side.syntax.replacement?.accepted === false
) {
addReason(reasons, {
code: "syntax-rejected",
flavour,
message: `${flavour} syntax provider rejected the exact pattern or replacement snapshot.`,
});
}
if (side.runtime.status === "timeout") {
addReason(reasons, {
code: "execution-timeout",
flavour,
message: `${flavour} execution timed out; timeout is not a no-match result.`,
});
return;
}
if (side.runtime.status === "cancelled") {
addReason(reasons, {
code: "execution-cancelled",
flavour,
message: `${flavour} execution was cancelled and has no semantic result.`,
});
return;
}
if (side.runtime.status !== "complete") {
addReason(reasons, {
code: "execution-worker-failed",
flavour,
message: `${flavour} worker did not complete: ${side.runtime.error ?? side.runtime.status}.`,
});
return;
}
const execution = executionFor(side);
if (!execution?.accepted) {
addReason(reasons, {
code: "compile-rejected",
flavour,
message: `${flavour} engine rejected the exact pattern snapshot.`,
});
return;
}
if (execution.truncated) {
addReason(reasons, {
code: "result-truncated",
flavour,
message: `${flavour} results reached a configured match or capture limit.`,
});
}
if (
execution.matches.some(
(match) =>
match.valueStatus === "truncated" ||
match.captures.some((capture) => capture.status === "truncated"),
)
) {
addReason(reasons, {
code: "value-truncated",
flavour,
message: `${flavour} retained value previews are incomplete.`,
});
}
if (side.runtime.replacement?.truncated) {
addReason(reasons, {
code: "replacement-truncated",
flavour,
message: `${flavour} replacement output or its match collection is incomplete.`,
});
}
}
function compareMetadata(
left: ComparisonSideOutcome,
right: ComparisonSideOutcome,
differences: DifferenceCollector,
): void {
const leftSyntax = left.syntax.pattern;
const rightSyntax = right.syntax.pattern;
if (
leftSyntax &&
rightSyntax &&
leftSyntax.accepted !== rightSyntax.accepted
) {
differences.add(
"syntax-acceptance",
"The syntax providers disagree on acceptance.",
leftSyntax.accepted ? "accepted" : "rejected",
rightSyntax.accepted ? "accepted" : "rejected",
);
}
const leftExecution = executionFor(left);
const rightExecution = executionFor(right);
if (
leftExecution &&
rightExecution &&
leftExecution.accepted !== rightExecution.accepted
) {
differences.add(
"compile-acceptance",
"The execution engines disagree on compilation.",
leftExecution.accepted ? "accepted" : "rejected",
rightExecution.accepted ? "accepted" : "rejected",
);
}
if (
leftExecution &&
rightExecution &&
leftExecution.flags.effectiveFlags !== rightExecution.flags.effectiveFlags
) {
differences.add(
"effective-flags",
"The exact effective engine flags differ.",
leftExecution.flags.effectiveFlags || "none",
rightExecution.flags.effectiveFlags || "none",
);
}
if (
leftExecution &&
rightExecution &&
leftExecution.engine.offsetUnit !== rightExecution.engine.offsetUnit
) {
differences.add(
"offset-model",
"Native offset units differ; alignments use normalized editor UTF-16 ranges.",
leftExecution.engine.offsetUnit,
rightExecution.engine.offsetUnit,
);
}
}
export function compareCompletedResults(
request: RegexComparisonRequest,
startedAt: string,
elapsedMs: number,
sides: readonly [ComparisonSideOutcome, ComparisonSideOutcome],
): RegexComparisonResult {
const [left, right] = sides;
const differences = new DifferenceCollector();
const notComparable: ComparisonNotComparableReason[] = [];
taskReasons(left, notComparable);
taskReasons(right, notComparable);
compareMetadata(left, right, differences);
const leftExecution = executionFor(left);
const rightExecution = executionFor(right);
let allAlignments: readonly MatchAlignment[] = [];
if (leftExecution?.accepted && rightExecution?.accepted) {
if (leftExecution.matches.length !== rightExecution.matches.length) {
differences.add(
"match-count",
"The engines returned different match counts.",
leftExecution.matches.length.toLocaleString(),
rightExecution.matches.length.toLocaleString(),
);
}
allAlignments = alignMatches(
leftExecution.matches,
rightExecution.matches,
differences,
);
const leftReplacement = left.runtime.replacement;
const rightReplacement = right.runtime.replacement;
if (
leftReplacement &&
rightReplacement &&
leftReplacement.output !== rightReplacement.output
) {
differences.add(
"replacement-output",
"The engine-native replacement outputs differ.",
differenceValue(leftReplacement.output),
differenceValue(rightReplacement.output),
);
}
}
const syntaxAcceptanceDiffers =
left.syntax.pattern !== undefined &&
right.syntax.pattern !== undefined &&
left.syntax.pattern.accepted !== right.syntax.pattern.accepted;
const compileAcceptanceDiffers =
leftExecution !== undefined &&
rightExecution !== undefined &&
leftExecution.accepted !== rightExecution.accepted;
if (syntaxAcceptanceDiffers || compileAcceptanceDiffers) {
addReason(notComparable, {
code: "flavour-specific-syntax",
message:
"At least one exact snapshot is accepted on only one side; no runtime equivalence claim is possible.",
});
}
const status =
notComparable.length > 0
? "not-comparable"
: differences.semanticTotal === 0
? "same-for-current-input"
: "different-for-current-input";
const notices =
status === "same-for-current-input"
? [
{
confidence: "likely-equivalent-for-input" as const,
message:
"The completed, untruncated results agree for this subject only. This is test evidence, not a proof that the patterns are equivalent.",
},
]
: status === "different-for-current-input"
? [
{
confidence: "requires-manual-review" as const,
message:
"The exact engine results differ for this subject. Review syntax, flags, ranges and captures before porting.",
},
]
: [
{
confidence:
compileAcceptanceDiffers || syntaxAcceptanceDiffers
? ("no-direct-equivalent" as const)
: ("requires-manual-review" as const),
message:
"The run is not comparable because one or more authoritative results are incomplete or rejected.",
},
];
return {
schemaVersion: 1,
request,
startedAt,
elapsedMs,
sides,
status,
notComparable,
differences: differences.retained,
totalDifferences: differences.total,
differencesTruncated: differences.retained.length < differences.total,
matchAlignments: allAlignments.slice(0, MAXIMUM_RETAINED_MATCH_ALIGNMENTS),
totalMatchAlignments: allAlignments.length,
matchAlignmentsTruncated:
allAlignments.length > MAXIMUM_RETAINED_MATCH_ALIGNMENTS,
notices,
};
}
export function comparisonSideLabel(flavour: ComparisonFlavour): string {
return flavour === "ecmascript" ? "ECMAScript" : "PCRE2";
}

View File

@@ -0,0 +1,174 @@
import type { RegexEngineInfo, RegexEngineOptions } from "../model/flavour";
import type {
CaptureResult,
RegexExecutionRequest,
RegexExecutionResult,
RegexMatchResult,
RegexReplacementRequest,
RegexReplacementResult,
} from "../model/match";
import type {
RegexSyntaxRequest,
RegexSyntaxResult,
ReplacementSyntaxResult,
} from "../model/syntax";
export type ComparisonFlavour = "ecmascript" | "pcre2";
export type ComparisonOperation = "match" | "replace";
export type ComparisonPatternModel = "shared" | "variants";
export interface ComparisonSideInput {
readonly flavour: ComparisonFlavour;
readonly flavourVersion: string;
readonly pattern: string;
readonly flags: readonly string[];
readonly options: RegexEngineOptions;
readonly replacement?: string;
}
export interface RegexComparisonRequest {
readonly schemaVersion: 1;
readonly patternModel: ComparisonPatternModel;
readonly operation: ComparisonOperation;
readonly subject: string;
readonly scanAll: boolean;
readonly maximumMatches: number;
readonly maximumCaptureRows: number;
readonly maximumOutputBytes: number;
readonly timeoutMs: number;
readonly sides: readonly [ComparisonSideInput, ComparisonSideInput];
}
export type ComparisonTaskStatus =
"complete" | "timeout" | "cancelled" | "worker-failed";
export interface ComparisonTaskFailure {
readonly status: Exclude<ComparisonTaskStatus, "complete">;
readonly message: string;
}
export interface ComparisonSyntaxOutcome {
readonly status: ComparisonTaskStatus;
readonly elapsedMs: number;
readonly pattern?: RegexSyntaxResult;
readonly replacement?: ReplacementSyntaxResult;
readonly error?: string;
}
export interface ComparisonExecutionOutcome {
readonly status: ComparisonTaskStatus;
readonly elapsedMs: number;
readonly execution?: RegexExecutionResult;
readonly replacement?: RegexReplacementResult;
readonly error?: string;
}
export interface ComparisonSideOutcome {
/**
* A deterministic display key for the exact request. It is not a
* cryptographic digest; the complete request sent to the worker is retained
* below and remains authoritative.
*/
readonly requestIdentity: string;
readonly input: ComparisonSideInput;
readonly syntaxRequest: RegexSyntaxRequest;
readonly executionRequest: RegexExecutionRequest;
readonly replacementRequest?: RegexReplacementRequest;
readonly syntax: ComparisonSyntaxOutcome;
readonly runtime: ComparisonExecutionOutcome;
readonly engine?: RegexEngineInfo;
}
export type ComparisonNotComparableCode =
| "syntax-timeout"
| "syntax-worker-failed"
| "syntax-rejected"
| "flavour-specific-syntax"
| "execution-timeout"
| "execution-cancelled"
| "execution-worker-failed"
| "compile-rejected"
| "result-truncated"
| "replacement-truncated"
| "value-truncated";
export interface ComparisonNotComparableReason {
readonly code: ComparisonNotComparableCode;
readonly flavour?: ComparisonFlavour;
readonly message: string;
}
export type ComparisonDifferenceKind =
| "syntax-acceptance"
| "compile-acceptance"
| "effective-flags"
| "offset-model"
| "match-count"
| "match-presence"
| "match-range"
| "match-value"
| "capture-presence"
| "capture-identity"
| "capture-status"
| "capture-range"
| "capture-value"
| "replacement-output";
export interface ComparisonDifference {
readonly kind: ComparisonDifferenceKind;
readonly summary: string;
readonly left?: string;
readonly right?: string;
}
export type MatchAlignmentKind =
"normalized-range" | "ordinal-fallback" | "left-only" | "right-only";
export type CaptureAlignmentKind =
| "normalized-range"
| "capture-identity"
| "ordinal-fallback"
| "left-only"
| "right-only";
export interface CaptureAlignment {
readonly alignment: CaptureAlignmentKind;
readonly left?: CaptureResult;
readonly right?: CaptureResult;
readonly equal: boolean;
}
export interface MatchAlignment {
readonly alignment: MatchAlignmentKind;
readonly left?: RegexMatchResult;
readonly right?: RegexMatchResult;
readonly captures: readonly CaptureAlignment[];
readonly equal: boolean;
}
export interface ComparisonNotice {
readonly confidence:
| "possible-port"
| "likely-equivalent-for-input"
| "requires-manual-review"
| "no-direct-equivalent";
readonly message: string;
}
export interface RegexComparisonResult {
readonly schemaVersion: 1;
readonly request: RegexComparisonRequest;
readonly startedAt: string;
readonly elapsedMs: number;
readonly sides: readonly [ComparisonSideOutcome, ComparisonSideOutcome];
readonly status:
"not-comparable" | "same-for-current-input" | "different-for-current-input";
readonly notComparable: readonly ComparisonNotComparableReason[];
readonly differences: readonly ComparisonDifference[];
readonly totalDifferences: number;
readonly differencesTruncated: boolean;
readonly matchAlignments: readonly MatchAlignment[];
readonly totalMatchAlignments: number;
readonly matchAlignmentsTruncated: boolean;
readonly notices: readonly ComparisonNotice[];
}

Some files were not shown because too many files have changed in this diff Show More